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
+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" });
}