feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
+247
View File
@@ -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 062 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
View File
@@ -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
View File
@@ -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;
}
+189
View File
@@ -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",
};
}
+5
View File
@@ -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";
+37
View File
@@ -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";
}
}
+581
View File
@@ -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
View File
@@ -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);
}
+489
View File
@@ -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
View File
@@ -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,
+669
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
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" });
}