Files
sudoku-tools/src/formats/visual.ts
T

670 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" });
}