puzjs
Version:
javascript crossword puzzle file library (.puz parser)
462 lines (397 loc) • 12 kB
JavaScript
;(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof exports === 'object') {
module.exports = factory();
} else {
root.Puz = factory();
}
}(this, function() {
//
// reference: https://code.google.com/archive/p/puz/wikis/FileFormat.wiki
// === ENCODE ===
function numToBytes(len, val) {
val = val || 0;
var result = new Uint8Array(len);
for (var i = len - 1; i >= 0; i -= 1) {
result[i] = val % 256;
val = Math.floor(val / 256);
}
return result;
}
function strToBytes(str, end) {
str = str || '';
if (end === undefined) {
end = '\0';
}
str += end;
var result = new Uint8Array(str.length);
for (var i = 0; i < str.length; i += 1) {
result[i] = str.charCodeAt(i);
}
return result;
}
function concat(byteArrays) {
var totalLength = 0;
byteArrays.forEach(function(bytes) {
totalLength += bytes.length;
});
var result = new Uint8Array(totalLength);
var currentIndex = 0;
byteArrays.forEach(function(bytes) {
result.set(bytes, currentIndex);
currentIndex += bytes.length;
});
return result;
}
function Checksum_02() { return numToBytes(2); } // stub
function FileMagic_0C() { return strToBytes('ACROSS&DOWN'); } // stub
function CIBChecksum_02() { return numToBytes(2); } // stub
function MaskedLowChecksums_04() { return numToBytes(4); } // stub
function MaskedHighChecksums_04() { return numToBytes(4); } // stub
function VersionString_04() { return numToBytes(4); } // stub
function Reserved1C_02() { return numToBytes(2); } // stub
function ScrambledChecksum_02() { return numToBytes(2); } // stub
function Reserved20_0C() { return numToBytes(12); } // stub
function Width_01(puzzle) {
return numToBytes(1, puzzle.grid[0].length);
}
function Height_01(puzzle) {
return numToBytes(1, puzzle.grid.length);
}
function NumClues_02(puzzle) {
var allClues = (puzzle.clues.across.concat(puzzle.clues.down)).filter(function(clue) {
return clue !== undefined;
});
return numToBytes(2, allClues.length);
}
function UnknownBitmask_02() { return numToBytes(2); } // stub
function ScrambledTag_02() {
// indicate that the puzzle is not scrambled by putting 0s here
return numToBytes(2, 0);
}
function BoardSolution(puzzle) {
var solString = '';
puzzle.grid.forEach(function(row) {
row.forEach(function(cell) {
var sol = cell;
if (sol === '.') { // black square, append a '.'
solString += '.';
} else {
// in case of rebus, only append first char
// rebus solutions are handled in the Extras Section
solString += sol.substring(0, 1);
}
});
});
return strToBytes(solString, '');
}
function BoardProgress(puzzle) {
var progressString = '';
puzzle.grid.forEach(function(row) {
row.forEach(function(cell) {
var sol = cell.solution;
if (sol === '.') { // black square, append a '.'
progressString += '.';
} else {
// we don't support user-progress yet, so we append '-' (empty cell)
progressString += '-';
}
});
});
return strToBytes(progressString, '');
}
function Title(puzzle) {
return strToBytes(puzzle.meta.title);
}
function Author(puzzle) {
return strToBytes(puzzle.meta.author);
}
function Copyright(puzzle) {
return strToBytes(puzzle.meta.copyright);
}
function Clues(puzzle) {
// ordered numerically, breaking ties by across before down
var cluesList = [];
var across = puzzle.clues.across;
var down = puzzle.clues.down;
for (var i = 0; i < across.length || i < down.length; i += 1) {
if (across[i]) {
cluesList.push(across[i]);
}
if (down[i]) {
cluesList.push(down[i]);
}
}
var byteArrays = cluesList.map(function(clue) {
return strToBytes(clue);
});
return concat(byteArrays);
}
function Notes(puzzle) {
return strToBytes(puzzle.meta.copyright);
}
function Extension(code, bytes) {
var codeBytes = strToBytes(code, ''); // must be length 4
var length = bytes.length;
var lengthBytes = new Uint8Array(2);
lengthBytes[0] = Math.floor(length / 256);
lengthBytes[1] = length % 256;
var checksum = new Uint8Array(2); // TODO implement this stuff
var header = concat([codeBytes, lengthBytes, checksum]);
var result = concat([header, bytes]);
return result;
}
function Rebus(puzzle) {
var table = [];
var sols = [];
var idx = 0;
puzzle.grid.forEach(function(row) {
row.forEach(function(cell) {
if (cell && cell.length > 1) {
var sol = cell;
if (sols.indexOf(sol) === -1) {
sols.push(sol);
}
table[idx] = sols.indexOf(sol) + 1;
}
idx += 1;
});
});
var grbs = new Uint8Array(puzzle.grid.length * puzzle.grid[0].length);
table.forEach(function(v, i) {
grbs[i] = v;
});
var enc = new TextEncoder('ISO-8859-1');
var solstring = sols.map(function(sol, i) {
return i + ':' + sol;
}).join(';') + ';';
var rtbl = enc.encode(solstring);
// dict string format is k1:v1;k2:v2;...;kn:vn;
if (sols.length) {
return concat([Extension('GRBS', grbs), Extension('RTBL', rtbl)]);
}
}
function Circles(puzzle) {
var circles = puzzle.circles || [];
var shades = puzzle.shades || [];
if (circles.length + shades.length > 0) {
var markup = new Uint8Array(puzzle.grid.length * puzzle.grid[0].length);
circles.forEach(function(i) {
markup[i] = markup[i] | 128;
});
shades.forEach(function(i) {
markup[i] = markup[i] | 8;
});
return Extension('GEXT', markup);
}
}
// .puz format documentation: https://code.google.com/archive/p/puz/wikis/FileFormat.wiki
var format = [
// ===== Header
//
Checksum_02, // offset 0
FileMagic_0C, // offset 2
CIBChecksum_02, // offset 14
MaskedLowChecksums_04, // offset 16
MaskedHighChecksums_04, // offset 20
VersionString_04, // offset 24
Reserved1C_02, // offset 28
ScrambledChecksum_02, // offset 30
Reserved20_0C, // offset 32
Width_01, // offset 44
Height_01, // offset 45
NumClues_02, // offset 46
UnknownBitmask_02, // offset 48
ScrambledTag_02, // offset 50
// ===== Puzzle Layout
//
// SIZE = width*height
BoardSolution, // offset 52
BoardProgress, // offset 52+SIZE
// ===== Strings
//
// Starts at offset 52 + 2 * SIZE,
// Each string ends with a NUL byte
Title,
Author,
Copyright,
Clues,
Notes,
// ===== Extra Sections
Rebus,
Circles,
];
// === DECODE ===
var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }();
function getExtension(bytes, code) {
console.log('getExtension', code);
console.log('=', code.charCodeAt(0), code.charCodeAt(1), code.charCodeAt(2), code.charCodeAt(3));
// struct byte format is 4S H H
var i = 0,
j = 0;
for (i = 0; i < bytes.length; i += 1) {
if (j === code.length) break;
if (bytes[i] === code.charCodeAt(j)) {
j += 1;
} else {
j = 0;
}
}
if (j === code.length) {
// we found the code
var length = bytes[i] * 256 + bytes[i + 1];
i += 4; // skip the H H
return Array.from(bytes).slice(i, i + length);
}
return null; // could not find
}
function getRebus(bytes) {
var grbs = 'GRBS';
var rtbl = 'RTBL';
var table = getExtension(bytes, grbs);
if (!table) {
return; // no rebus
}
var solbytes = getExtension(bytes, rtbl);
var enc = new TextDecoder('ISO-8859-1');
var solstring = enc.decode(new Uint8Array(solbytes));
if (!solstring) {
return;
}
var sols = {};
solstring.split(';').forEach(function (s) {
var tokens = s.split(':');
if (tokens.length === 2) {
var _tokens = _slicedToArray(tokens, 2),
key = _tokens[0],
val = _tokens[1];
sols[parseInt(key.trim(), 10)] = val;
}
});
// dict string format is k1:v1;k2:v2;...;kn:vn;
return { table: table, sols: sols };
}
function getCircles(bytes) {
var circles = [];
var gext = 'GEXT';
var markups = getExtension(bytes, gext);
if (markups) {
markups.forEach(function (byte, i) {
if (byte & 128) {
console.log(byte, i);
circles.push(i);
}
});
}
return circles;
}
function getShades(bytes) {
var shades = [];
var gext = 'GEXT';
var markups = getExtension(bytes, gext);
if (markups) {
markups.forEach(function (byte, i) {
if (byte & 8) {
shades.push(i);
}
});
}
console.log('shades', shades);
return shades;
}
function addRebusToGrid(grid, rebus) {
return grid.map(function (row, i) {
return row.map(function (cell, j) {
var idx = i * row.length + j;
if (rebus.table[idx]) {
return _extends({}, cell, {
solution: rebus.sols[rebus.table[idx] - 1]
});
}
return cell;
});
});
}
function PUZtoJSON(buffer) {
var grid = [];
var info = {};
var across = [];
var down = [];
var bytes = new Uint8Array(buffer);
var ncol = bytes[44];
var nrow = bytes[45];
if (!(bytes[50] === 0 && bytes[51] === 0)) {
throw new Error('Scrambled PUZ file');
}
for (var i = 0; i < nrow; i++) {
grid[i] = [];
for (var j = 0; j < ncol; j++) {
var letter = String.fromCharCode(bytes[52 + i * ncol + j]);
grid[i][j] = letter;
}
}
function isBlack(i, j) {
return i < 0 || j < 0 || i >= nrow || j >= ncol || grid[i][j] === '.';
}
var isAcross = [];
var isDown = [];
var n = 0;
for (var _i = 0; _i < nrow; _i++) {
for (var _j = 0; _j < ncol; _j++) {
if (grid[_i][_j] !== '.') {
var isAcrossStart = isBlack(_i, _j - 1) && !isBlack(_i, _j + 1);
var isDownStart = isBlack(_i - 1, _j) && !isBlack(_i + 1, _j);
if (isAcrossStart || isDownStart) {
n += 1;
isAcross[n] = isAcrossStart;
isDown[n] = isDownStart;
}
}
}
}
var ibyte = 52 + ncol * nrow * 2;
function readString() {
var result = "";
var b = bytes[ibyte++];
while (b !== 0) {
result += String.fromCharCode(b);
b = bytes[ibyte++];
}
return result;
}
info.title = readString();
info.author = readString();
info.copyright = readString();
for (var _i2 = 1; _i2 <= n; _i2++) {
if (isAcross[_i2]) {
across[_i2] = readString();
}
if (isDown[_i2]) {
down[_i2] = readString();
}
}
info.description = readString();
var rebus = getRebus(bytes);
var circles = getCircles(bytes);
var shades = getShades(bytes);
if (rebus) {
grid = addRebusToGrid(grid, rebus);
}
return { grid: grid, meta: info, circles: circles, shades: shades, clues: { across: across, down: down } };
};
// === EXPORT ===
var puz = {
// returns a Uint8Array containing the bytes in .puz format
encode: function(puzzle) {
return concat(format.map(function(fn) {
return fn(puzzle) || new Uint8Array(0);
}));
},
decode: function(bytes) {
return PUZtoJSON(bytes);
},
}
return Puz;
}));