wip: initial up
This commit is contained in:
parent
ea8d6d1c8d
commit
14d9f8b10e
19 changed files with 6764 additions and 0 deletions
5
.babelrc
Normal file
5
.babelrc
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"presets": ["@babel/preset-env"],
|
||||||
|
"comments": false,
|
||||||
|
"plugins": ["@babel/plugin-proposal-optional-chaining"]
|
||||||
|
}
|
4
.eslintrc.js
Normal file
4
.eslintrc.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
module.exports = {
|
||||||
|
extends: ["@tophat"],
|
||||||
|
parser: "babel-eslint"
|
||||||
|
}
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
*.swp
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
lib
|
13
README.md
Normal file
13
README.md
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# AcrossDownJS
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
__AcrossDownJS__ is a parser / conversion library that tackles the AcrossLite crosswords format. Its primary objective is to provide a trim, dependency-free tool to manipulate `puz` files both in the browser and via NodeJS; as such, it only uses language features with good browser support. This is also a documentation effort to try and determine what the AcrossLite specification is, since complete information on it is rather hard to find online.
|
||||||
|
|
||||||
|
## Specification
|
||||||
|
|
||||||
|
The specification living-document can be found [here](./format_specs.md).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Know a lot about crossword binary formats? Interested in staring longingly at bytes to try and guess what they mean? Open a PR! Any help is welcome on both the library and specification side of things.
|
28
format_specs.md
Normal file
28
format_specs.md
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# `puz` format specifications
|
||||||
|
|
||||||
|
## Foreword
|
||||||
|
|
||||||
|
The AcrossLite format is not publicly documented. This specification was built by probing dozens of `puz` files and is a living document that will be update as new sections of the format are figured out.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
The `puz` file format is a binary blob with `latin1` encoding.
|
||||||
|
|
||||||
|
### Header
|
||||||
|
|
||||||
|
The header section is consistently 52 bytes long.
|
||||||
|
|
||||||
|
|Label|Start offset|Length (bytes)|Notes|
|
||||||
|
|:----|:----|:----|:----|
|
||||||
|
|Puzzle width|`0x2c`|1|Self-explanatory.|
|
||||||
|
|Puzzle height|`0x2d`|1|Self-explanatory.|
|
||||||
|
|
||||||
|
### Body
|
||||||
|
|
||||||
|
The body of the puzzle starts consistently at offset `0x34`.
|
||||||
|
|
||||||
|
|Label|Start offset|Length (bytes)|Notes|
|
||||||
|
|:----|:----|:----|:----|
|
||||||
|
|Solution|`0x34`|`width * height`|Based on the height and width, the solution is written out as ASCII.|
|
||||||
|
|Layout|(end of the solution)|`width * height`|The layout of the grid is defined with `.` used as "empty cell"|
|
||||||
|
|Clues|(end of the layout)|???|`NUL` separated strings containing each of the clues, not numbered.|
|
33
package.json
Normal file
33
package.json
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "puzparse",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint src",
|
||||||
|
"test": "jest src",
|
||||||
|
"build": "babel src --out-dir lib"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/cli": "^7.8.4",
|
||||||
|
"@babel/plugin-proposal-optional-chaining": "^7.8.3",
|
||||||
|
"@babel/plugin-syntax-optional-chaining": "^7.8.3",
|
||||||
|
"@babel/preset-env": "^7.11.0",
|
||||||
|
"@tophat/eslint-config": "^0.6.0",
|
||||||
|
"ajv": "^6.12.0",
|
||||||
|
"babel-eslint": "^10.1.0",
|
||||||
|
"eslint": "^6.8.0",
|
||||||
|
"eslint-config-prettier": "^6.10.0",
|
||||||
|
"eslint-plugin-import": "^2.20.1",
|
||||||
|
"eslint-plugin-jest": "^23.8.2",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||||
|
"eslint-plugin-prettier": "^3.1.2",
|
||||||
|
"eslint-plugin-react": "^7.19.0",
|
||||||
|
"eslint-plugin-react-hooks": "^2.5.0",
|
||||||
|
"jest": "^25.1.0",
|
||||||
|
"prettier": "^1.19.1"
|
||||||
|
}
|
||||||
|
}
|
BIN
src/.Parser.js.swn
Normal file
BIN
src/.Parser.js.swn
Normal file
Binary file not shown.
BIN
src/.Parser.js.swo
Normal file
BIN
src/.Parser.js.swo
Normal file
Binary file not shown.
BIN
src/.Parser.test.js.swo
Normal file
BIN
src/.Parser.test.js.swo
Normal file
Binary file not shown.
135
src/Parser.js
Normal file
135
src/Parser.js
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
import Puzzle from './Puzzle'
|
||||||
|
import {
|
||||||
|
BODY_OFFSET,
|
||||||
|
GRID_EXTRA_MARKER,
|
||||||
|
HEIGHT_OFFSET,
|
||||||
|
NUL_MARKER,
|
||||||
|
WIDTH_OFFSET,
|
||||||
|
} from './constants'
|
||||||
|
|
||||||
|
class PuzzleParser {
|
||||||
|
constructor({ verbose = false } = {}) {
|
||||||
|
this.verbose = verbose
|
||||||
|
}
|
||||||
|
|
||||||
|
_processHeader(headerBytes) {
|
||||||
|
const width = headerBytes.charCodeAt(WIDTH_OFFSET)
|
||||||
|
const height = headerBytes.charCodeAt(HEIGHT_OFFSET)
|
||||||
|
|
||||||
|
const headerData = { height, width }
|
||||||
|
if (this.verbose) {
|
||||||
|
console.debug('[header]', headerData)
|
||||||
|
console.debug('[header]', Array(headerBytes))
|
||||||
|
}
|
||||||
|
return headerData
|
||||||
|
}
|
||||||
|
|
||||||
|
_processBody(bodyBytes, width, height) {
|
||||||
|
const rowRegexp = new RegExp(`.{${width}}`, 'g')
|
||||||
|
const solutionsLength = width * height
|
||||||
|
const layoutOffsetEnd = solutionsLength * 2
|
||||||
|
const solutionsString = bodyBytes
|
||||||
|
.slice(0, solutionsLength)
|
||||||
|
.match(rowRegexp)
|
||||||
|
const layoutString = bodyBytes
|
||||||
|
.slice(solutionsLength, solutionsLength * 2)
|
||||||
|
.match(rowRegexp)
|
||||||
|
const cluesString = bodyBytes
|
||||||
|
.slice(solutionsLength * 2)
|
||||||
|
.split(NUL_MARKER)
|
||||||
|
const authors = cluesString.slice(0, 3)
|
||||||
|
const clues = cluesString.slice(3)
|
||||||
|
const annotatedLayout = this._deriveClueAssignment(layoutString)
|
||||||
|
const annotatedClues = this._categorizeClues(annotatedLayout, clues)
|
||||||
|
|
||||||
|
return {
|
||||||
|
solutions: solutionsString,
|
||||||
|
annotatedLayout,
|
||||||
|
annotatedClues,
|
||||||
|
authors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(data) {
|
||||||
|
const headerBytes = data.slice(0, BODY_OFFSET)
|
||||||
|
const bodyBytes = data.slice(BODY_OFFSET)
|
||||||
|
const { width, height } = this._processHeader(headerBytes)
|
||||||
|
const {
|
||||||
|
authors,
|
||||||
|
annotatedClues,
|
||||||
|
annotatedLayout,
|
||||||
|
solutions,
|
||||||
|
} = this._processBody(bodyBytes, width, height)
|
||||||
|
return new Puzzle(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
authors,
|
||||||
|
annotatedClues,
|
||||||
|
annotatedLayout,
|
||||||
|
solutions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_deriveClueAssignment(grid) {
|
||||||
|
let currentClue = 1
|
||||||
|
|
||||||
|
return grid.reduce((annotatedGrid, row, rowIndex) => {
|
||||||
|
const currentRow = row
|
||||||
|
.split('')
|
||||||
|
.reduce((annotatedRow, cell, cellIndex) => {
|
||||||
|
if (cell === '.') return [...annotatedRow, null]
|
||||||
|
|
||||||
|
const leftNeighbour =
|
||||||
|
cellIndex > 0 ? annotatedRow[cellIndex - 1] : null
|
||||||
|
const upNeighbour =
|
||||||
|
rowIndex > 0
|
||||||
|
? annotatedGrid[rowIndex - 1][cellIndex]
|
||||||
|
: null
|
||||||
|
|
||||||
|
const annotation = {}
|
||||||
|
|
||||||
|
if (leftNeighbour === null) annotation.across = currentClue
|
||||||
|
else
|
||||||
|
annotation.acrossGroup =
|
||||||
|
leftNeighbour?.across || leftNeighbour?.acrossGroup
|
||||||
|
|
||||||
|
if (upNeighbour === null) annotation.down = currentClue
|
||||||
|
else
|
||||||
|
annotation.downGroup =
|
||||||
|
upNeighbour?.down || upNeighbour?.downGroup
|
||||||
|
|
||||||
|
if (annotation.across || annotation.down) currentClue++
|
||||||
|
|
||||||
|
return [...annotatedRow, annotation]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return [...annotatedGrid, currentRow]
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
_categorizeClues(annotatedGrid, clues) {
|
||||||
|
const across = {}
|
||||||
|
const down = {}
|
||||||
|
let currentClue = clues.shift()
|
||||||
|
|
||||||
|
annotatedGrid.forEach(row => {
|
||||||
|
row.forEach(cell => {
|
||||||
|
if (!cell) return
|
||||||
|
|
||||||
|
if (cell.across) {
|
||||||
|
across[cell.across] = currentClue
|
||||||
|
currentClue = clues.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.down) {
|
||||||
|
down[cell.down] = currentClue
|
||||||
|
currentClue = clues.shift()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { across, down }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PuzzleParser
|
67
src/Parser.test.js
Normal file
67
src/Parser.test.js
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
import PuzzleParser from "./core";
|
||||||
|
|
||||||
|
describe("Puzzle Parser", () => {
|
||||||
|
let puzzle;
|
||||||
|
|
||||||
|
function getSamplePuzzle(identifier) {
|
||||||
|
return readFileSync(__dirname + `/${identifier}.puz`, {
|
||||||
|
encoding: "latin1"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
puzzle = getSamplePuzzle("sampleGrid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts puzzle dimensions", () => {
|
||||||
|
const expectedHeight = 15;
|
||||||
|
const expectedWidth = 15;
|
||||||
|
|
||||||
|
const parsedPuzzle = new PuzzleParser().parse(puzzle);
|
||||||
|
|
||||||
|
expect(parsedPuzzle.getDimensions()).toEqual({
|
||||||
|
width: expectedWidth,
|
||||||
|
height: expectedHeight
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts clues", () => {
|
||||||
|
const parsedPuzzle = new PuzzleParser().parse(puzzle);
|
||||||
|
|
||||||
|
expect(parsedPuzzle.getClueCount()).toMatchInlineSnapshot(`NaN`);
|
||||||
|
expect(parsedPuzzle.getAcrossClues()).toMatchSnapshot();
|
||||||
|
expect(parsedPuzzle.getDownClues()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts author details", () => {
|
||||||
|
const parsedPuzzle = new PuzzleParser().parse(puzzle);
|
||||||
|
expect(parsedPuzzle.getAuthorDetails()).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
"Heard It Before?",
|
||||||
|
"Stu Ockman",
|
||||||
|
"© 2020, Andrews McMeel Syndication. Editor: David Steinberg.",
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses puzzles', () => {
|
||||||
|
const parsedPuzzle = new PuzzleParser().parse(puzzle)
|
||||||
|
expect(parsedPuzzle.toJSON()).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('annotates cell details', () => {
|
||||||
|
const parsedPuzzle = new PuzzleParser().parse(puzzle)
|
||||||
|
expect(parsedPuzzle.toJSON().layout[0][0]).toEqual(parsedPuzzle.getCell(0, 0))
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dumps debug output if verbose flag is set', () => {
|
||||||
|
const debugSpy = jest.spyOn(console, 'debug')
|
||||||
|
const parsedPuzzle = new PuzzleParser({ verbose: true}).parse(puzzle)
|
||||||
|
expect(debugSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
|
||||||
|
})
|
||||||
|
});
|
47
src/Puzzle.js
Normal file
47
src/Puzzle.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
class Puzzle {
|
||||||
|
constructor(width, height, author, clues, layout, solution) {
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
this.author = author
|
||||||
|
this.clues = clues
|
||||||
|
this.layout = layout
|
||||||
|
this.solution = solution
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthorDetails() {
|
||||||
|
return this.author
|
||||||
|
}
|
||||||
|
|
||||||
|
getCell(x, y) {
|
||||||
|
return this.layout[y][x]
|
||||||
|
}
|
||||||
|
|
||||||
|
getDimensions() {
|
||||||
|
return { width: this.width, height: this.height }
|
||||||
|
}
|
||||||
|
|
||||||
|
getClueCount() {
|
||||||
|
return this.clues.across.length + this.clues.down.length
|
||||||
|
}
|
||||||
|
|
||||||
|
getAcrossClues() {
|
||||||
|
return {...this.clues.across}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDownClues() {
|
||||||
|
return {...this.clues.down}
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
author: this.author,
|
||||||
|
clues: this.clues,
|
||||||
|
layout: this.layout,
|
||||||
|
solution: this.solution,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Puzzle
|
1022
src/__snapshots__/Parser.test.js.snap
Normal file
1022
src/__snapshots__/Parser.test.js.snap
Normal file
File diff suppressed because it is too large
Load diff
15
src/cli.js
Normal file
15
src/cli.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { readFileSync, writeFileSync } from 'fs'
|
||||||
|
|
||||||
|
import Parser from './core'
|
||||||
|
|
||||||
|
const [puzPath, outpath] = process.argv.slice(2, 4)
|
||||||
|
|
||||||
|
const content = readFileSync(puzPath, { encoding: 'latin1' })
|
||||||
|
const parsed = new Parser({ verbose: true }).parse(content)
|
||||||
|
|
||||||
|
if (!outpath) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(JSON.stringify(parsed.toJSON(), null, 2))
|
||||||
|
} else {
|
||||||
|
writeFileSync(outpath, JSON.stringify(parsed))
|
||||||
|
}
|
6
src/constants.js
Normal file
6
src/constants.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export const BODY_OFFSET = 0x34
|
||||||
|
export const WIDTH_OFFSET = 0x2c
|
||||||
|
export const HEIGHT_OFFSET = 0x2d
|
||||||
|
export const NUL_MARKER = '\u0000'
|
||||||
|
|
||||||
|
export const GRID_EXTRA_MARKER = 'GEXT'
|
3
src/core.js
Normal file
3
src/core.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import PuzzleParser from './Parser'
|
||||||
|
|
||||||
|
export default PuzzleParser
|
3
src/index.js
Normal file
3
src/index.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
const puzFileParser = require('./parser')
|
||||||
|
|
||||||
|
module.exports = puzFileParser
|
BIN
src/sampleGrid.puz
Normal file
BIN
src/sampleGrid.puz
Normal file
Binary file not shown.
Reference in a new issue