wip: initial up

This commit is contained in:
Marc Cataford 2020-08-03 14:34:03 -04:00
parent ea8d6d1c8d
commit 14d9f8b10e
19 changed files with 6764 additions and 0 deletions

5
.babelrc Normal file
View file

@ -0,0 +1,5 @@
{
"presets": ["@babel/preset-env"],
"comments": false,
"plugins": ["@babel/plugin-proposal-optional-chaining"]
}

4
.eslintrc.js Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
extends: ["@tophat"],
parser: "babel-eslint"
}

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
*.swp
node_modules
coverage
lib

13
README.md Normal file
View 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
View 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
View 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

Binary file not shown.

BIN
src/.Parser.js.swo Normal file

Binary file not shown.

BIN
src/.Parser.test.js.swo Normal file

Binary file not shown.

135
src/Parser.js Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

15
src/cli.js Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
import PuzzleParser from './Parser'
export default PuzzleParser

3
src/index.js Normal file
View file

@ -0,0 +1,3 @@
const puzFileParser = require('./parser')
module.exports = puzFileParser

BIN
src/sampleGrid.puz Normal file

Binary file not shown.

5379
yarn.lock Normal file

File diff suppressed because it is too large Load diff