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