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, 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 ``; } if (constraint.type === "killer-cage") { const first = Math.min(...constraint.cells); const label = point(size, first, origin); return `${marker}${String(constraint.sum)}`; } if (constraint.type === "thermo") { const bulb = point(size, constraint.cells[0]!, origin); return `${negated ? `` : ""}`; } 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 `${negated ? `` : ""}`; } if (constraint.type === "renban" || constraint.type === "palindrome") { return `${ constraint.type === "palindrome" ? constraint.cells .map((cell) => { const center = point(size, cell, origin); return ``; }) .join("") : "" }`; } if (constraint.type === "maximum") { const center = point(size, constraint.cell, origin); const offset = CELL_SIZE * 0.28; const inner = CELL_SIZE * 0.14; return ``; } 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 `${marker}${escapeXml(constraint.digits.map((digit) => symbolFor(digit, size)).join(""))}`; } 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 `Σ · ▥${marker}${String(value)}`; } return `${constraint.type === "x-sum" ? "Σ" : "▥"}${marker}${String(value)}`; } 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 `${negated ? `×` : ""}`; } if (constraint.type === "xv") { return `${marker}${String(constraint.total)}`; } 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 ``; } function renderRegionBoundaries(puzzle: NormalizedPuzzle, origin: BoardOrigin) { const { size, regions } = puzzle; const lines: string[] = []; const board = size * CELL_SIZE; lines.push( ``, ); 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( ``, ); } 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( ``, ); } } } return `${lines.join("")}`; } 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 ``; }).join(""); const gridLines = Array.from({ length: puzzle.size - 1 }, (_, index) => { const offset = (index + 1) * CELL_SIZE; return ``; }).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 `${escapeXml(symbolFor(value, puzzle.size))}`; } 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 `${escapeXml(symbolFor(digit, puzzle.size))}`; }) .join(""); const centerText = centerNotes.length ? `${escapeXml(centerNotes.map((digit) => symbolFor(digit, puzzle.size)).join(""))}` : ""; return cornerText + centerText; }).join(""); const svg = ` ${escapeXml(title)} ${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)} ${escapeXml(title)}${globals.length > 0 ? `${escapeXml(globals.join(" · "))}` : ""} ${backgrounds}${gridLines}${constraints}${renderRegionBoundaries(puzzle, origin)}${digits} `; 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 = /]+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 { 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((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 { 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 { 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 { 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 { 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" }); }