feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* A size-bounded adaptation of the LZ-String decompressor (MIT).
|
||||
*
|
||||
* LZ-String's public decompressors only return after constructing the complete
|
||||
* output. Importers need to stop while expanding untrusted payloads, so this
|
||||
* implementation applies both UTF-16 character and UTF-8 byte budgets before
|
||||
* appending every decoded dictionary entry.
|
||||
*/
|
||||
|
||||
const BASE64_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||
const URI_SAFE_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
|
||||
|
||||
export class LzStringOutputLimitError extends Error {
|
||||
readonly maxOutputBytes: number;
|
||||
|
||||
constructor(maxOutputBytes: number) {
|
||||
super(
|
||||
`The decompressed LZ-String output exceeds ${maxOutputBytes.toLocaleString()} bytes.`,
|
||||
);
|
||||
this.name = "LzStringOutputLimitError";
|
||||
this.maxOutputBytes = maxOutputBytes;
|
||||
}
|
||||
}
|
||||
|
||||
class OutputBudget {
|
||||
private characters = 0;
|
||||
private bytes = 0;
|
||||
private pendingHighSurrogate = false;
|
||||
private readonly maximum: number;
|
||||
|
||||
constructor(maximum: number) {
|
||||
this.maximum = maximum;
|
||||
}
|
||||
|
||||
append(value: string): void {
|
||||
this.characters += value.length;
|
||||
if (this.characters > this.maximum) this.exceeded();
|
||||
|
||||
let index = 0;
|
||||
if (this.pendingHighSurrogate) {
|
||||
if (isLowSurrogate(value.charCodeAt(0))) {
|
||||
this.bytes += 4;
|
||||
index = 1;
|
||||
} else {
|
||||
this.bytes += 3;
|
||||
}
|
||||
this.pendingHighSurrogate = false;
|
||||
}
|
||||
|
||||
while (index < value.length) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
if (codeUnit <= 0x7f) {
|
||||
this.bytes += 1;
|
||||
} else if (codeUnit <= 0x7ff) {
|
||||
this.bytes += 2;
|
||||
} else if (isHighSurrogate(codeUnit)) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (isLowSurrogate(next)) {
|
||||
this.bytes += 4;
|
||||
index += 1;
|
||||
} else if (index + 1 === value.length) {
|
||||
this.pendingHighSurrogate = true;
|
||||
} else {
|
||||
this.bytes += 3;
|
||||
}
|
||||
} else {
|
||||
// TextEncoder replaces lone low surrogates with U+FFFD (three bytes).
|
||||
this.bytes += 3;
|
||||
}
|
||||
if (this.bytes > this.maximum) this.exceeded();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
if (this.pendingHighSurrogate) {
|
||||
this.bytes += 3;
|
||||
this.pendingHighSurrogate = false;
|
||||
}
|
||||
if (this.bytes > this.maximum) this.exceeded();
|
||||
}
|
||||
|
||||
private exceeded(): never {
|
||||
throw new LzStringOutputLimitError(this.maximum);
|
||||
}
|
||||
}
|
||||
|
||||
function isHighSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
||||
}
|
||||
|
||||
function isLowSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
||||
}
|
||||
|
||||
function alphabetReader(alphabet: string, input: string) {
|
||||
const reverse = new Map<string, number>();
|
||||
for (let index = 0; index < alphabet.length; index += 1) {
|
||||
reverse.set(alphabet.charAt(index), index);
|
||||
}
|
||||
return (index: number) => reverse.get(input.charAt(index)) ?? 0;
|
||||
}
|
||||
|
||||
function decompressBounded(
|
||||
input: string,
|
||||
alphabet: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
if (input.length === 0) return null;
|
||||
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) {
|
||||
throw new RangeError("maxOutputBytes must be a positive safe integer.");
|
||||
}
|
||||
|
||||
const nextValue = alphabetReader(alphabet, input);
|
||||
const dictionary: Array<string | null | undefined> = ["", "", ""];
|
||||
const output: string[] = [];
|
||||
const budget = new OutputBudget(maxOutputBytes);
|
||||
let enlargeIn = 4;
|
||||
let dictionarySize = 4;
|
||||
let numberOfBits = 3;
|
||||
let dataValue = nextValue(0);
|
||||
let dataPosition = 32;
|
||||
let dataIndex = 1;
|
||||
|
||||
const readBits = (count: number) => {
|
||||
let bits = 0;
|
||||
let power = 1;
|
||||
const maximumPower = 2 ** count;
|
||||
while (power !== maximumPower) {
|
||||
const bit = dataValue & dataPosition;
|
||||
dataPosition >>= 1;
|
||||
if (dataPosition === 0) {
|
||||
dataPosition = 32;
|
||||
dataValue = nextValue(dataIndex);
|
||||
dataIndex += 1;
|
||||
}
|
||||
if (bit > 0) bits |= power;
|
||||
power <<= 1;
|
||||
}
|
||||
return bits;
|
||||
};
|
||||
|
||||
const firstCode = readBits(2);
|
||||
let first: string;
|
||||
if (firstCode === 0) first = String.fromCharCode(readBits(8));
|
||||
else if (firstCode === 1) first = String.fromCharCode(readBits(16));
|
||||
else if (firstCode === 2) return "";
|
||||
else return null;
|
||||
|
||||
dictionary[3] = first;
|
||||
let previous = first;
|
||||
budget.append(first);
|
||||
output.push(first);
|
||||
|
||||
while (true) {
|
||||
if (dataIndex > input.length) return "";
|
||||
|
||||
let code = readBits(numberOfBits);
|
||||
if (code === 0 || code === 1) {
|
||||
const literal = String.fromCharCode(readBits(code === 0 ? 8 : 16));
|
||||
dictionary[dictionarySize] = literal;
|
||||
code = dictionarySize;
|
||||
dictionarySize += 1;
|
||||
enlargeIn -= 1;
|
||||
} else if (code === 2) {
|
||||
budget.finish();
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
if (enlargeIn === 0) {
|
||||
enlargeIn = 2 ** numberOfBits;
|
||||
numberOfBits += 1;
|
||||
}
|
||||
|
||||
const known = dictionary[code];
|
||||
let entry: string;
|
||||
if (known === null) {
|
||||
throw new LzStringOutputLimitError(maxOutputBytes);
|
||||
} else if (known !== undefined && known.length > 0) {
|
||||
entry = known;
|
||||
} else if (code === dictionarySize) {
|
||||
if (previous.length >= maxOutputBytes) {
|
||||
throw new LzStringOutputLimitError(maxOutputBytes);
|
||||
}
|
||||
entry = previous + previous.charAt(0);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
budget.append(entry);
|
||||
output.push(entry);
|
||||
|
||||
dictionary[dictionarySize] =
|
||||
previous.length < maxOutputBytes ? previous + entry.charAt(0) : null;
|
||||
dictionarySize += 1;
|
||||
enlargeIn -= 1;
|
||||
previous = entry;
|
||||
|
||||
if (enlargeIn === 0) {
|
||||
enlargeIn = 2 ** numberOfBits;
|
||||
numberOfBits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function decompressFromBase64Bounded(
|
||||
input: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
return decompressBounded(input, BASE64_ALPHABET, maxOutputBytes);
|
||||
}
|
||||
|
||||
export function decompressFromEncodedURIComponentBounded(
|
||||
input: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
return decompressBounded(
|
||||
input.replaceAll(" ", "+"),
|
||||
URI_SAFE_ALPHABET,
|
||||
maxOutputBytes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an LZ-String payload which may use either public six-bit alphabet.
|
||||
*
|
||||
* Values 0–62 have identical symbols in the Base64 and URI-safe alphabets.
|
||||
* Base64 uniquely uses `/` and `=`, while the URI-safe form uniquely uses `-`
|
||||
* and `$`. Selecting from those markers is important: LZ-String treats an
|
||||
* unknown symbol as zero, which can otherwise yield non-empty, subtly corrupt
|
||||
* JSON instead of a decompression failure.
|
||||
*/
|
||||
export function decompressFromBase64OrUriComponentBounded(
|
||||
input: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
const normalized = input.replaceAll(" ", "+");
|
||||
const hasBase64OnlySymbol = /[/=]/u.test(normalized);
|
||||
const hasUriOnlySymbol = /[-$]/u.test(normalized);
|
||||
if (hasBase64OnlySymbol && hasUriOnlySymbol) return null;
|
||||
if (hasUriOnlySymbol) {
|
||||
return decompressFromEncodedURIComponentBounded(normalized, maxOutputBytes);
|
||||
}
|
||||
return decompressFromBase64Bounded(normalized, maxOutputBytes);
|
||||
}
|
||||
+140
-2
@@ -1,7 +1,10 @@
|
||||
import { cellsFormQuadruple } from "../domain/geometry";
|
||||
import { normalizePortableAidMemoire } from "../state/aidMemoire";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
cloneConstraint,
|
||||
type PortableAidMemoire,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
@@ -121,6 +124,34 @@ function pair(
|
||||
return { a, b };
|
||||
}
|
||||
|
||||
function outsideSide(value: unknown): "top" | "right" | "bottom" | "left" {
|
||||
if (
|
||||
value !== "top" &&
|
||||
value !== "right" &&
|
||||
value !== "bottom" &&
|
||||
value !== "left"
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"An outside clue side must be top, right, bottom, or left.",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cluePolarity(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
): { negated?: boolean } {
|
||||
if (value.negated !== undefined && typeof value.negated !== "boolean") {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
`${label}.negated must be true or false.`,
|
||||
);
|
||||
}
|
||||
return value.negated === true ? { negated: true } : {};
|
||||
}
|
||||
|
||||
function parseConstraint(
|
||||
value: unknown,
|
||||
cellCount: number,
|
||||
@@ -131,6 +162,7 @@ function parseConstraint(
|
||||
"Each constraint must be an object with a type.",
|
||||
);
|
||||
}
|
||||
const size = Math.sqrt(cellCount);
|
||||
switch (value.type) {
|
||||
case "diagonal": {
|
||||
if (value.direction !== "main" && value.direction !== "anti") {
|
||||
@@ -164,6 +196,7 @@ function parseConstraint(
|
||||
cells: cageCells,
|
||||
sum,
|
||||
...(value.noRepeat === undefined ? {} : { noRepeat: value.noRepeat }),
|
||||
...cluePolarity(value, "killer-cage"),
|
||||
};
|
||||
}
|
||||
case "thermo":
|
||||
@@ -172,12 +205,14 @@ function parseConstraint(
|
||||
return {
|
||||
type: value.type,
|
||||
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
|
||||
...cluePolarity(value, value.type),
|
||||
};
|
||||
case "arrow":
|
||||
return {
|
||||
type: "arrow",
|
||||
bulb: cells(value.bulb, "arrow.bulb", cellCount),
|
||||
line: cells(value.line, "arrow.line", cellCount),
|
||||
...cluePolarity(value, "arrow"),
|
||||
};
|
||||
case "kropki": {
|
||||
const related = pair(value, cellCount);
|
||||
@@ -187,7 +222,12 @@ function parseConstraint(
|
||||
"A Kropki kind must be white or black.",
|
||||
);
|
||||
}
|
||||
return { type: "kropki", ...related, kind: value.kind };
|
||||
return {
|
||||
type: "kropki",
|
||||
...related,
|
||||
kind: value.kind,
|
||||
...cluePolarity(value, "kropki"),
|
||||
};
|
||||
}
|
||||
case "xv": {
|
||||
const related = pair(value, cellCount);
|
||||
@@ -198,6 +238,7 @@ function parseConstraint(
|
||||
type: "xv",
|
||||
...related,
|
||||
total: value.total,
|
||||
...cluePolarity(value, "xv"),
|
||||
};
|
||||
}
|
||||
case "inequality": {
|
||||
@@ -209,8 +250,83 @@ function parseConstraint(
|
||||
"An inequality must join two different cells.",
|
||||
);
|
||||
}
|
||||
return { type: "inequality", lesser, greater };
|
||||
return {
|
||||
type: "inequality",
|
||||
lesser,
|
||||
greater,
|
||||
...cluePolarity(value, "inequality"),
|
||||
};
|
||||
}
|
||||
case "x-sum": {
|
||||
const polarity = cluePolarity(value, "x-sum");
|
||||
return {
|
||||
type: "x-sum",
|
||||
side: outsideSide(value.side),
|
||||
index: integer(value.index, "x-sum.index", 0, size - 1),
|
||||
sum: integer(
|
||||
value.sum,
|
||||
"x-sum.sum",
|
||||
1,
|
||||
polarity.negated === true ? size ** 4 : (size * (size + 1)) / 2,
|
||||
),
|
||||
...polarity,
|
||||
};
|
||||
}
|
||||
case "skyscraper": {
|
||||
const polarity = cluePolarity(value, "skyscraper");
|
||||
return {
|
||||
type: "skyscraper",
|
||||
side: outsideSide(value.side),
|
||||
index: integer(value.index, "skyscraper.index", 0, size - 1),
|
||||
count: integer(
|
||||
value.count,
|
||||
"skyscraper.count",
|
||||
1,
|
||||
polarity.negated === true ? size ** 4 : size,
|
||||
),
|
||||
...polarity,
|
||||
};
|
||||
}
|
||||
case "quadruple": {
|
||||
const quadrupleCells = cells(value.cells, "quadruple.cells", cellCount);
|
||||
if (quadrupleCells.length > 4) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"quadruple.cells must contain at most four cells.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value.digits) ||
|
||||
value.digits.length < 1 ||
|
||||
value.digits.length > 4 ||
|
||||
value.digits.length > quadrupleCells.length
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"quadruple.digits must contain one to four digits and no more digits than clue cells.",
|
||||
);
|
||||
}
|
||||
if (!cellsFormQuadruple(size, quadrupleCells)) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"quadruple.cells must be the four cells surrounding one grid intersection.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "quadruple",
|
||||
cells: quadrupleCells,
|
||||
digits: value.digits.map((digit, index) =>
|
||||
integer(digit, `quadruple.digits[${index}]`, 1, size),
|
||||
),
|
||||
...cluePolarity(value, "quadruple"),
|
||||
};
|
||||
}
|
||||
case "maximum":
|
||||
return {
|
||||
type: "maximum",
|
||||
cell: cell(value.cell, "maximum.cell", cellCount),
|
||||
...cluePolarity(value, "maximum"),
|
||||
};
|
||||
default:
|
||||
return fail(
|
||||
"UNSUPPORTED_CONSTRAINT",
|
||||
@@ -316,6 +432,19 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||
}
|
||||
elapsedMs = value.elapsedMs;
|
||||
}
|
||||
let aidMemoire: PortableAidMemoire | undefined;
|
||||
if (value.aidMemoire !== undefined) {
|
||||
try {
|
||||
aidMemoire = normalizePortableAidMemoire(value.aidMemoire, size);
|
||||
} catch (error) {
|
||||
return fail(
|
||||
"INVALID_AID_MEMOIRE",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The aid-mémoire data is invalid.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(value.constraints) ||
|
||||
@@ -348,6 +477,7 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||
...(candidates === undefined ? {} : { candidates }),
|
||||
...(colors === undefined ? {} : { colors }),
|
||||
...(elapsedMs === undefined ? {} : { elapsedMs }),
|
||||
...(aidMemoire === undefined ? {} : { aidMemoire }),
|
||||
...(regions === undefined ? {} : { regions }),
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(author === undefined ? {} : { author }),
|
||||
@@ -416,6 +546,14 @@ export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
|
||||
...(normalized.colors === undefined
|
||||
? {}
|
||||
: { colors: [...normalized.colors] }),
|
||||
...(normalized.aidMemoire === undefined
|
||||
? {}
|
||||
: {
|
||||
aidMemoire: normalizePortableAidMemoire(
|
||||
normalized.aidMemoire,
|
||||
normalized.size,
|
||||
),
|
||||
}),
|
||||
...(normalized.regions === undefined
|
||||
? {}
|
||||
: { regions: [...normalized.regions] }),
|
||||
|
||||
+179
-30
@@ -1,14 +1,16 @@
|
||||
import { compressToBase64 } from "lz-string";
|
||||
import { cellsFormQuadruple } from "../domain/geometry";
|
||||
import {
|
||||
compressToBase64,
|
||||
decompressFromBase64,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
LzStringOutputLimitError,
|
||||
decompressFromBase64OrUriComponentBounded,
|
||||
} from "./boundedLz";
|
||||
import {
|
||||
MAX_BOARD_SIZE,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MIN_BOARD_SIZE,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import { UnsupportedPuzzleConstructsError } from "./interoperability";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
@@ -38,6 +40,10 @@ const SUPPORTED_ROOT_FIELDS = new Set([
|
||||
"ratio",
|
||||
"xv",
|
||||
"inequality",
|
||||
"xsum",
|
||||
"skyscraper",
|
||||
"quadruple",
|
||||
"maximum",
|
||||
"renban",
|
||||
"palindrome",
|
||||
"disabledlogic",
|
||||
@@ -54,10 +60,8 @@ const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
|
||||
odd: "odd cells",
|
||||
extraregion: "extra regions",
|
||||
clone: "clone regions",
|
||||
quadruple: "quadruples",
|
||||
betweenline: "between lines",
|
||||
minimum: "minimum cells",
|
||||
maximum: "maximum cells",
|
||||
whispers: "whisper lines",
|
||||
regionsumline: "region-sum lines",
|
||||
entropicline: "entropic lines",
|
||||
@@ -69,8 +73,6 @@ const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
|
||||
rowindexer: "row indexers",
|
||||
columnindexer: "column indexers",
|
||||
boxindexer: "box indexers",
|
||||
xsum: "X-sums",
|
||||
skyscraper: "skyscrapers",
|
||||
fogofwar: "fog of war",
|
||||
foglight: "fog lights",
|
||||
cage: "generic cages",
|
||||
@@ -115,32 +117,29 @@ function present(value: unknown): boolean {
|
||||
}
|
||||
|
||||
function assertSupportedRootFields(value: JsonRecord): void {
|
||||
const constructs: string[] = [];
|
||||
for (const [field, raw] of Object.entries(value)) {
|
||||
const unsupported = UNSUPPORTED_RULE_FIELDS[field];
|
||||
if (unsupported !== undefined && present(raw)) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`This puzzle uses ${unsupported}, which Sudoku Tools cannot enforce yet. Import stopped rather than silently weakening the puzzle.`,
|
||||
);
|
||||
constructs.push(unsupported);
|
||||
continue;
|
||||
}
|
||||
const decoration = DECORATION_FIELDS[field];
|
||||
if (decoration !== undefined && present(raw)) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`This puzzle contains ${decoration}, which cannot be preserved yet. Import stopped rather than discarding them.`,
|
||||
);
|
||||
constructs.push(decoration);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!SUPPORTED_ROOT_FIELDS.has(field) &&
|
||||
unsupported === undefined &&
|
||||
decoration === undefined
|
||||
) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`Unknown fpuzzles field “${field}”. Import stopped so a rule or visual cannot be silently lost.`,
|
||||
);
|
||||
constructs.push(`Unknown fpuzzles field “${field}”`);
|
||||
}
|
||||
}
|
||||
if (constructs.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("f-puzzles", constructs);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedJson(input: string): unknown {
|
||||
@@ -190,6 +189,56 @@ export function addressFromCellIndex(index: number, size: number): string {
|
||||
return `R${Math.floor(index / size) + 1}C${(index % size) + 1}`;
|
||||
}
|
||||
|
||||
type OutsideSide = "top" | "right" | "bottom" | "left";
|
||||
|
||||
function outsideClueFromAddress(
|
||||
value: unknown,
|
||||
size: number,
|
||||
): { side: OutsideSide; index: number } {
|
||||
if (typeof value !== "string") {
|
||||
return fail(
|
||||
"INVALID_CELL",
|
||||
"An fpuzzles outside clue must use an RnCn address.",
|
||||
);
|
||||
}
|
||||
const match = /^R(\d+)C(\d+)$/iu.exec(value.trim());
|
||||
if (match === null) {
|
||||
return fail("INVALID_CELL", `Invalid fpuzzles outside address: ${value}.`);
|
||||
}
|
||||
const row = Number(match[1]);
|
||||
const column = Number(match[2]);
|
||||
if (row === 0 && column >= 1 && column <= size)
|
||||
return { side: "top", index: column - 1 };
|
||||
if (row === size + 1 && column >= 1 && column <= size)
|
||||
return { side: "bottom", index: column - 1 };
|
||||
if (column === 0 && row >= 1 && row <= size)
|
||||
return { side: "left", index: row - 1 };
|
||||
if (column === size + 1 && row >= 1 && row <= size)
|
||||
return { side: "right", index: row - 1 };
|
||||
return fail(
|
||||
"INVALID_CELL",
|
||||
`Outside clue ${value} must sit immediately beyond one grid edge.`,
|
||||
);
|
||||
}
|
||||
|
||||
function addressFromOutsideClue(
|
||||
side: OutsideSide,
|
||||
index: number,
|
||||
size: number,
|
||||
): string {
|
||||
const position = index + 1;
|
||||
switch (side) {
|
||||
case "top":
|
||||
return `R0C${position}`;
|
||||
case "right":
|
||||
return `R${position}C${size + 1}`;
|
||||
case "bottom":
|
||||
return `R${size + 1}C${position}`;
|
||||
case "left":
|
||||
return `R${position}C0`;
|
||||
}
|
||||
}
|
||||
|
||||
function fpCells(value: unknown, size: number, minimum = 1): number[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
@@ -470,7 +519,62 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
|
||||
const [a, b] = inequalityCells as [number, number];
|
||||
if (inequality.value === ">")
|
||||
constraints.push({ type: "inequality", lesser: b, greater: a });
|
||||
else constraints.push({ type: "inequality", lesser: a, greater: b });
|
||||
else if (inequality.value === "<")
|
||||
constraints.push({ type: "inequality", lesser: a, greater: b });
|
||||
else {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
'inequality.value must be either ">" or "<".',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const clue of objects(value.xsum)) {
|
||||
constraints.push({
|
||||
type: "x-sum",
|
||||
...outsideClueFromAddress(clue.cell, size),
|
||||
sum: numeric(clue.value, "xsum.value", 1, (size * (size + 1)) / 2),
|
||||
});
|
||||
}
|
||||
for (const clue of objects(value.skyscraper)) {
|
||||
constraints.push({
|
||||
type: "skyscraper",
|
||||
...outsideClueFromAddress(clue.cell, size),
|
||||
count: numeric(clue.value, "skyscraper.value", 1, size),
|
||||
});
|
||||
}
|
||||
for (const clue of objects(value.quadruple)) {
|
||||
const quadrupleCells = fpCells(clue.cells, size);
|
||||
if (
|
||||
quadrupleCells.length > 4 ||
|
||||
!Array.isArray(clue.values) ||
|
||||
clue.values.length < 1 ||
|
||||
clue.values.length > 4 ||
|
||||
clue.values.length > quadrupleCells.length
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
"A quadruple must contain one to four cells and clue digits.",
|
||||
);
|
||||
}
|
||||
if (!cellsFormQuadruple(size, quadrupleCells)) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"A quadruple must use the four cells surrounding one grid intersection.",
|
||||
);
|
||||
}
|
||||
constraints.push({
|
||||
type: "quadruple",
|
||||
cells: quadrupleCells,
|
||||
digits: clue.values.map((digit, index) =>
|
||||
numeric(digit, `quadruple.values[${index}]`, 1, size),
|
||||
),
|
||||
});
|
||||
}
|
||||
for (const clue of objects(value.maximum)) {
|
||||
constraints.push({
|
||||
type: "maximum",
|
||||
cell: cellIndexFromAddress(clue.cell, size),
|
||||
});
|
||||
}
|
||||
|
||||
const regions = readRegions(grid, size);
|
||||
@@ -548,6 +652,12 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
|
||||
};
|
||||
|
||||
for (const constraint of document.constraints) {
|
||||
if ("negated" in constraint && constraint.negated === true) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
"fpuzzles export cannot preserve an individually false clue. Use Sudoku Tools JSON instead.",
|
||||
);
|
||||
}
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
output[constraint.direction === "main" ? "diagonal+" : "diagonal-"] =
|
||||
@@ -621,6 +731,39 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
|
||||
value: "<",
|
||||
});
|
||||
break;
|
||||
case "x-sum":
|
||||
append("xsum", {
|
||||
cell: addressFromOutsideClue(
|
||||
constraint.side,
|
||||
numeric(constraint.index, "xsum.index", 0, size - 1),
|
||||
size,
|
||||
),
|
||||
value: String(
|
||||
numeric(constraint.sum, "xsum.value", 1, (size * (size + 1)) / 2),
|
||||
),
|
||||
});
|
||||
break;
|
||||
case "skyscraper":
|
||||
append("skyscraper", {
|
||||
cell: addressFromOutsideClue(
|
||||
constraint.side,
|
||||
numeric(constraint.index, "skyscraper.index", 0, size - 1),
|
||||
size,
|
||||
),
|
||||
value: String(numeric(constraint.count, "skyscraper.value", 1, size)),
|
||||
});
|
||||
break;
|
||||
case "quadruple":
|
||||
append("quadruple", {
|
||||
cells: constraintCells(constraint.cells, size),
|
||||
values: [...constraint.digits],
|
||||
});
|
||||
break;
|
||||
case "maximum":
|
||||
append("maximum", {
|
||||
cell: addressFromCellIndex(constraint.cell, size),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,21 +818,27 @@ function decodePayload(payload: string): string {
|
||||
} catch {
|
||||
// URLSearchParams already decodes input. Keep the original if a literal % is malformed.
|
||||
}
|
||||
const base64 = decompressFromBase64(decodedPayload.replaceAll(" ", "+"));
|
||||
const uriEncoded =
|
||||
base64 || decompressFromEncodedURIComponent(decodedPayload);
|
||||
let uriEncoded: string | null;
|
||||
try {
|
||||
uriEncoded = decompressFromBase64OrUriComponentBounded(
|
||||
decodedPayload,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LzStringOutputLimitError) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed fpuzzles puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (uriEncoded === null || uriEncoded === "") {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
"The inline fpuzzles payload could not be decompressed.",
|
||||
);
|
||||
}
|
||||
if (new TextEncoder().encode(uriEncoded).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed fpuzzles puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
return uriEncoded;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
parseSudokuDocument,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import {
|
||||
importFpuzzles,
|
||||
NetworkPuzzleIdError,
|
||||
parseFpuzzles,
|
||||
} from "./fpuzzles";
|
||||
import {
|
||||
type PuzzleImportResult,
|
||||
RemotePuzzleReferenceError,
|
||||
} from "./interoperability";
|
||||
import { importPenpa, parsePenpaText } from "./penpa";
|
||||
import { parsePlainGrid } from "./grid";
|
||||
import { decodePuzzleHash } from "./share";
|
||||
import { importSudokuPad, parseSudokuPadPuzzle } from "./sudokupad";
|
||||
import type { SudokuDocument } from "./types";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function urlPayload(input: string): {
|
||||
readonly kind: "fpuzzles" | "sudokupad" | "penpa" | "remote";
|
||||
readonly value: string;
|
||||
} | null {
|
||||
if (!/^https?:\/\//iu.test(input)) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
return { kind: "remote", value: input };
|
||||
}
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (url.pathname.includes("/penpa-edit/")) {
|
||||
return { kind: "penpa", value: input };
|
||||
}
|
||||
if (host === "f-puzzles.com" || host === "www.f-puzzles.com") {
|
||||
return { kind: "fpuzzles", value: input };
|
||||
}
|
||||
if (host === "sudokupad.app" || host.endsWith(".sudokupad.app")) {
|
||||
let pathCandidate: string;
|
||||
try {
|
||||
pathCandidate = decodeURIComponent(
|
||||
url.pathname.replace(/^\/+|\/+$/gu, ""),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SUDOKUPAD_URL",
|
||||
"The SudokuPad URL contains invalid escaped text.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const candidate =
|
||||
url.searchParams.get("puzzleid") ??
|
||||
url.searchParams.get("load") ??
|
||||
pathCandidate;
|
||||
if (/^(?:ctc|scl)/iu.test(candidate)) {
|
||||
return { kind: "sudokupad", value: candidate };
|
||||
}
|
||||
if (/^fpuzzles/iu.test(candidate)) {
|
||||
return { kind: "fpuzzles", value: input };
|
||||
}
|
||||
throw new NetworkPuzzleIdError(candidate || "unknown");
|
||||
}
|
||||
return { kind: "remote", value: input };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and import every supported local representation. The function is
|
||||
* asynchronous only because Penpa+ uses browser-native raw-deflate streams;
|
||||
* no branch performs a network request.
|
||||
*/
|
||||
export async function importPuzzle(
|
||||
input: string,
|
||||
): Promise<PuzzleImportResult<SudokuDocument>> {
|
||||
if (input.length > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The pasted puzzle data is too large to open safely.",
|
||||
);
|
||||
}
|
||||
const trimmed = input.trim().replace(/^\uFEFF/u, "");
|
||||
const url = urlPayload(trimmed);
|
||||
if (url?.kind === "remote") {
|
||||
throw new RemotePuzzleReferenceError("The pasted URL");
|
||||
}
|
||||
if (url?.kind === "penpa") {
|
||||
return {
|
||||
document: await importPenpa(url.value),
|
||||
format: "penpa",
|
||||
label: "Penpa+",
|
||||
};
|
||||
}
|
||||
if (url?.kind === "sudokupad") {
|
||||
return {
|
||||
document: importSudokuPad(url.value),
|
||||
format: "sudokupad",
|
||||
label: "SudokuPad/CTC",
|
||||
};
|
||||
}
|
||||
if (url?.kind === "fpuzzles") {
|
||||
return {
|
||||
document: importFpuzzles(url.value),
|
||||
format: "fpuzzles",
|
||||
label: "f-puzzles",
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku=")) {
|
||||
return {
|
||||
document: decodePuzzleHash(trimmed),
|
||||
format: "sudoku-tools",
|
||||
label: "Sudoku Tools share link",
|
||||
};
|
||||
}
|
||||
if (/^(?:ctc|scl)/iu.test(trimmed)) {
|
||||
return {
|
||||
document: importSudokuPad(trimmed),
|
||||
format: "sudokupad",
|
||||
label: "SudokuPad/CTC",
|
||||
};
|
||||
}
|
||||
if (/^penpa:/iu.test(trimmed) || /^[?#]?(?:m=[^&]+&)?p=/iu.test(trimmed)) {
|
||||
return {
|
||||
document: await importPenpa(trimmed),
|
||||
format: "penpa",
|
||||
label: "Penpa+",
|
||||
};
|
||||
}
|
||||
if (/^(?:square|sudoku),[^\r\n]+[\r\n]/iu.test(trimmed)) {
|
||||
return {
|
||||
document: parsePenpaText(trimmed),
|
||||
format: "penpa",
|
||||
label: "Penpa+ text",
|
||||
};
|
||||
}
|
||||
if (/^fpuzzles/iu.test(trimmed)) {
|
||||
return {
|
||||
document: importFpuzzles(trimmed),
|
||||
format: "fpuzzles",
|
||||
label: "f-puzzles",
|
||||
};
|
||||
}
|
||||
if (trimmed.startsWith("{")) {
|
||||
if (new TextEncoder().encode(trimmed).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The pasted puzzle JSON is too large to open safely.",
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_JSON",
|
||||
"The pasted puzzle data is not valid JSON.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (isRecord(parsed) && "schema" in parsed) {
|
||||
return {
|
||||
document: parseSudokuDocument(trimmed),
|
||||
format: "sudoku-tools",
|
||||
label: "Sudoku Tools JSON",
|
||||
};
|
||||
}
|
||||
if (isRecord(parsed) && "cells" in parsed && !("grid" in parsed)) {
|
||||
return {
|
||||
document: parseSudokuPadPuzzle(parsed),
|
||||
format: "sudokupad",
|
||||
label: "SudokuPad/CTC JSON",
|
||||
};
|
||||
}
|
||||
return {
|
||||
document: parseFpuzzles(parsed),
|
||||
format: "fpuzzles",
|
||||
label: "f-puzzles JSON",
|
||||
};
|
||||
}
|
||||
return {
|
||||
document: parsePlainGrid(trimmed),
|
||||
format: "plain-grid",
|
||||
label: "plain grid",
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
export * from "./document";
|
||||
export * from "./fpuzzles";
|
||||
export * from "./grid";
|
||||
export * from "./import";
|
||||
export * from "./interoperability";
|
||||
export * from "./penpa";
|
||||
export * from "./share";
|
||||
export * from "./sudokupad";
|
||||
export * from "./types";
|
||||
export * from "./visual";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SudokuFormatError } from "./document";
|
||||
|
||||
export type PuzzleSourceFormat =
|
||||
"sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa";
|
||||
|
||||
export interface PuzzleImportResult<T> {
|
||||
readonly document: T;
|
||||
readonly format: PuzzleSourceFormat;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export class UnsupportedPuzzleConstructsError extends SudokuFormatError {
|
||||
readonly format: string;
|
||||
readonly constructs: readonly string[];
|
||||
|
||||
constructor(format: string, constructs: readonly string[]) {
|
||||
const unique = [...new Set(constructs)].slice(0, 24);
|
||||
const remainder = Math.max(0, new Set(constructs).size - unique.length);
|
||||
super(
|
||||
`UNSUPPORTED_${format.toUpperCase().replaceAll(/[^A-Z0-9]+/gu, "_")}`,
|
||||
`${format} contains unsupported constructs: ${unique.join(", ")}${remainder > 0 ? `, and ${String(remainder)} more` : ""}. Import stopped rather than silently weakening or changing the puzzle.`,
|
||||
);
|
||||
this.name = "UnsupportedPuzzleConstructsError";
|
||||
this.format = format;
|
||||
this.constructs = unique;
|
||||
}
|
||||
}
|
||||
|
||||
export class RemotePuzzleReferenceError extends SudokuFormatError {
|
||||
constructor(service: string) {
|
||||
super(
|
||||
"REMOTE_PUZZLE_REFERENCE",
|
||||
`${service} is a server-hosted or shortened puzzle reference. This local-only app never fetches remote puzzle IDs; paste a self-contained link or exported puzzle data instead.`,
|
||||
);
|
||||
this.name = "RemotePuzzleReferenceError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import {
|
||||
MAX_BOARD_SIZE,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MIN_BOARD_SIZE,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import {
|
||||
RemotePuzzleReferenceError,
|
||||
UnsupportedPuzzleConstructsError,
|
||||
} from "./interoperability";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_PENPA_PAYLOAD_LENGTH = 524_288;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/* Penpa+ applies these substitutions before raw-deflate compression. */
|
||||
const PENPA_SUBSTITUTIONS = [
|
||||
["z", "zZ"],
|
||||
['"qa"', "z9"],
|
||||
['"pu_q"', "zQ"],
|
||||
['"pu_a"', "zA"],
|
||||
['"grid"', "zG"],
|
||||
['"edit_mode"', "zM"],
|
||||
['"surface"', "zS"],
|
||||
['"line"', "zL"],
|
||||
['"lineE"', "zE"],
|
||||
['"wall"', "zW"],
|
||||
['"cage"', "zC"],
|
||||
['"number"', "zN"],
|
||||
['"symbol"', "zY"],
|
||||
['"special"', "zP"],
|
||||
['"board"', "zB"],
|
||||
['"command_redo"', "zR"],
|
||||
['"command_undo"', "zU"],
|
||||
['"command_replay"', "z8"],
|
||||
['"numberS"', "z1"],
|
||||
['"freeline"', "zF"],
|
||||
['"freelineE"', "z2"],
|
||||
['"thermo"', "zT"],
|
||||
['"arrows"', "z3"],
|
||||
['"direction"', "zD"],
|
||||
['"squareframe"', "z0"],
|
||||
['"polygon"', "z5"],
|
||||
['"deletelineE"', "z4"],
|
||||
['"killercages"', "z6"],
|
||||
['"nobulbthermo"', "z7"],
|
||||
['"__a"', "z_"],
|
||||
["null", "zO"],
|
||||
] as const;
|
||||
|
||||
const LAYER_FIELDS = new Set([
|
||||
"command_redo",
|
||||
"command_undo",
|
||||
"command_replay",
|
||||
"surface",
|
||||
"number",
|
||||
"numberS",
|
||||
"symbol",
|
||||
"thermo",
|
||||
"arrows",
|
||||
"direction",
|
||||
"squareframe",
|
||||
"polygon",
|
||||
"line",
|
||||
"lineE",
|
||||
"wall",
|
||||
"cage",
|
||||
"freeline",
|
||||
"freelineE",
|
||||
"deletelineE",
|
||||
"killercages",
|
||||
"nobulbthermo",
|
||||
]);
|
||||
|
||||
function fail(code: string, message: string): never {
|
||||
throw new SudokuFormatError(code, message);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedJson(input: string, label: string): unknown {
|
||||
if (input.length > MAX_DOCUMENT_BYTES) {
|
||||
return fail("LIMIT_EXCEEDED", `${label} is too large to open safely.`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(input) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
`${label} is not valid JSON.`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed =
|
||||
typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
||||
if (
|
||||
!Number.isInteger(parsed) ||
|
||||
(parsed as number) < minimum ||
|
||||
(parsed as number) > maximum
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
`${label} must be an integer from ${minimum} to ${maximum}.`,
|
||||
);
|
||||
}
|
||||
return parsed as number;
|
||||
}
|
||||
|
||||
function hasContent(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (isRecord(value)) {
|
||||
if (
|
||||
Object.keys(value).length === 1 &&
|
||||
isRecord(value.command_redo) &&
|
||||
Array.isArray(value.command_redo.__a)
|
||||
) {
|
||||
return value.command_redo.__a.length > 0;
|
||||
}
|
||||
return Object.keys(value).length > 0;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function expandCenterList(value: unknown): number[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_BOARD_SIZE ** 2) {
|
||||
return fail("LIMIT_EXCEEDED", "Penpa+ has an invalid or oversized grid.");
|
||||
}
|
||||
const result: number[] = [];
|
||||
let previous = 0;
|
||||
value.forEach((raw, index) => {
|
||||
const delta = integer(
|
||||
raw,
|
||||
`centerlist[${String(index)}]`,
|
||||
-100_000,
|
||||
100_000,
|
||||
);
|
||||
const point = index === 0 ? delta : previous + delta;
|
||||
if (point < 0) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
"Penpa+ centerlist contains a negative point.",
|
||||
);
|
||||
}
|
||||
result.push(point);
|
||||
previous = point;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function restoreSubstitutions(value: string): string {
|
||||
let restored = value;
|
||||
for (let index = PENPA_SUBSTITUTIONS.length - 1; index >= 0; index -= 1) {
|
||||
const substitution = PENPA_SUBSTITUTIONS[index];
|
||||
if (substitution === undefined) continue;
|
||||
restored = restored.split(substitution[1]).join(substitution[0]);
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
function decodeNumber(value: unknown, size: number): number | undefined {
|
||||
if (!Array.isArray(value) || value.length < 3 || value.length > 8) {
|
||||
return undefined;
|
||||
}
|
||||
const rawDigit = value[0];
|
||||
const type = String(value[2]);
|
||||
if (["1", "2", "4", "10"].includes(type)) {
|
||||
const parsed =
|
||||
typeof rawDigit === "string" && /^\d+$/u.test(rawDigit)
|
||||
? Number(rawDigit)
|
||||
: rawDigit;
|
||||
return Number.isInteger(parsed) && (parsed as number) >= 1 && parsed <= size
|
||||
? (parsed as number)
|
||||
: undefined;
|
||||
}
|
||||
if (type === "7" && Array.isArray(rawDigit) && rawDigit.length === size) {
|
||||
const enabled = rawDigit
|
||||
.map((flag, index) => (flag === 1 ? index + 1 : 0))
|
||||
.filter(Boolean);
|
||||
return enabled.length === 1 ? enabled[0] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function plainMetadata(value: string | undefined, prefix: string) {
|
||||
if (value === undefined) return undefined;
|
||||
return value
|
||||
.replaceAll("%2C", ",")
|
||||
.replace(new RegExp(`^${prefix}:\\s*`, "iu"), "")
|
||||
.slice(0, 256);
|
||||
}
|
||||
|
||||
function rulesMetadata(value: string | undefined) {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
return value
|
||||
.replaceAll("%2C", ",")
|
||||
.replaceAll("%2D", "\n")
|
||||
.replaceAll("%2E", "&")
|
||||
.replaceAll("%2F", "=")
|
||||
.slice(0, 16_384);
|
||||
}
|
||||
|
||||
function layer(value: unknown, label: string): JsonRecord {
|
||||
if (!isRecord(value) || Object.keys(value).length > 100) {
|
||||
return fail("INVALID_PENPA", `${label} must be a bounded object.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateLayerFields(value: JsonRecord, label: string): void {
|
||||
const unsupported = Object.keys(value)
|
||||
.filter((field) => !LAYER_FIELDS.has(field))
|
||||
.map((field) => `${label} field “${field}”`);
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", unsupported);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLineConstraints(
|
||||
value: unknown,
|
||||
field: "thermo" | "arrows",
|
||||
pointToCell: ReadonlyMap<number, number>,
|
||||
): PortableConstraint[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 5_000) {
|
||||
return fail("LIMIT_EXCEEDED", `Penpa+ ${field} data is too large.`);
|
||||
}
|
||||
return value.map((raw, index) => {
|
||||
if (
|
||||
!Array.isArray(raw) ||
|
||||
raw.length < 2 ||
|
||||
raw.length > pointToCell.size
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
`Penpa+ ${field}[${String(index)}] is not a bounded cell path.`,
|
||||
);
|
||||
}
|
||||
const cells = raw.map((point, pointIndex) => {
|
||||
const parsed = integer(
|
||||
point,
|
||||
`${field}[${String(index)}][${String(pointIndex)}]`,
|
||||
0,
|
||||
1_000_000,
|
||||
);
|
||||
const cell = pointToCell.get(parsed);
|
||||
if (cell === undefined) {
|
||||
return fail(
|
||||
"UNSUPPORTED_PENPA_GEOMETRY",
|
||||
`Penpa+ ${field} uses a point outside the rectangular Sudoku grid.`,
|
||||
);
|
||||
}
|
||||
return cell;
|
||||
});
|
||||
if (new Set(cells).size !== cells.length) {
|
||||
return fail("INVALID_PENPA", `Penpa+ ${field} repeats a path cell.`);
|
||||
}
|
||||
return field === "thermo"
|
||||
? ({ type: "thermo", cells } as const)
|
||||
: ({ type: "arrow", bulb: [cells[0]!], line: cells.slice(1) } as const);
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse the locally decompressed text stored in a Penpa+ long URL. */
|
||||
export function parsePenpaText(compressedText: string): SudokuDocument {
|
||||
if (
|
||||
new TextEncoder().encode(compressedText).byteLength > MAX_DOCUMENT_BYTES
|
||||
) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed Penpa+ puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
const text = restoreSubstitutions(compressedText);
|
||||
const rows = text.split("\n");
|
||||
if (rows.length < 7 || rows.length > 32) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ puzzle record is incomplete.");
|
||||
}
|
||||
const header = rows[0]?.split(",") ?? [];
|
||||
if (header.length < 15) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ puzzle header is incomplete.");
|
||||
}
|
||||
const gridType = header[0]?.toLowerCase();
|
||||
if (gridType !== "square" && gridType !== "sudoku") {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", [
|
||||
`grid type “${gridType ?? "unknown"}”`,
|
||||
]);
|
||||
}
|
||||
const nx = integer(header[1], "Penpa+ width", 1, 100);
|
||||
const ny = integer(header[2], "Penpa+ height", 1, 100);
|
||||
const rotation = integer(header[4] ?? 0, "Penpa+ rotation", -360, 360);
|
||||
const reflectX = integer(header[5] ?? 1, "Penpa+ reflection", -1, 1);
|
||||
const reflectY = integer(header[6] ?? 1, "Penpa+ reflection", -1, 1);
|
||||
if (rotation !== 0 || reflectX !== 1 || reflectY !== 1) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", [
|
||||
"rotated or reflected grids",
|
||||
]);
|
||||
}
|
||||
boundedJson(rows[1] ?? "", "Penpa+ spacing data");
|
||||
const problem = layer(
|
||||
boundedJson(rows[3] ?? "", "Penpa+ problem layer"),
|
||||
"Penpa+ problem layer",
|
||||
);
|
||||
const progress = layer(
|
||||
boundedJson(rows[4] ?? "{}", "Penpa+ answer layer"),
|
||||
"Penpa+ answer layer",
|
||||
);
|
||||
validateLayerFields(problem, "problem layer");
|
||||
validateLayerFields(progress, "answer layer");
|
||||
|
||||
const centerList = expandCenterList(
|
||||
boundedJson(rows[5] ?? "", "Penpa+ centerlist"),
|
||||
);
|
||||
if (centerList.length === 0) {
|
||||
return fail("INVALID_PENPA", "Penpa+ contains no active grid cells.");
|
||||
}
|
||||
const nx0 = nx + 4;
|
||||
const positions = centerList.map((point) => ({
|
||||
point,
|
||||
row: Math.floor(point / nx0) - 2,
|
||||
column: (point % nx0) - 2,
|
||||
}));
|
||||
const minimumRow = Math.min(...positions.map(({ row }) => row));
|
||||
const maximumRow = Math.max(...positions.map(({ row }) => row));
|
||||
const minimumColumn = Math.min(...positions.map(({ column }) => column));
|
||||
const maximumColumn = Math.max(...positions.map(({ column }) => column));
|
||||
const height = maximumRow - minimumRow + 1;
|
||||
const width = maximumColumn - minimumColumn + 1;
|
||||
if (
|
||||
width !== height ||
|
||||
width < MIN_BOARD_SIZE ||
|
||||
width > MAX_BOARD_SIZE ||
|
||||
positions.length !== width * height ||
|
||||
width > nx ||
|
||||
height > ny
|
||||
) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", [
|
||||
"masked, non-square or unsupported-size Sudoku grids",
|
||||
]);
|
||||
}
|
||||
const pointToCell = new Map<number, number>();
|
||||
for (const position of positions) {
|
||||
const row = position.row - minimumRow;
|
||||
const column = position.column - minimumColumn;
|
||||
const cell = row * width + column;
|
||||
if (
|
||||
pointToCell.has(position.point) ||
|
||||
[...pointToCell.values()].includes(cell)
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
"Penpa+ centerlist contains duplicate cells.",
|
||||
);
|
||||
}
|
||||
pointToCell.set(position.point, cell);
|
||||
}
|
||||
|
||||
const unsupported: string[] = [];
|
||||
const supportedFields = new Set(["number", "thermo", "arrows"]);
|
||||
for (const [field, raw] of Object.entries(problem)) {
|
||||
if (
|
||||
!supportedFields.has(field) &&
|
||||
!field.startsWith("command_") &&
|
||||
hasContent(raw)
|
||||
) {
|
||||
unsupported.push(`problem ${field}`);
|
||||
}
|
||||
}
|
||||
for (const [field, raw] of Object.entries(progress)) {
|
||||
if (
|
||||
field !== "number" &&
|
||||
!field.startsWith("command_") &&
|
||||
hasContent(raw)
|
||||
) {
|
||||
unsupported.push(`answer ${field}`);
|
||||
}
|
||||
}
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", unsupported);
|
||||
}
|
||||
|
||||
const givens = Array<number>(width * width).fill(0);
|
||||
const values = Array<number>(width * width).fill(0);
|
||||
const parseNumbers = (
|
||||
raw: unknown,
|
||||
target: number[],
|
||||
requireGivenStyle: boolean,
|
||||
label: string,
|
||||
) => {
|
||||
if (raw === undefined) return;
|
||||
if (!isRecord(raw) || Object.keys(raw).length > width * width * 4) {
|
||||
return fail("LIMIT_EXCEEDED", `Penpa+ ${label} numbers are too large.`);
|
||||
}
|
||||
const unsupportedNumbers: string[] = [];
|
||||
for (const [pointText, entry] of Object.entries(raw)) {
|
||||
const point = integer(pointText, `${label} point`, 0, 1_000_000);
|
||||
const cell = pointToCell.get(point);
|
||||
const digit = decodeNumber(entry, width);
|
||||
const style = Array.isArray(entry) ? entry[1] : undefined;
|
||||
if (
|
||||
cell === undefined ||
|
||||
digit === undefined ||
|
||||
(requireGivenStyle && style !== 1)
|
||||
) {
|
||||
unsupportedNumbers.push(
|
||||
`${label} number or style at point ${pointText}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
target[cell] = digit;
|
||||
}
|
||||
if (unsupportedNumbers.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", unsupportedNumbers);
|
||||
}
|
||||
};
|
||||
parseNumbers(problem.number, givens, true, "problem");
|
||||
values.splice(0, values.length, ...givens);
|
||||
parseNumbers(progress.number, values, false, "answer");
|
||||
for (const [cell, given] of givens.entries()) {
|
||||
if (given !== 0) values[cell] = given;
|
||||
}
|
||||
|
||||
const constraints = [
|
||||
...parseLineConstraints(problem.thermo, "thermo", pointToCell),
|
||||
...parseLineConstraints(problem.arrows, "arrows", pointToCell),
|
||||
];
|
||||
const title = plainMetadata(header[15], "Title");
|
||||
const author = plainMetadata(header[16], "Author");
|
||||
const rules = rulesMetadata(header[18]);
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size: width,
|
||||
givens,
|
||||
values,
|
||||
constraints,
|
||||
...(title === undefined || title === "" ? {} : { title }),
|
||||
...(author === undefined || author === "" ? {} : { author }),
|
||||
...(rules === undefined ? {} : { rules: [rules] }),
|
||||
};
|
||||
}
|
||||
|
||||
function extractPenpaPayload(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("penpa:")) return trimmed.slice("penpa:".length);
|
||||
let params: URLSearchParams;
|
||||
if (/^https?:\/\//iu.test(trimmed)) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError("INVALID_PENPA_URL", "Invalid Penpa+ URL.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!url.pathname.includes("/penpa-edit/")) {
|
||||
throw new RemotePuzzleReferenceError("The pasted URL");
|
||||
}
|
||||
params = new URLSearchParams(
|
||||
url.hash.length > 1 ? url.hash.slice(1) : url.search.slice(1),
|
||||
);
|
||||
} else {
|
||||
params = new URLSearchParams(trimmed.replace(/^[?#]/u, ""));
|
||||
}
|
||||
const payload = params.get("p");
|
||||
if (payload === null || payload.startsWith("http")) {
|
||||
throw new RemotePuzzleReferenceError("The Penpa+ link");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function base64Bytes(payload: string): Uint8Array {
|
||||
const normalized = payload.replaceAll(" ", "+");
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized.length > MAX_PENPA_PAYLOAD_LENGTH ||
|
||||
!/^[A-Za-z0-9+/]*={0,2}$/u.test(normalized)
|
||||
) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ payload is invalid or too large.");
|
||||
}
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(normalized);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The Penpa+ payload is not valid base64.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function inflateRaw(bytes: Uint8Array): Promise<string> {
|
||||
if (typeof DecompressionStream === "undefined") {
|
||||
return fail(
|
||||
"UNSUPPORTED_BROWSER",
|
||||
"This browser cannot decompress Penpa+ data locally.",
|
||||
);
|
||||
}
|
||||
let stream: ReadableStream<Uint8Array>;
|
||||
try {
|
||||
const input = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(input).set(bytes);
|
||||
const body = new Response(input).body;
|
||||
if (body === null) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ payload stream is empty.");
|
||||
}
|
||||
stream = body.pipeThrough(new DecompressionStream("deflate-raw"));
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The Penpa+ raw-deflate stream could not be opened.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
total += chunk.value.byteLength;
|
||||
if (total > MAX_DOCUMENT_BYTES) {
|
||||
await reader.cancel();
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed Penpa+ puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The Penpa+ payload could not be decompressed.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(output);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The decompressed Penpa+ payload is not valid UTF-8 text.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import a self-contained Penpa+ long URL without making a network request. */
|
||||
export async function importPenpa(input: string): Promise<SudokuDocument> {
|
||||
return parsePenpaText(
|
||||
await inflateRaw(base64Bytes(extractPenpaPayload(input))),
|
||||
);
|
||||
}
|
||||
+20
-10
@@ -1,7 +1,8 @@
|
||||
import { compressToEncodedURIComponent } from "lz-string";
|
||||
import {
|
||||
compressToEncodedURIComponent,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
LzStringOutputLimitError,
|
||||
decompressFromEncodedURIComponentBounded,
|
||||
} from "./boundedLz";
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
SudokuFormatError,
|
||||
@@ -49,18 +50,27 @@ export function decodePuzzleHash(hashOrUrl: string): SudokuDocument {
|
||||
);
|
||||
}
|
||||
const compressed = hash.slice(PUZZLE_HASH_PREFIX.length);
|
||||
const json = decompressFromEncodedURIComponent(compressed);
|
||||
let json: string | null;
|
||||
try {
|
||||
json = decompressFromEncodedURIComponentBounded(
|
||||
compressed,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LzStringOutputLimitError) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed puzzle is too large to open safely.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (json === null || json === "") {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SHARE",
|
||||
"The share payload could not be decompressed.",
|
||||
);
|
||||
}
|
||||
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
return parseSudokuDocument(json);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import {
|
||||
LzStringOutputLimitError,
|
||||
decompressFromBase64OrUriComponentBounded,
|
||||
} from "./boundedLz";
|
||||
import {
|
||||
MAX_BOARD_SIZE,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MIN_BOARD_SIZE,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import { UnsupportedPuzzleConstructsError } from "./interoperability";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_SUDOKUPAD_PAYLOAD_LENGTH = 262_144;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const ROOT_FIELDS = new Set([
|
||||
"id",
|
||||
"cellSize",
|
||||
"cells",
|
||||
"settings",
|
||||
"metadata",
|
||||
"metaData",
|
||||
"global",
|
||||
"regions",
|
||||
"lines",
|
||||
"underlays",
|
||||
"overlays",
|
||||
"arrows",
|
||||
"cages",
|
||||
"title",
|
||||
"author",
|
||||
"rules",
|
||||
"solution",
|
||||
]);
|
||||
|
||||
const CELL_FIELDS = new Set(["value", "given", "pencilMarks", "centremarks"]);
|
||||
|
||||
function fail(code: string, message: string): never {
|
||||
throw new SudokuFormatError(code, message);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function present(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === false) return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (isRecord(value)) return Object.keys(value).length > 0;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, label: string, maximum = 16_384) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value !== "string" || value.length > maximum) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`${label} must be text of at most ${maximum} characters.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed =
|
||||
typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
||||
if (
|
||||
!Number.isInteger(parsed) ||
|
||||
(parsed as number) < minimum ||
|
||||
(parsed as number) > maximum
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`${label} must be an integer from ${minimum} to ${maximum}.`,
|
||||
);
|
||||
}
|
||||
return parsed as number;
|
||||
}
|
||||
|
||||
function coordinate(value: unknown, size: number, label: string): number {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length !== 2 ||
|
||||
!Number.isInteger(value[0]) ||
|
||||
!Number.isInteger(value[1])
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`${label} must be a zero-based [row, column] cell.`,
|
||||
);
|
||||
}
|
||||
const row = integer(value[0], `${label}[0]`, 0, size - 1);
|
||||
const column = integer(value[1], `${label}[1]`, 0, size - 1);
|
||||
return row * size + column;
|
||||
}
|
||||
|
||||
function digitList(value: unknown, size: number, label: string): number[] {
|
||||
if (!Array.isArray(value) || value.length > size) {
|
||||
return fail("INVALID_SUDOKUPAD", `${label} must be a bounded digit array.`);
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
value.map((digit, index) =>
|
||||
integer(digit, `${label}[${String(index)}]`, 1, size),
|
||||
),
|
||||
),
|
||||
].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function metadataFrom(value: JsonRecord): JsonRecord {
|
||||
const metadata = value.metadata ?? value.metaData;
|
||||
if (metadata === undefined) return {};
|
||||
if (!isRecord(metadata)) {
|
||||
return fail("INVALID_SUDOKUPAD", "SudokuPad metadata must be an object.");
|
||||
}
|
||||
if (Object.keys(metadata).length > 100) {
|
||||
return fail("LIMIT_EXCEEDED", "SudokuPad metadata has too many fields.");
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function metaText(
|
||||
root: JsonRecord,
|
||||
metadata: JsonRecord,
|
||||
key: "title" | "author" | "rules" | "solution",
|
||||
): string | undefined {
|
||||
const value = root[key] ?? metadata[key];
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.every((entry) => typeof entry === "string")) {
|
||||
return fail("INVALID_SUDOKUPAD", `${key} metadata must be text.`);
|
||||
}
|
||||
return value.join(key === "rules" ? "\n" : " ").slice(0, 16_384);
|
||||
}
|
||||
return boundedText(value, `SudokuPad ${key}`);
|
||||
}
|
||||
|
||||
function solutionDigits(value: string | undefined, size: number) {
|
||||
if (value === undefined) return undefined;
|
||||
const tokens = value.includes(",") ? value.split(",") : [...value];
|
||||
if (tokens.length !== size * size) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`SudokuPad solution must contain exactly ${size * size} digits.`,
|
||||
);
|
||||
}
|
||||
return tokens.map((digit, index) =>
|
||||
integer(digit, `solution[${String(index)}]`, 1, size),
|
||||
);
|
||||
}
|
||||
|
||||
function parseRegions(value: unknown, size: number): number[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.length !== size) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`SudokuPad regions must contain exactly ${size} regions.`,
|
||||
);
|
||||
}
|
||||
const regions = Array<number>(size * size).fill(-1);
|
||||
value.forEach((rawRegion, regionIndex) => {
|
||||
if (!Array.isArray(rawRegion) || rawRegion.length !== size) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`SudokuPad region ${String(regionIndex + 1)} must contain ${size} cells.`,
|
||||
);
|
||||
}
|
||||
rawRegion.forEach((cell, cellIndex) => {
|
||||
const index = coordinate(
|
||||
cell,
|
||||
size,
|
||||
`regions[${String(regionIndex)}][${String(cellIndex)}]`,
|
||||
);
|
||||
if (regions[index] !== -1) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"SudokuPad regions contain a duplicate cell.",
|
||||
);
|
||||
}
|
||||
regions[index] = regionIndex;
|
||||
});
|
||||
});
|
||||
if (regions.some((region) => region === -1)) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"SudokuPad regions do not cover every grid cell.",
|
||||
);
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
function parseCages(
|
||||
value: unknown,
|
||||
size: number,
|
||||
metadata: JsonRecord,
|
||||
): PortableConstraint[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 5_000) {
|
||||
return fail("LIMIT_EXCEEDED", "SudokuPad cages must be a bounded array.");
|
||||
}
|
||||
const constraints: PortableConstraint[] = [];
|
||||
const unsupported: string[] = [];
|
||||
for (const [index, raw] of value.entries()) {
|
||||
if (!isRecord(raw)) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`cages[${String(index)}] must be an object.`,
|
||||
);
|
||||
}
|
||||
const cells = raw.cells;
|
||||
if (!Array.isArray(cells) || cells.length === 0) {
|
||||
const rawValue = boundedText(raw.value, `cages[${String(index)}].value`);
|
||||
const match = /^([^: ]+):\s*([\s\S]+)$/u.exec(rawValue ?? "");
|
||||
if (match !== null && metadata[match[1]!] === undefined) {
|
||||
metadata[match[1]!] = match[2]!;
|
||||
} else if (rawValue !== undefined) {
|
||||
unsupported.push("cell-free cage annotations");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cells.length > size * size) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"A SudokuPad cage contains too many cells.",
|
||||
);
|
||||
}
|
||||
const cageCells = cells.map((cell, cellIndex) =>
|
||||
coordinate(
|
||||
cell,
|
||||
size,
|
||||
`cages[${String(index)}].cells[${String(cellIndex)}]`,
|
||||
),
|
||||
);
|
||||
if (new Set(cageCells).size !== cageCells.length) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`cages[${String(index)}] contains a duplicate cell.`,
|
||||
);
|
||||
}
|
||||
if (raw.hidden === true) {
|
||||
unsupported.push("hidden or masked cells");
|
||||
continue;
|
||||
}
|
||||
const sumValue = raw.sum ?? raw.value;
|
||||
if (sumValue === undefined || sumValue === "") {
|
||||
unsupported.push("cages without numeric sums");
|
||||
continue;
|
||||
}
|
||||
const sum = integer(sumValue, `cages[${String(index)}].sum`, 1, size ** 3);
|
||||
constraints.push({
|
||||
type: "killer-cage",
|
||||
cells: cageCells,
|
||||
sum,
|
||||
noRepeat: raw.unique !== false,
|
||||
});
|
||||
}
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("SudokuPad/CTC", unsupported);
|
||||
}
|
||||
return constraints;
|
||||
}
|
||||
|
||||
/** Parse raw, already-decoded SudokuPad/CTC (often called SCL) JSON. */
|
||||
export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
|
||||
if (!isRecord(value)) {
|
||||
return fail("INVALID_SUDOKUPAD", "The SudokuPad puzzle must be an object.");
|
||||
}
|
||||
const unsupported: string[] = [];
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!ROOT_FIELDS.has(field))
|
||||
unsupported.push(`unknown root field “${field}”`);
|
||||
}
|
||||
for (const field of ["lines", "underlays", "overlays", "arrows"] as const) {
|
||||
if (present(value[field])) unsupported.push(`visual ${field}`);
|
||||
}
|
||||
if (present(value.global)) unsupported.push("custom global rules");
|
||||
if (isRecord(value.settings)) {
|
||||
for (const key of Object.keys(value.settings)) {
|
||||
if (key !== "conflictchecker") unsupported.push(`setting “${key}”`);
|
||||
}
|
||||
} else if (value.settings !== undefined) {
|
||||
return fail("INVALID_SUDOKUPAD", "SudokuPad settings must be an object.");
|
||||
}
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("SudokuPad/CTC", unsupported);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.cells)) {
|
||||
return fail("INVALID_SUDOKUPAD", "SudokuPad cells must be a square array.");
|
||||
}
|
||||
const size = integer(
|
||||
value.cells.length,
|
||||
"SudokuPad grid size",
|
||||
MIN_BOARD_SIZE,
|
||||
MAX_BOARD_SIZE,
|
||||
);
|
||||
if (!value.cells.every((row) => Array.isArray(row) && row.length === size)) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"SudokuPad cells must form a square grid.",
|
||||
);
|
||||
}
|
||||
|
||||
const givens: number[] = [];
|
||||
const values: number[] = [];
|
||||
const cornerMarks: number[][] = [];
|
||||
const centerMarks: number[][] = [];
|
||||
for (const [rowIndex, rawRow] of value.cells.entries()) {
|
||||
for (const [columnIndex, rawCell] of (rawRow as unknown[]).entries()) {
|
||||
if (!isRecord(rawCell)) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`cells[${String(rowIndex)}][${String(columnIndex)}] must be an object.`,
|
||||
);
|
||||
}
|
||||
const unknown = Object.keys(rawCell).filter(
|
||||
(field) => !CELL_FIELDS.has(field),
|
||||
);
|
||||
if (unknown.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError(
|
||||
"SudokuPad/CTC",
|
||||
unknown.map((field) => `cell field “${field}”`),
|
||||
);
|
||||
}
|
||||
const digit =
|
||||
rawCell.value === undefined || rawCell.value === ""
|
||||
? 0
|
||||
: integer(
|
||||
rawCell.value,
|
||||
`cells[${String(rowIndex)}][${String(columnIndex)}].value`,
|
||||
1,
|
||||
size,
|
||||
);
|
||||
const isGiven = digit !== 0 && rawCell.given !== false;
|
||||
givens.push(isGiven ? digit : 0);
|
||||
values.push(digit);
|
||||
cornerMarks.push(
|
||||
digitList(
|
||||
rawCell.pencilMarks ?? [],
|
||||
size,
|
||||
`cells[${String(rowIndex)}][${String(columnIndex)}].pencilMarks`,
|
||||
),
|
||||
);
|
||||
centerMarks.push(
|
||||
digitList(
|
||||
rawCell.centremarks ?? [],
|
||||
size,
|
||||
`cells[${String(rowIndex)}][${String(columnIndex)}].centremarks`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = metadataFrom(value);
|
||||
const constraints = parseCages(value.cages, size, metadata);
|
||||
if (metadata.antiknight === true || metadata.antiknight === "true") {
|
||||
constraints.push({ type: "anti-knight" });
|
||||
}
|
||||
if (metadata.antiking === true || metadata.antiking === "true") {
|
||||
constraints.push({ type: "anti-king" });
|
||||
}
|
||||
if (metadata.nonconsecutive === true || metadata.nonconsecutive === "true") {
|
||||
constraints.push({ type: "non-consecutive" });
|
||||
}
|
||||
const knownMetadata = new Set([
|
||||
"title",
|
||||
"author",
|
||||
"rules",
|
||||
"solution",
|
||||
"antiknight",
|
||||
"antiking",
|
||||
"nonconsecutive",
|
||||
]);
|
||||
const unknownMetadata = Object.keys(metadata).filter(
|
||||
(key) => !knownMetadata.has(key),
|
||||
);
|
||||
if (unknownMetadata.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError(
|
||||
"SudokuPad/CTC",
|
||||
unknownMetadata.map((key) => `metadata “${key}”`),
|
||||
);
|
||||
}
|
||||
|
||||
const title = metaText(value, metadata, "title");
|
||||
const author = metaText(value, metadata, "author");
|
||||
const rules = metaText(value, metadata, "rules");
|
||||
const solution = solutionDigits(metaText(value, metadata, "solution"), size);
|
||||
const regions = parseRegions(value.regions, size);
|
||||
const id = boundedText(value.id, "SudokuPad id", 256);
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size,
|
||||
givens,
|
||||
values,
|
||||
cornerMarks,
|
||||
centerMarks,
|
||||
constraints,
|
||||
...(regions === undefined ? {} : { regions }),
|
||||
...(title === undefined ? {} : { title: title.slice(0, 256) }),
|
||||
...(author === undefined ? {} : { author: author.slice(0, 256) }),
|
||||
...(rules === undefined ? {} : { rules: [rules.slice(0, 16_384)] }),
|
||||
...(solution === undefined ? {} : { solution }),
|
||||
...(id === undefined ? {} : { id }),
|
||||
};
|
||||
}
|
||||
|
||||
function boundedJson(input: string): unknown {
|
||||
if (new TextEncoder().encode(input).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decoded SudokuPad puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(input) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"The SudokuPad payload does not contain valid JSON.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decodePayload(payload: string): string {
|
||||
if (payload.length === 0 || payload.length > MAX_SUDOKUPAD_PAYLOAD_LENGTH) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The inline SudokuPad payload is empty or too large.",
|
||||
);
|
||||
}
|
||||
let decoded = payload;
|
||||
try {
|
||||
decoded = decodeURIComponent(payload);
|
||||
} catch {
|
||||
// A malformed literal percent will fail decompression below.
|
||||
}
|
||||
let text: string | null;
|
||||
try {
|
||||
text = decompressFromBase64OrUriComponentBounded(
|
||||
decoded,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LzStringOutputLimitError) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decoded SudokuPad puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (text === null || text === "") {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"The inline SudokuPad payload could not be decompressed.",
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Import raw SCL/CTC JSON or a self-contained `ctc…`/`scl…` payload. */
|
||||
export function importSudokuPad(input: string): SudokuDocument {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("{")) {
|
||||
return parseSudokuPadPuzzle(boundedJson(trimmed));
|
||||
}
|
||||
const payload = trimmed.replace(/^(?:ctc|scl)/iu, "");
|
||||
if (payload === trimmed) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"Expected raw SudokuPad JSON or a self-contained ctc/scl payload.",
|
||||
);
|
||||
}
|
||||
return parseSudokuPadPuzzle(boundedJson(decodePayload(payload)));
|
||||
}
|
||||
+60
-13
@@ -1,45 +1,84 @@
|
||||
import type { PortableAidMemoire } from "../state/aidMemoire";
|
||||
|
||||
export type {
|
||||
PortableAidMemoire,
|
||||
PortableAidMemoireCell,
|
||||
} from "../state/aidMemoire";
|
||||
|
||||
export const SUDOKU_DOCUMENT_SCHEMA =
|
||||
"de.add-ideas.sudoku-tools.puzzle" as const;
|
||||
export const SUDOKU_DOCUMENT_VERSION = 1 as const;
|
||||
|
||||
export type CellIndex = number;
|
||||
export type OutsideSide = "top" | "right" | "bottom" | "left";
|
||||
export interface CluePolarity {
|
||||
/** When true, the completed clue must be false rather than true. */
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export type PortableConstraint =
|
||||
| { readonly type: "diagonal"; readonly direction: "main" | "anti" }
|
||||
| { readonly type: "anti-knight" }
|
||||
| { readonly type: "anti-king" }
|
||||
| { readonly type: "non-consecutive" }
|
||||
| {
|
||||
| ({
|
||||
readonly type: "killer-cage";
|
||||
readonly cells: readonly CellIndex[];
|
||||
readonly sum: number;
|
||||
readonly noRepeat?: boolean;
|
||||
}
|
||||
| { readonly type: "thermo"; readonly cells: readonly CellIndex[] }
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "thermo";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "arrow";
|
||||
readonly bulb: readonly CellIndex[];
|
||||
readonly line: readonly CellIndex[];
|
||||
}
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "kropki";
|
||||
readonly a: CellIndex;
|
||||
readonly b: CellIndex;
|
||||
readonly kind: "white" | "black";
|
||||
}
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "xv";
|
||||
readonly a: CellIndex;
|
||||
readonly b: CellIndex;
|
||||
readonly total: 5 | 10;
|
||||
}
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "inequality";
|
||||
readonly lesser: CellIndex;
|
||||
readonly greater: CellIndex;
|
||||
}
|
||||
| { readonly type: "renban"; readonly cells: readonly CellIndex[] }
|
||||
| { readonly type: "palindrome"; readonly cells: readonly CellIndex[] };
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "x-sum";
|
||||
readonly side: OutsideSide;
|
||||
readonly index: number;
|
||||
readonly sum: number;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "skyscraper";
|
||||
readonly side: OutsideSide;
|
||||
readonly index: number;
|
||||
readonly count: number;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "quadruple";
|
||||
readonly cells: readonly CellIndex[];
|
||||
readonly digits: readonly number[];
|
||||
} & CluePolarity)
|
||||
| ({ readonly type: "maximum"; readonly cell: CellIndex } & CluePolarity)
|
||||
| ({
|
||||
readonly type: "renban";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "palindrome";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity);
|
||||
|
||||
/**
|
||||
* Stable, versioned interchange format owned by Sudoku Tools. Cell indices are
|
||||
@@ -57,6 +96,8 @@ export interface SudokuDocument {
|
||||
readonly candidates?: readonly (readonly number[])[];
|
||||
readonly colors?: readonly number[];
|
||||
readonly elapsedMs?: number;
|
||||
/** Optional, non-constraining scratch cells used while solving. */
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
readonly solution?: readonly number[];
|
||||
readonly regions?: readonly number[];
|
||||
readonly constraints: readonly PortableConstraint[];
|
||||
@@ -136,6 +177,12 @@ export function cloneConstraint(
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return { ...constraint, cells: [...constraint.cells] };
|
||||
case "quadruple":
|
||||
return {
|
||||
...constraint,
|
||||
cells: [...constraint.cells],
|
||||
digits: [...constraint.digits],
|
||||
};
|
||||
case "arrow":
|
||||
return {
|
||||
...constraint,
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
import { normalizePuzzle } from "../domain/validation";
|
||||
import type {
|
||||
NormalizedPuzzle,
|
||||
OutsideClueSide,
|
||||
VariantConstraint,
|
||||
} from "../domain/types";
|
||||
import { symbolFor } from "../state/session";
|
||||
import { normalizeSudokuDocument, SudokuFormatError } from "./document";
|
||||
import { toDomainPuzzle, type SudokuDocument } from "./types";
|
||||
|
||||
export interface VisualExportOptions {
|
||||
readonly includeProgress?: boolean;
|
||||
readonly includeNotes?: boolean;
|
||||
readonly rasterScale?: number;
|
||||
}
|
||||
|
||||
export const MAX_VISUAL_EXPORT_DIMENSION = 4_096;
|
||||
export const MAX_VISUAL_EXPORT_BYTES = 5_242_880;
|
||||
|
||||
const CELL_SIZE = 72;
|
||||
const OUTSIDE_MARGIN = 58;
|
||||
const BOARD_MARGIN = 20;
|
||||
const HEADER_HEIGHT = 74;
|
||||
|
||||
function escapeXml(value: unknown): string {
|
||||
const unicode = new TextDecoder().decode(
|
||||
new TextEncoder().encode(String(value)),
|
||||
);
|
||||
const xmlCharacters = [...unicode]
|
||||
.map((character) => {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
return codePoint === 0x09 ||
|
||||
codePoint === 0x0a ||
|
||||
codePoint === 0x0d ||
|
||||
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
|
||||
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
|
||||
(codePoint >= 0x1_0000 && codePoint <= 0x10_ffff)
|
||||
? character
|
||||
: "�";
|
||||
})
|
||||
.join("");
|
||||
return xmlCharacters
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function number(value: number): string {
|
||||
return Number(value.toFixed(3)).toString();
|
||||
}
|
||||
|
||||
interface BoardOrigin {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
function point(size: number, cell: number, origin: BoardOrigin) {
|
||||
return {
|
||||
x: origin.x + ((cell % size) + 0.5) * CELL_SIZE,
|
||||
y: origin.y + (Math.floor(cell / size) + 0.5) * CELL_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function linePoints(
|
||||
size: number,
|
||||
cells: readonly number[],
|
||||
origin: BoardOrigin,
|
||||
) {
|
||||
return cells
|
||||
.map((cell) => {
|
||||
const position = point(size, cell, origin);
|
||||
return `${number(position.x)},${number(position.y)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function pathBoundary(
|
||||
size: number,
|
||||
cells: ReadonlySet<number>,
|
||||
origin: BoardOrigin,
|
||||
inset: number,
|
||||
): string {
|
||||
const commands: string[] = [];
|
||||
for (const cell of cells) {
|
||||
const row = Math.floor(cell / size);
|
||||
const column = cell % size;
|
||||
const x0 = origin.x + column * CELL_SIZE + inset;
|
||||
const x1 = origin.x + (column + 1) * CELL_SIZE - inset;
|
||||
const y0 = origin.y + row * CELL_SIZE + inset;
|
||||
const y1 = origin.y + (row + 1) * CELL_SIZE - inset;
|
||||
if (row === 0 || !cells.has(cell - size))
|
||||
commands.push(`M${number(x0)} ${number(y0)}H${number(x1)}`);
|
||||
if (row === size - 1 || !cells.has(cell + size))
|
||||
commands.push(`M${number(x0)} ${number(y1)}H${number(x1)}`);
|
||||
if (column === 0 || !cells.has(cell - 1))
|
||||
commands.push(`M${number(x0)} ${number(y0)}V${number(y1)}`);
|
||||
if (column === size - 1 || !cells.has(cell + 1))
|
||||
commands.push(`M${number(x1)} ${number(y0)}V${number(y1)}`);
|
||||
}
|
||||
return commands.join(" ");
|
||||
}
|
||||
|
||||
function outsidePoint(
|
||||
size: number,
|
||||
side: OutsideClueSide,
|
||||
index: number,
|
||||
origin: BoardOrigin,
|
||||
) {
|
||||
const board = size * CELL_SIZE;
|
||||
switch (side) {
|
||||
case "top":
|
||||
return {
|
||||
x: origin.x + (index + 0.5) * CELL_SIZE,
|
||||
y: origin.y - OUTSIDE_MARGIN * 0.52,
|
||||
};
|
||||
case "right":
|
||||
return {
|
||||
x: origin.x + board + OUTSIDE_MARGIN * 0.52,
|
||||
y: origin.y + (index + 0.5) * CELL_SIZE,
|
||||
};
|
||||
case "bottom":
|
||||
return {
|
||||
x: origin.x + (index + 0.5) * CELL_SIZE,
|
||||
y: origin.y + board + OUTSIDE_MARGIN * 0.52,
|
||||
};
|
||||
case "left":
|
||||
return {
|
||||
x: origin.x - OUTSIDE_MARGIN * 0.52,
|
||||
y: origin.y + (index + 0.5) * CELL_SIZE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function globalRuleLabels(puzzle: NormalizedPuzzle): string[] {
|
||||
const labels: string[] = [];
|
||||
if (puzzle.constraints.some(({ type }) => type === "anti-knight"))
|
||||
labels.push("Anti-knight");
|
||||
if (puzzle.constraints.some(({ type }) => type === "anti-king"))
|
||||
labels.push("Anti-king");
|
||||
if (puzzle.constraints.some(({ type }) => type === "non-consecutive"))
|
||||
labels.push("Non-consecutive");
|
||||
return labels;
|
||||
}
|
||||
|
||||
function renderConstraint(
|
||||
constraint: VariantConstraint,
|
||||
index: number,
|
||||
puzzle: NormalizedPuzzle,
|
||||
origin: BoardOrigin,
|
||||
): string {
|
||||
const { size } = puzzle;
|
||||
const negated = "negated" in constraint && constraint.negated === true;
|
||||
const polarity = negated ? " negated" : "";
|
||||
const marker = negated ? "≠" : "";
|
||||
if (
|
||||
constraint.type === "anti-knight" ||
|
||||
constraint.type === "anti-king" ||
|
||||
constraint.type === "non-consecutive"
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
if (constraint.type === "diagonal") {
|
||||
const startX =
|
||||
origin.x + (constraint.direction === "main" ? 0 : size * CELL_SIZE);
|
||||
const endX =
|
||||
origin.x + (constraint.direction === "main" ? size * CELL_SIZE : 0);
|
||||
return `<line class="constraint diagonal" x1="${number(startX)}" y1="${number(origin.y)}" x2="${number(endX)}" y2="${number(origin.y + size * CELL_SIZE)}"/>`;
|
||||
}
|
||||
if (constraint.type === "killer-cage") {
|
||||
const first = Math.min(...constraint.cells);
|
||||
const label = point(size, first, origin);
|
||||
return `<g class="constraint cage${polarity}" data-constraint="${index}"><path d="${pathBoundary(size, new Set(constraint.cells), origin, 7)}"/><text class="cage-label" x="${number(label.x - CELL_SIZE * 0.34)}" y="${number(label.y - CELL_SIZE * 0.27)}">${marker}${String(constraint.sum)}</text></g>`;
|
||||
}
|
||||
if (constraint.type === "thermo") {
|
||||
const bulb = point(size, constraint.cells[0]!, origin);
|
||||
return `<g class="constraint thermo${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(bulb.x)}" cy="${number(bulb.y)}" r="${number(CELL_SIZE * 0.3)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "arrow") {
|
||||
const bulbPoints = constraint.bulb.map((cell) => point(size, cell, origin));
|
||||
const bulb = bulbPoints[0]!;
|
||||
const path = [constraint.bulb.at(-1)!, ...constraint.line];
|
||||
const tip = point(size, constraint.line.at(-1)!, origin);
|
||||
const minimumX = Math.min(...bulbPoints.map(({ x }) => x));
|
||||
const maximumX = Math.max(...bulbPoints.map(({ x }) => x));
|
||||
const minimumY = Math.min(...bulbPoints.map(({ y }) => y));
|
||||
const maximumY = Math.max(...bulbPoints.map(({ y }) => y));
|
||||
return `<g class="constraint arrow${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, path, origin)}"/><rect x="${number(minimumX - CELL_SIZE * 0.3)}" y="${number(minimumY - CELL_SIZE * 0.3)}" width="${number(maximumX - minimumX + CELL_SIZE * 0.6)}" height="${number(maximumY - minimumY + CELL_SIZE * 0.6)}" rx="${number(CELL_SIZE * 0.3)}"/><circle class="arrow-tip" cx="${number(tip.x)}" cy="${number(tip.y)}" r="${number(CELL_SIZE * 0.075)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "renban" || constraint.type === "palindrome") {
|
||||
return `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/>${
|
||||
constraint.type === "palindrome"
|
||||
? constraint.cells
|
||||
.map((cell) => {
|
||||
const center = point(size, cell, origin);
|
||||
return `<circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.13)}"/>`;
|
||||
})
|
||||
.join("")
|
||||
: ""
|
||||
}</g>`;
|
||||
}
|
||||
if (constraint.type === "maximum") {
|
||||
const center = point(size, constraint.cell, origin);
|
||||
const offset = CELL_SIZE * 0.28;
|
||||
const inner = CELL_SIZE * 0.14;
|
||||
return `<g class="constraint maximum${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.18)}"/><path d="M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x)} ${number(center.y - offset)}L${number(center.x + inner)} ${number(center.y - inner)}M${number(center.x - inner)} ${number(center.y + inner)}L${number(center.x)} ${number(center.y + offset)}L${number(center.x + inner)} ${number(center.y + inner)}M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x - offset)} ${number(center.y)}L${number(center.x - inner)} ${number(center.y + inner)}M${number(center.x + inner)} ${number(center.y - inner)}L${number(center.x + offset)} ${number(center.y)}L${number(center.x + inner)} ${number(center.y + inner)}"/></g>`;
|
||||
}
|
||||
if (constraint.type === "quadruple") {
|
||||
const positions = constraint.cells.map((cell) => point(size, cell, origin));
|
||||
const center = {
|
||||
x:
|
||||
positions.reduce((sum, position) => sum + position.x, 0) /
|
||||
positions.length,
|
||||
y:
|
||||
positions.reduce((sum, position) => sum + position.y, 0) /
|
||||
positions.length,
|
||||
};
|
||||
return `<g class="constraint quadruple${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.29)}"/><text x="${number(center.x)}" y="${number(center.y)}">${marker}${escapeXml(constraint.digits.map((digit) => symbolFor(digit, size)).join(""))}</text></g>`;
|
||||
}
|
||||
if (constraint.type === "x-sum" || constraint.type === "skyscraper") {
|
||||
const position = outsidePoint(
|
||||
size,
|
||||
constraint.side,
|
||||
constraint.index,
|
||||
origin,
|
||||
);
|
||||
const value =
|
||||
constraint.type === "x-sum" ? constraint.sum : constraint.count;
|
||||
const companionIndex = puzzle.constraints.findIndex(
|
||||
(candidate) =>
|
||||
candidate.type !== constraint.type &&
|
||||
(candidate.type === "x-sum" || candidate.type === "skyscraper") &&
|
||||
candidate.side === constraint.side &&
|
||||
candidate.index === constraint.index &&
|
||||
(candidate.type === "x-sum" ? candidate.sum : candidate.count) ===
|
||||
value &&
|
||||
("negated" in candidate && candidate.negated === true) === negated,
|
||||
);
|
||||
const combined = companionIndex >= 0;
|
||||
if (combined && companionIndex < index) return "";
|
||||
if (combined) {
|
||||
return `<g class="constraint outside combined${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 23)}" width="50" height="46" rx="8"/><text class="outside-kinds" x="${number(position.x)}" y="${number(position.y - 8)}">Σ · ▥</text><text class="outside-value" x="${number(position.x)}" y="${number(position.y + 10)}">${marker}${String(value)}</text></g>`;
|
||||
}
|
||||
return `<g class="constraint outside ${constraint.type}${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 18)}" width="50" height="36" rx="8"/><text x="${number(position.x)}" y="${number(position.y)}">${constraint.type === "x-sum" ? "Σ" : "▥"}${marker}${String(value)}</text></g>`;
|
||||
}
|
||||
const a = point(
|
||||
size,
|
||||
constraint.type === "inequality" ? constraint.lesser : constraint.a,
|
||||
origin,
|
||||
);
|
||||
const b = point(
|
||||
size,
|
||||
constraint.type === "inequality" ? constraint.greater : constraint.b,
|
||||
origin,
|
||||
);
|
||||
const middle = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
if (constraint.type === "kropki") {
|
||||
return `<g class="constraint kropki ${constraint.kind}${polarity}" data-constraint="${index}"><circle cx="${number(middle.x)}" cy="${number(middle.y)}" r="${number(CELL_SIZE * 0.11)}"/>${negated ? `<text class="pair-false-marker" x="${number(middle.x)}" y="${number(middle.y)}">×</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "xv") {
|
||||
return `<g class="constraint xv total-${String(constraint.total)}${polarity}" data-constraint="${index}"><circle cx="${number(middle.x)}" cy="${number(middle.y)}" r="${number(CELL_SIZE * 0.19)}"/><text x="${number(middle.x)}" y="${number(middle.y)}">${marker}${String(constraint.total)}</text></g>`;
|
||||
}
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / length;
|
||||
const uy = dy / length;
|
||||
const perpendicular = { x: -uy, y: ux };
|
||||
const tip = {
|
||||
x: middle.x + ux * CELL_SIZE * 0.15,
|
||||
y: middle.y + uy * CELL_SIZE * 0.15,
|
||||
};
|
||||
const back = {
|
||||
x: middle.x - ux * CELL_SIZE * 0.15,
|
||||
y: middle.y - uy * CELL_SIZE * 0.15,
|
||||
};
|
||||
return `<g class="constraint inequality${polarity}" data-constraint="${index}"><polyline points="${number(back.x + perpendicular.x * CELL_SIZE * 0.14)},${number(back.y + perpendicular.y * CELL_SIZE * 0.14)} ${number(tip.x)},${number(tip.y)} ${number(back.x - perpendicular.x * CELL_SIZE * 0.14)},${number(back.y - perpendicular.y * CELL_SIZE * 0.14)}"/><circle class="inequality-tip" cx="${number(tip.x)}" cy="${number(tip.y)}" r="${number(CELL_SIZE * 0.035)}"/></g>`;
|
||||
}
|
||||
|
||||
function renderRegionBoundaries(puzzle: NormalizedPuzzle, origin: BoardOrigin) {
|
||||
const { size, regions } = puzzle;
|
||||
const lines: string[] = [];
|
||||
const board = size * CELL_SIZE;
|
||||
lines.push(
|
||||
`<rect x="${origin.x}" y="${origin.y}" width="${board}" height="${board}"/>`,
|
||||
);
|
||||
for (let row = 0; row < size; row += 1) {
|
||||
for (let column = 0; column < size; column += 1) {
|
||||
const cell = row * size + column;
|
||||
if (column < size - 1 && regions[cell] !== regions[cell + 1]) {
|
||||
const x = origin.x + (column + 1) * CELL_SIZE;
|
||||
const y = origin.y + row * CELL_SIZE;
|
||||
lines.push(
|
||||
`<line x1="${x}" y1="${y}" x2="${x}" y2="${y + CELL_SIZE}"/>`,
|
||||
);
|
||||
}
|
||||
if (row < size - 1 && regions[cell] !== regions[cell + size]) {
|
||||
const x = origin.x + column * CELL_SIZE;
|
||||
const y = origin.y + (row + 1) * CELL_SIZE;
|
||||
lines.push(
|
||||
`<line x1="${x}" y1="${y}" x2="${x + CELL_SIZE}" y2="${y}"/>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `<g class="region-boundaries">${lines.join("")}</g>`;
|
||||
}
|
||||
|
||||
const COLOR_FILLS = [
|
||||
"transparent",
|
||||
"#fff2a8",
|
||||
"#ffd0c7",
|
||||
"#ccefd8",
|
||||
"#cde6ff",
|
||||
"#e3d4ff",
|
||||
"#ffe0b7",
|
||||
"#cdeeed",
|
||||
"#f3d0e6",
|
||||
];
|
||||
|
||||
/** Render a standalone, print-friendly SVG without external resources. */
|
||||
export function renderPuzzleSvg(
|
||||
document: SudokuDocument,
|
||||
options: VisualExportOptions = {},
|
||||
): string {
|
||||
const normalizedDocument = normalizeSudokuDocument(document);
|
||||
const puzzle = normalizePuzzle(toDomainPuzzle(normalizedDocument));
|
||||
const includeProgress = options.includeProgress ?? true;
|
||||
const includeNotes = options.includeNotes ?? includeProgress;
|
||||
const hasOutside = puzzle.constraints.some(
|
||||
({ type }) => type === "x-sum" || type === "skyscraper",
|
||||
);
|
||||
const sideMargin = hasOutside ? OUTSIDE_MARGIN : BOARD_MARGIN;
|
||||
const boardSize = puzzle.size * CELL_SIZE;
|
||||
const width = boardSize + sideMargin * 2;
|
||||
const height = HEADER_HEIGHT + boardSize + sideMargin * 2;
|
||||
const origin: BoardOrigin = {
|
||||
x: sideMargin,
|
||||
y: sideMargin + HEADER_HEIGHT,
|
||||
};
|
||||
const title = (normalizedDocument.title ?? "Sudoku").slice(0, 256);
|
||||
const byline = normalizedDocument.author
|
||||
? `by ${normalizedDocument.author.slice(0, 256)}`
|
||||
: "Sudoku Tools";
|
||||
const globals = globalRuleLabels(puzzle);
|
||||
const values = includeProgress
|
||||
? (normalizedDocument.values ?? puzzle.givens)
|
||||
: puzzle.givens;
|
||||
const cornerMarks = includeNotes
|
||||
? (normalizedDocument.cornerMarks ?? [])
|
||||
: [];
|
||||
const centerMarks = includeNotes
|
||||
? (normalizedDocument.centerMarks ?? normalizedDocument.candidates ?? [])
|
||||
: [];
|
||||
const colors = includeProgress ? (normalizedDocument.colors ?? []) : [];
|
||||
const cellCount = puzzle.size * puzzle.size;
|
||||
if (
|
||||
values.length !== cellCount ||
|
||||
(normalizedDocument.colors !== undefined &&
|
||||
normalizedDocument.colors.length !== cellCount)
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"Puzzle progress does not match the grid size.",
|
||||
);
|
||||
}
|
||||
|
||||
const backgrounds = Array.from({ length: cellCount }, (_, cell) => {
|
||||
const color = colors[cell] ?? 0;
|
||||
const fill = COLOR_FILLS[color] ?? "transparent";
|
||||
if (fill === "transparent") return "";
|
||||
const row = Math.floor(cell / puzzle.size);
|
||||
const column = cell % puzzle.size;
|
||||
return `<rect class="cell-color" x="${origin.x + column * CELL_SIZE}" y="${origin.y + row * CELL_SIZE}" width="${CELL_SIZE}" height="${CELL_SIZE}" fill="${fill}"/>`;
|
||||
}).join("");
|
||||
|
||||
const gridLines = Array.from({ length: puzzle.size - 1 }, (_, index) => {
|
||||
const offset = (index + 1) * CELL_SIZE;
|
||||
return `<path d="M${origin.x + offset} ${origin.y}V${origin.y + boardSize}M${origin.x} ${origin.y + offset}H${origin.x + boardSize}"/>`;
|
||||
}).join("");
|
||||
const constraints = puzzle.constraints
|
||||
.map((constraint, index) =>
|
||||
renderConstraint(constraint, index, puzzle, origin),
|
||||
)
|
||||
.join("");
|
||||
const digits = Array.from({ length: cellCount }, (_, cell) => {
|
||||
const value = puzzle.givens[cell] || values[cell] || 0;
|
||||
const center = point(puzzle.size, cell, origin);
|
||||
if (value !== 0) {
|
||||
if (!Number.isInteger(value) || value < 1 || value > puzzle.size) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
`Cell ${String(cell + 1)} contains an out-of-range value.`,
|
||||
);
|
||||
}
|
||||
return `<text class="cell-value ${puzzle.givens[cell] ? "given" : "progress"}" x="${number(center.x)}" y="${number(center.y)}">${escapeXml(symbolFor(value, puzzle.size))}</text>`;
|
||||
}
|
||||
const corner = cornerMarks[cell] ?? [];
|
||||
const centerNotes = centerMarks[cell] ?? [];
|
||||
const cornerText = corner
|
||||
.slice(0, puzzle.size)
|
||||
.map((digit, noteIndex) => {
|
||||
const columns = Math.ceil(Math.sqrt(puzzle.size));
|
||||
const x =
|
||||
center.x -
|
||||
CELL_SIZE * 0.39 +
|
||||
((noteIndex % columns) * (CELL_SIZE * 0.78)) /
|
||||
Math.max(1, columns - 1);
|
||||
const y =
|
||||
center.y -
|
||||
CELL_SIZE * 0.34 +
|
||||
Math.floor(noteIndex / columns) * CELL_SIZE * 0.19;
|
||||
return `<text class="corner-note" x="${number(x)}" y="${number(y)}">${escapeXml(symbolFor(digit, puzzle.size))}</text>`;
|
||||
})
|
||||
.join("");
|
||||
const centerText = centerNotes.length
|
||||
? `<text class="center-note" x="${number(center.x)}" y="${number(center.y)}">${escapeXml(centerNotes.map((digit) => symbolFor(digit, puzzle.size)).join(""))}</text>`
|
||||
: "";
|
||||
return cornerText + centerText;
|
||||
}).join("");
|
||||
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="title description">
|
||||
<title id="title">${escapeXml(title)}</title>
|
||||
<desc id="description">${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)}</desc>
|
||||
<style>
|
||||
.background{fill:#fff}.heading{fill:#172033;font-family:system-ui,sans-serif}.title{font-size:28px;font-weight:750}.byline,.global-rules{fill:#5d6678;font-size:14px}.grid-lines{fill:none;stroke:#a5acb8;stroke-width:1}.region-boundaries{fill:none;stroke:#172033;stroke-width:3;stroke-linecap:square}.constraint{fill:none;stroke:#596273;stroke-width:6;stroke-linecap:round;stroke-linejoin:round}.constraint text{dominant-baseline:central;text-anchor:middle;fill:#172033;stroke:none;font-family:system-ui,sans-serif;font-weight:700}.constraint.negated{stroke:#c63f52;stroke-dasharray:10 7}.constraint.negated text{fill:#a5283b}.diagonal{stroke:#4da3d6;stroke-width:3}.cage{stroke-width:2.2;stroke-dasharray:6 5}.cage-label{font-size:14px;text-anchor:start!important}.thermo{stroke:#c3c7ce;stroke-width:20}.thermo circle{fill:#c3c7ce;stroke:none}.thermo.negated{stroke-width:13}.false-marker{font-size:22px}.arrow{stroke:#929aa7;stroke-width:5}.arrow rect{fill:#fff;stroke:#929aa7}.arrow-tip{fill:#929aa7;stroke:none}.renban{stroke:#b55b8c;stroke-width:14;opacity:.72}.palindrome{stroke:#9ba1ad;stroke-width:13}.palindrome circle{fill:#d8dbe1;stroke:none}.maximum circle{fill:#eef0f4;stroke:#596273;stroke-width:2}.maximum path{stroke-width:3}.quadruple circle{fill:#fff;stroke:#596273;stroke-width:2}.quadruple text{font-size:15px}.outside rect{fill:#fff;stroke:#788191;stroke-width:1.5}.outside text{font-size:17px}.outside .outside-kinds{font-size:12px}.outside .outside-value{font-size:16px}.kropki circle{stroke:#172033;stroke-width:2}.kropki.white circle{fill:#fff}.kropki.black circle{fill:#172033}.pair-false-marker{fill:#c63f52!important;font-size:14px}.xv circle{fill:#fff;stroke:#fff}.xv text{font-size:18px}.inequality{stroke:#172033;stroke-width:4}.inequality-tip{fill:#172033;stroke:none}.cell-value,.corner-note,.center-note{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.cell-value{fill:#273c75;font-size:${number(CELL_SIZE * 0.58)}px}.cell-value.given{fill:#111827;font-weight:800}.cell-value.progress{font-weight:600}.corner-note{fill:#596273;font-size:${number(CELL_SIZE * 0.15)}px}.center-note{fill:#596273;font-size:${number(CELL_SIZE * 0.2)}px;letter-spacing:1px}
|
||||
</style>
|
||||
<rect class="background" width="100%" height="100%"/>
|
||||
<g class="heading"><text class="title" x="${origin.x}" y="34">${escapeXml(title)}</text><text class="byline" x="${origin.x}" y="57">${escapeXml(byline)}</text>${globals.length > 0 ? `<text class="global-rules" x="${width - sideMargin}" y="57" text-anchor="end">${escapeXml(globals.join(" · "))}</text>` : ""}</g>
|
||||
<g>${backgrounds}<g class="grid-lines">${gridLines}</g>${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}</g>
|
||||
</svg>`;
|
||||
if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The rendered SVG is too large to export safely.",
|
||||
);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
function svgDimensions(svg: string): { width: number; height: number } {
|
||||
const match = /<svg[^>]+width="(\d+)"[^>]+height="(\d+)"/u.exec(svg);
|
||||
if (match === null) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"The generated SVG has no dimensions.",
|
||||
);
|
||||
}
|
||||
return { width: Number(match[1]), height: Number(match[2]) };
|
||||
}
|
||||
|
||||
async function loadSvgImage(svg: string): Promise<CanvasImageSource> {
|
||||
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
return await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari and some hardened browsers cannot decode SVG through ImageBitmap.
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
return await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () =>
|
||||
reject(new Error("The SVG image could not be decoded."));
|
||||
image.src = url;
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function rasterizeSvg(
|
||||
svg: string,
|
||||
scale: number,
|
||||
): Promise<HTMLCanvasElement> {
|
||||
if (!Number.isFinite(scale) || scale < 0.5 || scale > 4) {
|
||||
throw new RangeError("rasterScale must be from 0.5 to 4.");
|
||||
}
|
||||
const dimensions = svgDimensions(svg);
|
||||
const boundedScale = Math.min(
|
||||
scale,
|
||||
MAX_VISUAL_EXPORT_DIMENSION / Math.max(dimensions.width, dimensions.height),
|
||||
);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(dimensions.width * boundedScale));
|
||||
canvas.height = Math.max(1, Math.round(dimensions.height * boundedScale));
|
||||
const context = canvas.getContext("2d");
|
||||
if (context === null) {
|
||||
throw new SudokuFormatError(
|
||||
"UNSUPPORTED_BROWSER",
|
||||
"This browser cannot create a canvas for visual export.",
|
||||
);
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const image = await loadSvgImage(svg);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
if ("close" in image && typeof image.close === "function") image.close();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function canvasBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
type: "image/png" | "image/jpeg",
|
||||
quality?: number,
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) =>
|
||||
blob === null
|
||||
? reject(new Error(`The browser could not encode ${type}.`))
|
||||
: resolve(blob),
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderPuzzlePng(
|
||||
document: SudokuDocument,
|
||||
options: VisualExportOptions = {},
|
||||
): Promise<Blob> {
|
||||
const canvas = await rasterizeSvg(
|
||||
renderPuzzleSvg(document, options),
|
||||
options.rasterScale ?? 2,
|
||||
);
|
||||
const blob = await canvasBlob(canvas, "image/png");
|
||||
if (blob.size > MAX_VISUAL_EXPORT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The rendered PNG is too large to export safely.",
|
||||
);
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
function ascii(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function concatenate(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const length = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
||||
const output = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
output.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Build a single-page PDF around browser-generated JPEG bytes. */
|
||||
export function buildJpegPdf(
|
||||
jpeg: Uint8Array,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): Uint8Array {
|
||||
if (
|
||||
jpeg.byteLength < 4 ||
|
||||
jpeg.byteLength > MAX_VISUAL_EXPORT_BYTES ||
|
||||
jpeg[0] !== 0xff ||
|
||||
jpeg[1] !== 0xd8 ||
|
||||
jpeg.at(-2) !== 0xff ||
|
||||
jpeg.at(-1) !== 0xd9
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"The PDF renderer did not receive a bounded JPEG image.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(imageWidth) ||
|
||||
!Number.isInteger(imageHeight) ||
|
||||
imageWidth < 1 ||
|
||||
imageHeight < 1 ||
|
||||
imageWidth > MAX_VISUAL_EXPORT_DIMENSION ||
|
||||
imageHeight > MAX_VISUAL_EXPORT_DIMENSION
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"The PDF image dimensions are invalid.",
|
||||
);
|
||||
}
|
||||
const pageWidth = 595;
|
||||
const pageHeight = 842;
|
||||
const maximumWidth = pageWidth - 64;
|
||||
const maximumHeight = pageHeight - 64;
|
||||
const scale = Math.min(
|
||||
maximumWidth / imageWidth,
|
||||
maximumHeight / imageHeight,
|
||||
);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const x = (pageWidth - drawWidth) / 2;
|
||||
const y = (pageHeight - drawHeight) / 2;
|
||||
const content = `q\n${number(drawWidth)} 0 0 ${number(drawHeight)} ${number(x)} ${number(y)} cm\n/Im0 Do\nQ\n`;
|
||||
const objects: Uint8Array[] = [
|
||||
ascii("<< /Type /Catalog /Pages 2 0 R >>"),
|
||||
ascii("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"),
|
||||
ascii(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>`,
|
||||
),
|
||||
concatenate([
|
||||
ascii(
|
||||
`<< /Type /XObject /Subtype /Image /Width ${String(imageWidth)} /Height ${String(imageHeight)} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${String(jpeg.byteLength)} >>\nstream\n`,
|
||||
),
|
||||
jpeg,
|
||||
ascii("\nendstream"),
|
||||
]),
|
||||
ascii(
|
||||
`<< /Length ${String(ascii(content).byteLength)} >>\nstream\n${content}endstream`,
|
||||
),
|
||||
];
|
||||
const parts: Uint8Array[] = [
|
||||
new Uint8Array([
|
||||
...ascii("%PDF-1.4\n%"),
|
||||
0xe2,
|
||||
0xe3,
|
||||
0xcf,
|
||||
0xd3,
|
||||
...ascii("\n"),
|
||||
]),
|
||||
];
|
||||
const offsets = [0];
|
||||
let byteOffset = parts[0]!.byteLength;
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(byteOffset);
|
||||
const wrapped = concatenate([
|
||||
ascii(`${String(index + 1)} 0 obj\n`),
|
||||
object,
|
||||
ascii("\nendobj\n"),
|
||||
]);
|
||||
parts.push(wrapped);
|
||||
byteOffset += wrapped.byteLength;
|
||||
});
|
||||
const xrefOffset = byteOffset;
|
||||
const xref = [
|
||||
`xref\n0 ${String(objects.length + 1)}\n`,
|
||||
"0000000000 65535 f \n",
|
||||
...offsets
|
||||
.slice(1)
|
||||
.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`),
|
||||
`trailer\n<< /Size ${String(objects.length + 1)} /Root 1 0 R >>\nstartxref\n${String(xrefOffset)}\n%%EOF\n`,
|
||||
].join("");
|
||||
parts.push(ascii(xref));
|
||||
return concatenate(parts);
|
||||
}
|
||||
|
||||
export async function renderPuzzlePdf(
|
||||
document: SudokuDocument,
|
||||
options: VisualExportOptions = {},
|
||||
): Promise<Blob> {
|
||||
const canvas = await rasterizeSvg(
|
||||
renderPuzzleSvg(document, options),
|
||||
options.rasterScale ?? 2,
|
||||
);
|
||||
const jpeg = new Uint8Array(
|
||||
await (await canvasBlob(canvas, "image/jpeg", 0.94)).arrayBuffer(),
|
||||
);
|
||||
const pdf = buildJpegPdf(jpeg, canvas.width, canvas.height);
|
||||
const buffer = new ArrayBuffer(pdf.byteLength);
|
||||
new Uint8Array(buffer).set(pdf);
|
||||
return new Blob([buffer], { type: "application/pdf" });
|
||||
}
|
||||
Reference in New Issue
Block a user