// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15 // axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a // Canvas 2D — no dependencies. The module is the SHARED source of truth for the export // image: the render sidecar bundles it (renderer/src/entry.ts) and rasterizes on // skia-canvas, while this ui project keeps the pure parts unit-tested (the browser app // itself no longer imports it — delivery is the server's signed URL). // // buildScoresheet and layoutImage are pure (vitest-covered); drawGameImage is the thin // canvas pass exercised by the sidecar's node test. import type { GameView, MoveRecord, Variant } from './model'; import { replay } from './board'; import { historyGrid, type HistoryCell } from './history'; import { premiumGrid, centre, BOARD_SIZE } from './premiums'; import { valueForLetter } from './alphabet'; // The light-theme palette, mirrored from ui/src/app.css :root — the export is always light // regardless of the app theme, and a canvas cannot resolve CSS custom properties, so the // values are pinned here. Keep in sync with app.css. const C = { paper: '#ffffff', text: '#14181f', muted: '#6b7280', border: '#d8dce2', cell: '#e7ece8', cellDark: '#d9deda', // color-mix(in srgb, --cell-bg 94%, #000) tile: '#f4e2b8', tileEdge: '#d8c190', tileText: '#2a2113', premText: '#2a2113', prem: { TW: '#e06a5b', DW: '#efa6a0', TL: '#4f8fd6', DL: '#a8cdec' } as Record, }; const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif'; /** One scoresheet cell: a play (classic coordinate + words + points), a localized non-play * action, or an empty filler keeping the seat columns aligned. */ export type SheetCell = | { kind: 'play'; coord: string; words: string[]; score: number } | { kind: 'action'; label: string } | { kind: 'empty' }; /** Scoresheet is the pure content of the image's right column: one column per seat. */ export interface Scoresheet { /** Per seat: display name, final (adjusted) score, winner flag. */ header: { name: string; score: number; winner: boolean }[]; /** Row-major move grid, one column per seat (row k = each seat's k-th move). */ rows: SheetCell[][]; /** Per-seat endgame rack settlement: final Seat.score minus the seat's last running * total. The journal carries no adjustment move (engine applyEndAdjustment folds it * into the final score only), so the sheet shows it as a closing ± row. */ adjustments: number[]; hasAdjustments: boolean; /** Bottom line: " · ". */ footer: string; } /** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale * and time zone (regional conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23". * Both ride in from the client on a server render; an invalid zone falls back to the * process default rather than failing the whole image. */ export function formatFinishedDate(unixSec: number, dateLocale?: string, timeZone?: string): string { const opts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }; try { return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone }).format(new Date(unixSec * 1000)); } catch { return new Intl.DateTimeFormat(dateLocale, opts).format(new Date(unixSec * 1000)); } } /** * buildScoresheet lays the finished game's journal out as the export scoresheet. * actionLabel localizes a non-play move (the caller passes the same mapping the in-game * history uses); hostname/dateLocale feed the footer (tests pin them, production uses * location.hostname and the device locale). */ export function buildScoresheet( game: GameView, moves: MoveRecord[], actionLabel: (action: string) => string, opts?: { hostname?: string; dateLocale?: string; timeZone?: string }, ): Scoresheet { const seats = game.seats; const grid = historyGrid(moves, seats.length, null); const rows = grid.map((row) => row.map((c: HistoryCell): SheetCell => { if (c.kind === 'play') { return { kind: 'play', coord: c.coord ?? '', words: c.words ?? [], score: c.score ?? 0 }; } if (c.kind === 'action') return { kind: 'action', label: actionLabel(c.action ?? '') }; return { kind: 'empty' }; }), ); // The last running total each seat reached in the journal; the final Seat.score differs // from it exactly by the endgame rack settlement (resignations freeze scores — zero diff). const lastTotal = seats.map((s) => { for (let i = moves.length - 1; i >= 0; i--) { if (moves[i].player === s.seat) return moves[i].total; } return 0; }); const adjustments = seats.map((s, i) => s.score - lastTotal[i]); const host = opts?.hostname ?? (typeof location !== 'undefined' ? location.hostname : ''); return { header: seats.map((s) => ({ name: s.displayName, score: s.score, winner: s.isWinner })), rows, adjustments, hasAdjustments: adjustments.some((a) => a !== 0), footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale, opts?.timeZone)}`, }; } // Fixed scoresheet typography (@1x logical px). The column format never changes with game // length — the board stretches instead (layoutImage). const MARGIN = 22; const AXIS = 20; // top letters / left numbers gutter const GAP = 22; // board ↔ scoresheet const COL_W = 158; // one seat column const COL_GAP = 14; // between seat columns const HEAD_NAME_H = 20; const HEAD_SCORE_H = 24; const HEAD_RULE_H = 12; const ROW_H = 17; const SUB_ROW_H = 13; // extra-words line under a multi-word play const ADJ_ROW_H = 20; const FOOTER_H = 30; /** The board never shrinks below this side (@1x) even for a two-move game. */ export const MIN_SIDE = 620; /** ImageLayout is the pure geometry of the export image (@1x; the canvas scales it). */ export interface ImageLayout { w: number; h: number; board: { x: number; y: number; side: number; cell: number }; hist: { x: number; y: number; w: number; colW: number; colGap: number }; /** Per move-row height (a row with any multi-word play gets the extra sub-line). */ rowHeights: number[]; headNameH: number; headScoreH: number; headRuleH: number; rowH: number; subRowH: number; adjRowH: number; footerH: number; axis: number; margin: number; } /** * layoutImage computes the export geometry from the scoresheet: the fixed-typography * history column height decides the board side (never below MIN_SIDE), so the image * carries no dead space under the board and a long game simply grows both. */ export function layoutImage(sheet: Scoresheet): ImageLayout { const seats = sheet.header.length; const rowHeights = sheet.rows.map( (row) => ROW_H + (row.some((c) => c.kind === 'play' && c.words.length > 1) ? SUB_ROW_H : 0), ); const histContentH = HEAD_NAME_H + HEAD_SCORE_H + HEAD_RULE_H + rowHeights.reduce((a, b) => a + b, 0) + (sheet.hasAdjustments ? ADJ_ROW_H : 0); const side = Math.max(MIN_SIDE, Math.ceil(histContentH)); const histW = seats * COL_W + (seats - 1) * COL_GAP; return { w: MARGIN + AXIS + side + GAP + histW + MARGIN, h: MARGIN + AXIS + side + FOOTER_H + MARGIN, board: { x: MARGIN + AXIS, y: MARGIN + AXIS, side, cell: side / BOARD_SIZE }, hist: { x: MARGIN + AXIS + side + GAP, y: MARGIN + AXIS, w: histW, colW: COL_W, colGap: COL_GAP }, rowHeights, headNameH: HEAD_NAME_H, headScoreH: HEAD_SCORE_H, headRuleH: HEAD_RULE_H, rowH: ROW_H, subRowH: SUB_ROW_H, adjRowH: ADJ_ROW_H, footerH: FOOTER_H, axis: AXIS, margin: MARGIN, }; } /** RenderOptions carries the presentation the caller resolves from app state. */ export interface RenderOptions { /** Localizes a non-play move for the scoresheet (pass/exchange/resign/timeout). */ actionLabel: (action: string) => string; /** Footer host override (defaults to location.hostname) — for tests/harnesses. */ hostname?: string; /** Footer date locale override (defaults to the device locale) — for tests. */ dateLocale?: string; /** Footer IANA time zone (the device zone on a server render; defaults to local). */ timeZone?: string; /** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */ scale?: number; } /** CanvasLike is the minimal canvas surface drawGameImage needs — satisfied by both the * browser HTMLCanvasElement and the render sidecar's skia-canvas Canvas, so the drawing * code is shared verbatim between the two runtimes. */ export interface CanvasLike { width: number; height: number; getContext(contextId: '2d'): CanvasRenderingContext2D | null; } /** * drawGameImage sizes the canvas and draws the finished game onto it: final board with * classic axes on the left, the scoresheet on the right, the site + finish date at the * bottom. Light theme always; no last-move highlight (an archival artifact, not a live * frame). The caller owns the canvas and its PNG encoding (toBlob in the browser, * toBuffer in the sidecar). */ export function drawGameImage(canvas: CanvasLike, game: GameView, moves: MoveRecord[], opts: RenderOptions): void { const sheet = buildScoresheet(game, moves, opts.actionLabel, { hostname: opts.hostname, dateLocale: opts.dateLocale, timeZone: opts.timeZone, }); const layout = layoutImage(sheet); const scale = opts.scale ?? 2; canvas.width = Math.round(layout.w * scale); canvas.height = Math.round(layout.h * scale); const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('gameimage: no 2d context'); ctx.scale(scale, scale); ctx.fillStyle = C.paper; ctx.fillRect(0, 0, layout.w, layout.h); drawAxes(ctx, layout); drawBoard(ctx, layout, game.variant, moves); drawSheet(ctx, layout, sheet); ctx.fillStyle = C.muted; ctx.font = `11px ${FONT}`; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6); } function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void { const { x, y, cell } = l.board; ctx.fillStyle = C.muted; ctx.font = `${Math.max(10, cell * 0.28)}px ${FONT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (let c = 0; c < BOARD_SIZE; c++) { ctx.fillText(String.fromCharCode(65 + c), x + c * cell + cell / 2, y - l.axis / 2); } for (let r = 0; r < BOARD_SIZE; r++) { ctx.fillText(String(r + 1), x - l.axis / 2, y + r * cell + cell / 2); } } function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function drawBoard( ctx: CanvasRenderingContext2D, l: ImageLayout, variant: Variant, moves: MoveRecord[], ): void { const { x, y, cell, side } = l.board; const board = replay(moves); const prem = premiumGrid(variant); const ctr = centre(variant); // Squares first (premium colours + the alternating plain-cell tint, as in game). Bonus // squares carry NO text labels on the export — the fill alone reads universally, with the // coordinate axes carrying the location. for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { const p = prem[r][c]; ctx.fillStyle = p ? C.prem[p] : (r + c) % 2 === 1 && !board[r][c] ? C.cellDark : C.cell; ctx.fillRect(x + c * cell, y + r * cell, cell + 0.5, cell + 0.5); } } // The centre star on an uncovered centre square (rare in a finished game). if (!board[ctr.row][ctr.col]) { ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = C.premText; ctx.globalAlpha = 0.7; ctx.font = `${cell * 0.54}px ${FONT}`; ctx.fillText('★', x + ctr.col * cell + cell / 2, y + ctr.row * cell + cell / 2); ctx.globalAlpha = 1; } // Tiles: rounded face, inset bottom bevel and a soft right-side shadow, letter top-left, // value bottom-right — the in-game look (Board.svelte), light palette. const rad = cell * 0.1; for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { const t = board[r][c]; if (!t) continue; const tx = x + c * cell; const ty = y + r * cell; ctx.save(); ctx.shadowColor = 'rgba(0,0,0,0.4)'; ctx.shadowOffsetX = cell * 0.05; ctx.shadowBlur = cell * 0.07; ctx.fillStyle = C.tile; roundRect(ctx, tx, ty, cell, cell, rad); ctx.fill(); ctx.restore(); // Inset bottom bevel: a tile-edge strip clipped to the rounded face. ctx.save(); roundRect(ctx, tx, ty, cell, cell, rad); ctx.clip(); ctx.fillStyle = C.tileEdge; ctx.fillRect(tx, ty + cell - cell * 0.05, cell, cell * 0.05); ctx.restore(); ctx.fillStyle = C.tileText; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.font = `700 ${cell * 0.63}px ${FONT}`; ctx.fillText(t.letter, tx + cell * 0.08, ty + cell * 0.07); const isErudit = variant === 'erudit_ru'; ctx.textAlign = 'right'; ctx.textBaseline = 'alphabetic'; if (t.blank && isErudit) { // The Erudit blank marker (✻) in the value corner, as in game (usesStarBlank). ctx.font = `${cell * 0.32}px ${FONT}`; ctx.fillText('✻', tx + cell * 0.9, ty + cell * 0.9); } else if (!t.blank) { ctx.font = `600 ${cell * 0.28}px ${FONT}`; ctx.fillText(String(valueForLetter(variant, t.letter)), tx + cell * 0.9, ty + cell * 0.9); } } } // A hairline frame so the pale board edge reads against the paper. ctx.strokeStyle = C.border; ctx.lineWidth = 1; ctx.strokeRect(x - 0.5, y - 0.5, side + 1, side + 1); } /** fitText shrinks the font size until text fits maxW (floor 8.5px), returning the size. */ function fitText(ctx: CanvasRenderingContext2D, text: string, weight: string, size: number, maxW: number): number { let s = size; ctx.font = `${weight} ${s}px ${FONT}`; while (s > 8.5 && ctx.measureText(text).width > maxW) { s -= 0.5; ctx.font = `${weight} ${s}px ${FONT}`; } return s; } function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string { if (ctx.measureText(text).width <= maxW) return text; let t = text; while (t.length > 1 && ctx.measureText(t + '…').width > maxW) t = t.slice(0, -1); return t + '…'; } function drawSheet(ctx: CanvasRenderingContext2D, l: ImageLayout, sheet: Scoresheet): void { const { x, y, colW, colGap } = l.hist; const seats = sheet.header.length; const colX = (i: number) => x + i * (colW + colGap); // Header: per-seat name (ellipsized) over the final score; 🏆 marks the winner. for (let i = 0; i < seats; i++) { const h = sheet.header[i]; const cx = colX(i) + colW / 2; ctx.fillStyle = C.text; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `600 13px ${FONT}`; ctx.fillText(ellipsize(ctx, h.name, colW - 8), cx, y + l.headNameH / 2); ctx.font = `700 16px ${FONT}`; const score = h.winner ? `${h.score} 🏆` : String(h.score); ctx.fillText(score, cx, y + l.headNameH + l.headScoreH / 2); } // Rule under the header, spanning the sheet. const ruleY = y + l.headNameH + l.headScoreH + l.headRuleH / 2; ctx.strokeStyle = C.border; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, ruleY); ctx.lineTo(x + l.hist.w, ruleY); ctx.stroke(); // Move rows: " " left, points right; extra words on a small second line. let rowY = y + l.headNameH + l.headScoreH + l.headRuleH; for (let r = 0; r < sheet.rows.length; r++) { const row = sheet.rows[r]; const rh = l.rowHeights[r]; for (let i = 0; i < seats; i++) { const cell = row[i]; const bx = colX(i); const baseline = rowY + l.rowH / 2; if (cell.kind === 'play') { const scoreText = String(cell.score); ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.font = `600 11.5px ${FONT}`; ctx.fillStyle = C.text; ctx.fillText(scoreText, bx + colW, baseline); const scoreW = ctx.measureText(scoreText).width + 6; ctx.textAlign = 'left'; ctx.fillStyle = C.muted; ctx.font = `600 10.5px ${FONT}`; ctx.fillText(cell.coord, bx, baseline); const coordW = ctx.measureText('D15').width + 5; // fixed slot so words align const wordMax = colW - coordW - scoreW; const word = cell.words[0] ?? ''; ctx.fillStyle = C.text; const size = fitText(ctx, word, '500', 11.5, wordMax); ctx.font = `500 ${size}px ${FONT}`; ctx.fillText(ellipsize(ctx, word, wordMax), bx + coordW, baseline); if (cell.words.length > 1) { ctx.fillStyle = C.muted; ctx.font = `9.5px ${FONT}`; ctx.fillText( ellipsize(ctx, `+ ${cell.words.slice(1).join(', ')}`, colW - coordW), bx + coordW, rowY + l.rowH + l.subRowH / 2 - 1, ); } } else if (cell.kind === 'action') { ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = C.muted; ctx.font = `italic 10.5px ${FONT}`; ctx.fillText(ellipsize(ctx, cell.label, colW), bx, baseline); } } rowY += rh; } // Endgame rack settlement (when any seat has one): a closing ± row per column. if (sheet.hasAdjustments) { for (let i = 0; i < seats; i++) { const a = sheet.adjustments[i]; if (a === 0) continue; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = C.muted; ctx.font = `600 10.5px ${FONT}`; ctx.fillText(a > 0 ? `+${a}` : String(a), colX(i) + colW, rowY + l.adjRowH / 2); } } }