670 lines
28 KiB
TypeScript
670 lines
28 KiB
TypeScript
import { normalizePuzzle } from "../domain/validation";
|
||
import type {
|
||
NormalizedPuzzle,
|
||
OutsideClueSide,
|
||
VariantConstraint,
|
||
} from "../domain/types";
|
||
import { symbolFor } from "../state/session";
|
||
import { normalizeSudokuDocument, SudokuFormatError } from "./document";
|
||
import { toDomainPuzzle, type SudokuDocument } from "./types";
|
||
|
||
export interface VisualExportOptions {
|
||
readonly includeProgress?: boolean;
|
||
readonly includeNotes?: boolean;
|
||
readonly rasterScale?: number;
|
||
}
|
||
|
||
export const MAX_VISUAL_EXPORT_DIMENSION = 4_096;
|
||
export const MAX_VISUAL_EXPORT_BYTES = 5_242_880;
|
||
|
||
const CELL_SIZE = 72;
|
||
const OUTSIDE_MARGIN = 58;
|
||
const BOARD_MARGIN = 20;
|
||
const HEADER_HEIGHT = 74;
|
||
|
||
function escapeXml(value: unknown): string {
|
||
const unicode = new TextDecoder().decode(
|
||
new TextEncoder().encode(String(value)),
|
||
);
|
||
const xmlCharacters = [...unicode]
|
||
.map((character) => {
|
||
const codePoint = character.codePointAt(0)!;
|
||
return codePoint === 0x09 ||
|
||
codePoint === 0x0a ||
|
||
codePoint === 0x0d ||
|
||
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
|
||
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
|
||
(codePoint >= 0x1_0000 && codePoint <= 0x10_ffff)
|
||
? character
|
||
: "�";
|
||
})
|
||
.join("");
|
||
return xmlCharacters
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function number(value: number): string {
|
||
return Number(value.toFixed(3)).toString();
|
||
}
|
||
|
||
interface BoardOrigin {
|
||
readonly x: number;
|
||
readonly y: number;
|
||
}
|
||
|
||
function point(size: number, cell: number, origin: BoardOrigin) {
|
||
return {
|
||
x: origin.x + ((cell % size) + 0.5) * CELL_SIZE,
|
||
y: origin.y + (Math.floor(cell / size) + 0.5) * CELL_SIZE,
|
||
};
|
||
}
|
||
|
||
function linePoints(
|
||
size: number,
|
||
cells: readonly number[],
|
||
origin: BoardOrigin,
|
||
) {
|
||
return cells
|
||
.map((cell) => {
|
||
const position = point(size, cell, origin);
|
||
return `${number(position.x)},${number(position.y)}`;
|
||
})
|
||
.join(" ");
|
||
}
|
||
|
||
function pathBoundary(
|
||
size: number,
|
||
cells: ReadonlySet<number>,
|
||
origin: BoardOrigin,
|
||
inset: number,
|
||
): string {
|
||
const commands: string[] = [];
|
||
for (const cell of cells) {
|
||
const row = Math.floor(cell / size);
|
||
const column = cell % size;
|
||
const x0 = origin.x + column * CELL_SIZE + inset;
|
||
const x1 = origin.x + (column + 1) * CELL_SIZE - inset;
|
||
const y0 = origin.y + row * CELL_SIZE + inset;
|
||
const y1 = origin.y + (row + 1) * CELL_SIZE - inset;
|
||
if (row === 0 || !cells.has(cell - size))
|
||
commands.push(`M${number(x0)} ${number(y0)}H${number(x1)}`);
|
||
if (row === size - 1 || !cells.has(cell + size))
|
||
commands.push(`M${number(x0)} ${number(y1)}H${number(x1)}`);
|
||
if (column === 0 || !cells.has(cell - 1))
|
||
commands.push(`M${number(x0)} ${number(y0)}V${number(y1)}`);
|
||
if (column === size - 1 || !cells.has(cell + 1))
|
||
commands.push(`M${number(x1)} ${number(y0)}V${number(y1)}`);
|
||
}
|
||
return commands.join(" ");
|
||
}
|
||
|
||
function outsidePoint(
|
||
size: number,
|
||
side: OutsideClueSide,
|
||
index: number,
|
||
origin: BoardOrigin,
|
||
) {
|
||
const board = size * CELL_SIZE;
|
||
switch (side) {
|
||
case "top":
|
||
return {
|
||
x: origin.x + (index + 0.5) * CELL_SIZE,
|
||
y: origin.y - OUTSIDE_MARGIN * 0.52,
|
||
};
|
||
case "right":
|
||
return {
|
||
x: origin.x + board + OUTSIDE_MARGIN * 0.52,
|
||
y: origin.y + (index + 0.5) * CELL_SIZE,
|
||
};
|
||
case "bottom":
|
||
return {
|
||
x: origin.x + (index + 0.5) * CELL_SIZE,
|
||
y: origin.y + board + OUTSIDE_MARGIN * 0.52,
|
||
};
|
||
case "left":
|
||
return {
|
||
x: origin.x - OUTSIDE_MARGIN * 0.52,
|
||
y: origin.y + (index + 0.5) * CELL_SIZE,
|
||
};
|
||
}
|
||
}
|
||
|
||
function globalRuleLabels(puzzle: NormalizedPuzzle): string[] {
|
||
const labels: string[] = [];
|
||
if (puzzle.constraints.some(({ type }) => type === "anti-knight"))
|
||
labels.push("Anti-knight");
|
||
if (puzzle.constraints.some(({ type }) => type === "anti-king"))
|
||
labels.push("Anti-king");
|
||
if (puzzle.constraints.some(({ type }) => type === "non-consecutive"))
|
||
labels.push("Non-consecutive");
|
||
return labels;
|
||
}
|
||
|
||
function renderConstraint(
|
||
constraint: VariantConstraint,
|
||
index: number,
|
||
puzzle: NormalizedPuzzle,
|
||
origin: BoardOrigin,
|
||
): string {
|
||
const { size } = puzzle;
|
||
const negated = "negated" in constraint && constraint.negated === true;
|
||
const polarity = negated ? " negated" : "";
|
||
const marker = negated ? "≠" : "";
|
||
if (
|
||
constraint.type === "anti-knight" ||
|
||
constraint.type === "anti-king" ||
|
||
constraint.type === "non-consecutive"
|
||
) {
|
||
return "";
|
||
}
|
||
if (constraint.type === "diagonal") {
|
||
const startX =
|
||
origin.x + (constraint.direction === "main" ? 0 : size * CELL_SIZE);
|
||
const endX =
|
||
origin.x + (constraint.direction === "main" ? size * CELL_SIZE : 0);
|
||
return `<line class="constraint diagonal" x1="${number(startX)}" y1="${number(origin.y)}" x2="${number(endX)}" y2="${number(origin.y + size * CELL_SIZE)}"/>`;
|
||
}
|
||
if (constraint.type === "killer-cage") {
|
||
const first = Math.min(...constraint.cells);
|
||
const label = point(size, first, origin);
|
||
return `<g class="constraint cage${polarity}" data-constraint="${index}"><path d="${pathBoundary(size, new Set(constraint.cells), origin, 7)}"/><text class="cage-label" x="${number(label.x - CELL_SIZE * 0.34)}" y="${number(label.y - CELL_SIZE * 0.27)}">${marker}${String(constraint.sum)}</text></g>`;
|
||
}
|
||
if (constraint.type === "thermo") {
|
||
const bulb = point(size, constraint.cells[0]!, origin);
|
||
return `<g class="constraint thermo${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(bulb.x)}" cy="${number(bulb.y)}" r="${number(CELL_SIZE * 0.3)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
|
||
}
|
||
if (constraint.type === "arrow") {
|
||
const bulbPoints = constraint.bulb.map((cell) => point(size, cell, origin));
|
||
const bulb = bulbPoints[0]!;
|
||
const path = [constraint.bulb.at(-1)!, ...constraint.line];
|
||
const tip = point(size, constraint.line.at(-1)!, origin);
|
||
const minimumX = Math.min(...bulbPoints.map(({ x }) => x));
|
||
const maximumX = Math.max(...bulbPoints.map(({ x }) => x));
|
||
const minimumY = Math.min(...bulbPoints.map(({ y }) => y));
|
||
const maximumY = Math.max(...bulbPoints.map(({ y }) => y));
|
||
return `<g class="constraint arrow${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, path, origin)}"/><rect x="${number(minimumX - CELL_SIZE * 0.3)}" y="${number(minimumY - CELL_SIZE * 0.3)}" width="${number(maximumX - minimumX + CELL_SIZE * 0.6)}" height="${number(maximumY - minimumY + CELL_SIZE * 0.6)}" rx="${number(CELL_SIZE * 0.3)}"/><circle class="arrow-tip" cx="${number(tip.x)}" cy="${number(tip.y)}" r="${number(CELL_SIZE * 0.075)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
|
||
}
|
||
if (constraint.type === "renban" || constraint.type === "palindrome") {
|
||
return `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/>${
|
||
constraint.type === "palindrome"
|
||
? constraint.cells
|
||
.map((cell) => {
|
||
const center = point(size, cell, origin);
|
||
return `<circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.13)}"/>`;
|
||
})
|
||
.join("")
|
||
: ""
|
||
}</g>`;
|
||
}
|
||
if (constraint.type === "maximum") {
|
||
const center = point(size, constraint.cell, origin);
|
||
const offset = CELL_SIZE * 0.28;
|
||
const inner = CELL_SIZE * 0.14;
|
||
return `<g class="constraint maximum${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.18)}"/><path d="M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x)} ${number(center.y - offset)}L${number(center.x + inner)} ${number(center.y - inner)}M${number(center.x - inner)} ${number(center.y + inner)}L${number(center.x)} ${number(center.y + offset)}L${number(center.x + inner)} ${number(center.y + inner)}M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x - offset)} ${number(center.y)}L${number(center.x - inner)} ${number(center.y + inner)}M${number(center.x + inner)} ${number(center.y - inner)}L${number(center.x + offset)} ${number(center.y)}L${number(center.x + inner)} ${number(center.y + inner)}"/></g>`;
|
||
}
|
||
if (constraint.type === "quadruple") {
|
||
const positions = constraint.cells.map((cell) => point(size, cell, origin));
|
||
const center = {
|
||
x:
|
||
positions.reduce((sum, position) => sum + position.x, 0) /
|
||
positions.length,
|
||
y:
|
||
positions.reduce((sum, position) => sum + position.y, 0) /
|
||
positions.length,
|
||
};
|
||
return `<g class="constraint quadruple${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.29)}"/><text x="${number(center.x)}" y="${number(center.y)}">${marker}${escapeXml(constraint.digits.map((digit) => symbolFor(digit, size)).join(""))}</text></g>`;
|
||
}
|
||
if (constraint.type === "x-sum" || constraint.type === "skyscraper") {
|
||
const position = outsidePoint(
|
||
size,
|
||
constraint.side,
|
||
constraint.index,
|
||
origin,
|
||
);
|
||
const value =
|
||
constraint.type === "x-sum" ? constraint.sum : constraint.count;
|
||
const companionIndex = puzzle.constraints.findIndex(
|
||
(candidate) =>
|
||
candidate.type !== constraint.type &&
|
||
(candidate.type === "x-sum" || candidate.type === "skyscraper") &&
|
||
candidate.side === constraint.side &&
|
||
candidate.index === constraint.index &&
|
||
(candidate.type === "x-sum" ? candidate.sum : candidate.count) ===
|
||
value &&
|
||
("negated" in candidate && candidate.negated === true) === negated,
|
||
);
|
||
const combined = companionIndex >= 0;
|
||
if (combined && companionIndex < index) return "";
|
||
if (combined) {
|
||
return `<g class="constraint outside combined${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 23)}" width="50" height="46" rx="8"/><text class="outside-kinds" x="${number(position.x)}" y="${number(position.y - 8)}">Σ · ▥</text><text class="outside-value" x="${number(position.x)}" y="${number(position.y + 10)}">${marker}${String(value)}</text></g>`;
|
||
}
|
||
return `<g class="constraint outside ${constraint.type}${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 18)}" width="50" height="36" rx="8"/><text x="${number(position.x)}" y="${number(position.y)}">${constraint.type === "x-sum" ? "Σ" : "▥"}${marker}${String(value)}</text></g>`;
|
||
}
|
||
const a = point(
|
||
size,
|
||
constraint.type === "inequality" ? constraint.lesser : constraint.a,
|
||
origin,
|
||
);
|
||
const b = point(
|
||
size,
|
||
constraint.type === "inequality" ? constraint.greater : constraint.b,
|
||
origin,
|
||
);
|
||
const middle = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||
if (constraint.type === "kropki") {
|
||
return `<g class="constraint kropki ${constraint.kind}${polarity}" data-constraint="${index}"><circle cx="${number(middle.x)}" cy="${number(middle.y)}" r="${number(CELL_SIZE * 0.11)}"/>${negated ? `<text class="pair-false-marker" x="${number(middle.x)}" y="${number(middle.y)}">×</text>` : ""}</g>`;
|
||
}
|
||
if (constraint.type === "xv") {
|
||
return `<g class="constraint xv total-${String(constraint.total)}${polarity}" data-constraint="${index}"><circle cx="${number(middle.x)}" cy="${number(middle.y)}" r="${number(CELL_SIZE * 0.19)}"/><text x="${number(middle.x)}" y="${number(middle.y)}">${marker}${String(constraint.total)}</text></g>`;
|
||
}
|
||
const dx = a.x - b.x;
|
||
const dy = a.y - b.y;
|
||
const length = Math.hypot(dx, dy) || 1;
|
||
const ux = dx / length;
|
||
const uy = dy / length;
|
||
const perpendicular = { x: -uy, y: ux };
|
||
const tip = {
|
||
x: middle.x + ux * CELL_SIZE * 0.15,
|
||
y: middle.y + uy * CELL_SIZE * 0.15,
|
||
};
|
||
const back = {
|
||
x: middle.x - ux * CELL_SIZE * 0.15,
|
||
y: middle.y - uy * CELL_SIZE * 0.15,
|
||
};
|
||
return `<g class="constraint inequality${polarity}" data-constraint="${index}"><polyline points="${number(back.x + perpendicular.x * CELL_SIZE * 0.14)},${number(back.y + perpendicular.y * CELL_SIZE * 0.14)} ${number(tip.x)},${number(tip.y)} ${number(back.x - perpendicular.x * CELL_SIZE * 0.14)},${number(back.y - perpendicular.y * CELL_SIZE * 0.14)}"/><circle class="inequality-tip" cx="${number(tip.x)}" cy="${number(tip.y)}" r="${number(CELL_SIZE * 0.035)}"/></g>`;
|
||
}
|
||
|
||
function renderRegionBoundaries(puzzle: NormalizedPuzzle, origin: BoardOrigin) {
|
||
const { size, regions } = puzzle;
|
||
const lines: string[] = [];
|
||
const board = size * CELL_SIZE;
|
||
lines.push(
|
||
`<rect x="${origin.x}" y="${origin.y}" width="${board}" height="${board}"/>`,
|
||
);
|
||
for (let row = 0; row < size; row += 1) {
|
||
for (let column = 0; column < size; column += 1) {
|
||
const cell = row * size + column;
|
||
if (column < size - 1 && regions[cell] !== regions[cell + 1]) {
|
||
const x = origin.x + (column + 1) * CELL_SIZE;
|
||
const y = origin.y + row * CELL_SIZE;
|
||
lines.push(
|
||
`<line x1="${x}" y1="${y}" x2="${x}" y2="${y + CELL_SIZE}"/>`,
|
||
);
|
||
}
|
||
if (row < size - 1 && regions[cell] !== regions[cell + size]) {
|
||
const x = origin.x + column * CELL_SIZE;
|
||
const y = origin.y + (row + 1) * CELL_SIZE;
|
||
lines.push(
|
||
`<line x1="${x}" y1="${y}" x2="${x + CELL_SIZE}" y2="${y}"/>`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return `<g class="region-boundaries">${lines.join("")}</g>`;
|
||
}
|
||
|
||
const COLOR_FILLS = [
|
||
"transparent",
|
||
"#fff2a8",
|
||
"#ffd0c7",
|
||
"#ccefd8",
|
||
"#cde6ff",
|
||
"#e3d4ff",
|
||
"#ffe0b7",
|
||
"#cdeeed",
|
||
"#f3d0e6",
|
||
];
|
||
|
||
/** Render a standalone, print-friendly SVG without external resources. */
|
||
export function renderPuzzleSvg(
|
||
document: SudokuDocument,
|
||
options: VisualExportOptions = {},
|
||
): string {
|
||
const normalizedDocument = normalizeSudokuDocument(document);
|
||
const puzzle = normalizePuzzle(toDomainPuzzle(normalizedDocument));
|
||
const includeProgress = options.includeProgress ?? true;
|
||
const includeNotes = options.includeNotes ?? includeProgress;
|
||
const hasOutside = puzzle.constraints.some(
|
||
({ type }) => type === "x-sum" || type === "skyscraper",
|
||
);
|
||
const sideMargin = hasOutside ? OUTSIDE_MARGIN : BOARD_MARGIN;
|
||
const boardSize = puzzle.size * CELL_SIZE;
|
||
const width = boardSize + sideMargin * 2;
|
||
const height = HEADER_HEIGHT + boardSize + sideMargin * 2;
|
||
const origin: BoardOrigin = {
|
||
x: sideMargin,
|
||
y: sideMargin + HEADER_HEIGHT,
|
||
};
|
||
const title = (normalizedDocument.title ?? "Sudoku").slice(0, 256);
|
||
const byline = normalizedDocument.author
|
||
? `by ${normalizedDocument.author.slice(0, 256)}`
|
||
: "Sudoku Tools";
|
||
const globals = globalRuleLabels(puzzle);
|
||
const values = includeProgress
|
||
? (normalizedDocument.values ?? puzzle.givens)
|
||
: puzzle.givens;
|
||
const cornerMarks = includeNotes
|
||
? (normalizedDocument.cornerMarks ?? [])
|
||
: [];
|
||
const centerMarks = includeNotes
|
||
? (normalizedDocument.centerMarks ?? normalizedDocument.candidates ?? [])
|
||
: [];
|
||
const colors = includeProgress ? (normalizedDocument.colors ?? []) : [];
|
||
const cellCount = puzzle.size * puzzle.size;
|
||
if (
|
||
values.length !== cellCount ||
|
||
(normalizedDocument.colors !== undefined &&
|
||
normalizedDocument.colors.length !== cellCount)
|
||
) {
|
||
throw new SudokuFormatError(
|
||
"INVALID_VISUAL_EXPORT",
|
||
"Puzzle progress does not match the grid size.",
|
||
);
|
||
}
|
||
|
||
const backgrounds = Array.from({ length: cellCount }, (_, cell) => {
|
||
const color = colors[cell] ?? 0;
|
||
const fill = COLOR_FILLS[color] ?? "transparent";
|
||
if (fill === "transparent") return "";
|
||
const row = Math.floor(cell / puzzle.size);
|
||
const column = cell % puzzle.size;
|
||
return `<rect class="cell-color" x="${origin.x + column * CELL_SIZE}" y="${origin.y + row * CELL_SIZE}" width="${CELL_SIZE}" height="${CELL_SIZE}" fill="${fill}"/>`;
|
||
}).join("");
|
||
|
||
const gridLines = Array.from({ length: puzzle.size - 1 }, (_, index) => {
|
||
const offset = (index + 1) * CELL_SIZE;
|
||
return `<path d="M${origin.x + offset} ${origin.y}V${origin.y + boardSize}M${origin.x} ${origin.y + offset}H${origin.x + boardSize}"/>`;
|
||
}).join("");
|
||
const constraints = puzzle.constraints
|
||
.map((constraint, index) =>
|
||
renderConstraint(constraint, index, puzzle, origin),
|
||
)
|
||
.join("");
|
||
const digits = Array.from({ length: cellCount }, (_, cell) => {
|
||
const value = puzzle.givens[cell] || values[cell] || 0;
|
||
const center = point(puzzle.size, cell, origin);
|
||
if (value !== 0) {
|
||
if (!Number.isInteger(value) || value < 1 || value > puzzle.size) {
|
||
throw new SudokuFormatError(
|
||
"INVALID_VISUAL_EXPORT",
|
||
`Cell ${String(cell + 1)} contains an out-of-range value.`,
|
||
);
|
||
}
|
||
return `<text class="cell-value ${puzzle.givens[cell] ? "given" : "progress"}" x="${number(center.x)}" y="${number(center.y)}">${escapeXml(symbolFor(value, puzzle.size))}</text>`;
|
||
}
|
||
const corner = cornerMarks[cell] ?? [];
|
||
const centerNotes = centerMarks[cell] ?? [];
|
||
const cornerText = corner
|
||
.slice(0, puzzle.size)
|
||
.map((digit, noteIndex) => {
|
||
const columns = Math.ceil(Math.sqrt(puzzle.size));
|
||
const x =
|
||
center.x -
|
||
CELL_SIZE * 0.39 +
|
||
((noteIndex % columns) * (CELL_SIZE * 0.78)) /
|
||
Math.max(1, columns - 1);
|
||
const y =
|
||
center.y -
|
||
CELL_SIZE * 0.34 +
|
||
Math.floor(noteIndex / columns) * CELL_SIZE * 0.19;
|
||
return `<text class="corner-note" x="${number(x)}" y="${number(y)}">${escapeXml(symbolFor(digit, puzzle.size))}</text>`;
|
||
})
|
||
.join("");
|
||
const centerText = centerNotes.length
|
||
? `<text class="center-note" x="${number(center.x)}" y="${number(center.y)}">${escapeXml(centerNotes.map((digit) => symbolFor(digit, puzzle.size)).join(""))}</text>`
|
||
: "";
|
||
return cornerText + centerText;
|
||
}).join("");
|
||
|
||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="title description">
|
||
<title id="title">${escapeXml(title)}</title>
|
||
<desc id="description">${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)}</desc>
|
||
<style>
|
||
.background{fill:#fff}.heading{fill:#172033;font-family:system-ui,sans-serif}.title{font-size:28px;font-weight:750}.byline,.global-rules{fill:#5d6678;font-size:14px}.grid-lines{fill:none;stroke:#a5acb8;stroke-width:1}.region-boundaries{fill:none;stroke:#172033;stroke-width:3;stroke-linecap:square}.constraint{fill:none;stroke:#596273;stroke-width:6;stroke-linecap:round;stroke-linejoin:round}.constraint text{dominant-baseline:central;text-anchor:middle;fill:#172033;stroke:none;font-family:system-ui,sans-serif;font-weight:700}.constraint.negated{stroke:#c63f52;stroke-dasharray:10 7}.constraint.negated text{fill:#a5283b}.diagonal{stroke:#4da3d6;stroke-width:3}.cage{stroke-width:2.2;stroke-dasharray:6 5}.cage-label{font-size:14px;text-anchor:start!important}.thermo{stroke:#c3c7ce;stroke-width:20}.thermo circle{fill:#c3c7ce;stroke:none}.thermo.negated{stroke-width:13}.false-marker{font-size:22px}.arrow{stroke:#929aa7;stroke-width:5}.arrow rect{fill:#fff;stroke:#929aa7}.arrow-tip{fill:#929aa7;stroke:none}.renban{stroke:#b55b8c;stroke-width:14;opacity:.72}.palindrome{stroke:#9ba1ad;stroke-width:13}.palindrome circle{fill:#d8dbe1;stroke:none}.maximum circle{fill:#eef0f4;stroke:#596273;stroke-width:2}.maximum path{stroke-width:3}.quadruple circle{fill:#fff;stroke:#596273;stroke-width:2}.quadruple text{font-size:15px}.outside rect{fill:#fff;stroke:#788191;stroke-width:1.5}.outside text{font-size:17px}.outside .outside-kinds{font-size:12px}.outside .outside-value{font-size:16px}.kropki circle{stroke:#172033;stroke-width:2}.kropki.white circle{fill:#fff}.kropki.black circle{fill:#172033}.pair-false-marker{fill:#c63f52!important;font-size:14px}.xv circle{fill:#fff;stroke:#fff}.xv text{font-size:18px}.inequality{stroke:#172033;stroke-width:4}.inequality-tip{fill:#172033;stroke:none}.cell-value,.corner-note,.center-note{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.cell-value{fill:#273c75;font-size:${number(CELL_SIZE * 0.58)}px}.cell-value.given{fill:#111827;font-weight:800}.cell-value.progress{font-weight:600}.corner-note{fill:#596273;font-size:${number(CELL_SIZE * 0.15)}px}.center-note{fill:#596273;font-size:${number(CELL_SIZE * 0.2)}px;letter-spacing:1px}
|
||
</style>
|
||
<rect class="background" width="100%" height="100%"/>
|
||
<g class="heading"><text class="title" x="${origin.x}" y="34">${escapeXml(title)}</text><text class="byline" x="${origin.x}" y="57">${escapeXml(byline)}</text>${globals.length > 0 ? `<text class="global-rules" x="${width - sideMargin}" y="57" text-anchor="end">${escapeXml(globals.join(" · "))}</text>` : ""}</g>
|
||
<g>${backgrounds}<g class="grid-lines">${gridLines}</g>${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}</g>
|
||
</svg>`;
|
||
if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) {
|
||
throw new SudokuFormatError(
|
||
"LIMIT_EXCEEDED",
|
||
"The rendered SVG is too large to export safely.",
|
||
);
|
||
}
|
||
return svg;
|
||
}
|
||
|
||
function svgDimensions(svg: string): { width: number; height: number } {
|
||
const match = /<svg[^>]+width="(\d+)"[^>]+height="(\d+)"/u.exec(svg);
|
||
if (match === null) {
|
||
throw new SudokuFormatError(
|
||
"INVALID_VISUAL_EXPORT",
|
||
"The generated SVG has no dimensions.",
|
||
);
|
||
}
|
||
return { width: Number(match[1]), height: Number(match[2]) };
|
||
}
|
||
|
||
async function loadSvgImage(svg: string): Promise<CanvasImageSource> {
|
||
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||
if (typeof createImageBitmap === "function") {
|
||
try {
|
||
return await createImageBitmap(blob);
|
||
} catch {
|
||
// Safari and some hardened browsers cannot decode SVG through ImageBitmap.
|
||
}
|
||
}
|
||
const url = URL.createObjectURL(blob);
|
||
try {
|
||
return await new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const image = new Image();
|
||
image.onload = () => resolve(image);
|
||
image.onerror = () =>
|
||
reject(new Error("The SVG image could not be decoded."));
|
||
image.src = url;
|
||
});
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
}
|
||
|
||
async function rasterizeSvg(
|
||
svg: string,
|
||
scale: number,
|
||
): Promise<HTMLCanvasElement> {
|
||
if (!Number.isFinite(scale) || scale < 0.5 || scale > 4) {
|
||
throw new RangeError("rasterScale must be from 0.5 to 4.");
|
||
}
|
||
const dimensions = svgDimensions(svg);
|
||
const boundedScale = Math.min(
|
||
scale,
|
||
MAX_VISUAL_EXPORT_DIMENSION / Math.max(dimensions.width, dimensions.height),
|
||
);
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = Math.max(1, Math.round(dimensions.width * boundedScale));
|
||
canvas.height = Math.max(1, Math.round(dimensions.height * boundedScale));
|
||
const context = canvas.getContext("2d");
|
||
if (context === null) {
|
||
throw new SudokuFormatError(
|
||
"UNSUPPORTED_BROWSER",
|
||
"This browser cannot create a canvas for visual export.",
|
||
);
|
||
}
|
||
context.fillStyle = "#ffffff";
|
||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||
const image = await loadSvgImage(svg);
|
||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||
if ("close" in image && typeof image.close === "function") image.close();
|
||
return canvas;
|
||
}
|
||
|
||
function canvasBlob(
|
||
canvas: HTMLCanvasElement,
|
||
type: "image/png" | "image/jpeg",
|
||
quality?: number,
|
||
): Promise<Blob> {
|
||
return new Promise((resolve, reject) => {
|
||
canvas.toBlob(
|
||
(blob) =>
|
||
blob === null
|
||
? reject(new Error(`The browser could not encode ${type}.`))
|
||
: resolve(blob),
|
||
type,
|
||
quality,
|
||
);
|
||
});
|
||
}
|
||
|
||
export async function renderPuzzlePng(
|
||
document: SudokuDocument,
|
||
options: VisualExportOptions = {},
|
||
): Promise<Blob> {
|
||
const canvas = await rasterizeSvg(
|
||
renderPuzzleSvg(document, options),
|
||
options.rasterScale ?? 2,
|
||
);
|
||
const blob = await canvasBlob(canvas, "image/png");
|
||
if (blob.size > MAX_VISUAL_EXPORT_BYTES) {
|
||
throw new SudokuFormatError(
|
||
"LIMIT_EXCEEDED",
|
||
"The rendered PNG is too large to export safely.",
|
||
);
|
||
}
|
||
return blob;
|
||
}
|
||
|
||
function ascii(value: string): Uint8Array {
|
||
return new TextEncoder().encode(value);
|
||
}
|
||
|
||
function concatenate(parts: readonly Uint8Array[]): Uint8Array {
|
||
const length = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
||
const output = new Uint8Array(length);
|
||
let offset = 0;
|
||
for (const part of parts) {
|
||
output.set(part, offset);
|
||
offset += part.byteLength;
|
||
}
|
||
return output;
|
||
}
|
||
|
||
/** Build a single-page PDF around browser-generated JPEG bytes. */
|
||
export function buildJpegPdf(
|
||
jpeg: Uint8Array,
|
||
imageWidth: number,
|
||
imageHeight: number,
|
||
): Uint8Array {
|
||
if (
|
||
jpeg.byteLength < 4 ||
|
||
jpeg.byteLength > MAX_VISUAL_EXPORT_BYTES ||
|
||
jpeg[0] !== 0xff ||
|
||
jpeg[1] !== 0xd8 ||
|
||
jpeg.at(-2) !== 0xff ||
|
||
jpeg.at(-1) !== 0xd9
|
||
) {
|
||
throw new SudokuFormatError(
|
||
"INVALID_VISUAL_EXPORT",
|
||
"The PDF renderer did not receive a bounded JPEG image.",
|
||
);
|
||
}
|
||
if (
|
||
!Number.isInteger(imageWidth) ||
|
||
!Number.isInteger(imageHeight) ||
|
||
imageWidth < 1 ||
|
||
imageHeight < 1 ||
|
||
imageWidth > MAX_VISUAL_EXPORT_DIMENSION ||
|
||
imageHeight > MAX_VISUAL_EXPORT_DIMENSION
|
||
) {
|
||
throw new SudokuFormatError(
|
||
"INVALID_VISUAL_EXPORT",
|
||
"The PDF image dimensions are invalid.",
|
||
);
|
||
}
|
||
const pageWidth = 595;
|
||
const pageHeight = 842;
|
||
const maximumWidth = pageWidth - 64;
|
||
const maximumHeight = pageHeight - 64;
|
||
const scale = Math.min(
|
||
maximumWidth / imageWidth,
|
||
maximumHeight / imageHeight,
|
||
);
|
||
const drawWidth = imageWidth * scale;
|
||
const drawHeight = imageHeight * scale;
|
||
const x = (pageWidth - drawWidth) / 2;
|
||
const y = (pageHeight - drawHeight) / 2;
|
||
const content = `q\n${number(drawWidth)} 0 0 ${number(drawHeight)} ${number(x)} ${number(y)} cm\n/Im0 Do\nQ\n`;
|
||
const objects: Uint8Array[] = [
|
||
ascii("<< /Type /Catalog /Pages 2 0 R >>"),
|
||
ascii("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"),
|
||
ascii(
|
||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>`,
|
||
),
|
||
concatenate([
|
||
ascii(
|
||
`<< /Type /XObject /Subtype /Image /Width ${String(imageWidth)} /Height ${String(imageHeight)} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${String(jpeg.byteLength)} >>\nstream\n`,
|
||
),
|
||
jpeg,
|
||
ascii("\nendstream"),
|
||
]),
|
||
ascii(
|
||
`<< /Length ${String(ascii(content).byteLength)} >>\nstream\n${content}endstream`,
|
||
),
|
||
];
|
||
const parts: Uint8Array[] = [
|
||
new Uint8Array([
|
||
...ascii("%PDF-1.4\n%"),
|
||
0xe2,
|
||
0xe3,
|
||
0xcf,
|
||
0xd3,
|
||
...ascii("\n"),
|
||
]),
|
||
];
|
||
const offsets = [0];
|
||
let byteOffset = parts[0]!.byteLength;
|
||
objects.forEach((object, index) => {
|
||
offsets.push(byteOffset);
|
||
const wrapped = concatenate([
|
||
ascii(`${String(index + 1)} 0 obj\n`),
|
||
object,
|
||
ascii("\nendobj\n"),
|
||
]);
|
||
parts.push(wrapped);
|
||
byteOffset += wrapped.byteLength;
|
||
});
|
||
const xrefOffset = byteOffset;
|
||
const xref = [
|
||
`xref\n0 ${String(objects.length + 1)}\n`,
|
||
"0000000000 65535 f \n",
|
||
...offsets
|
||
.slice(1)
|
||
.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`),
|
||
`trailer\n<< /Size ${String(objects.length + 1)} /Root 1 0 R >>\nstartxref\n${String(xrefOffset)}\n%%EOF\n`,
|
||
].join("");
|
||
parts.push(ascii(xref));
|
||
return concatenate(parts);
|
||
}
|
||
|
||
export async function renderPuzzlePdf(
|
||
document: SudokuDocument,
|
||
options: VisualExportOptions = {},
|
||
): Promise<Blob> {
|
||
const canvas = await rasterizeSvg(
|
||
renderPuzzleSvg(document, options),
|
||
options.rasterScale ?? 2,
|
||
);
|
||
const jpeg = new Uint8Array(
|
||
await (await canvasBlob(canvas, "image/jpeg", 0.94)).arrayBuffer(),
|
||
);
|
||
const pdf = buildJpegPdf(jpeg, canvas.width, canvas.height);
|
||
const buffer = new ArrayBuffer(pdf.byteLength);
|
||
new Uint8Array(buffer).set(pdf);
|
||
return new Blob([buffer], { type: "application/pdf" });
|
||
}
|