import { getImage } from "./draw.ts"; import { parseFont, stringifyFont } from "./fonthelper.ts"; export type Piece = | { type: "text"; text: string; isBold?: boolean; isItalic?: boolean } | { type: "space" } | { type: "break" } | { type: "hr" } | { type: "symbol"; symbol: "coin" | "debt" | "potion" | "vp" | "vp-token"; text: string; textColor: string; }; type PromiseOr = T | Promise; type PieceMeasure = { type: "content" | "space" | "break"; width: number; ascent: number; descent: number; }; type Line = { pieces: { piece: Piece; measure: PieceMeasure; xOffset: number; }[]; width: number; ascent: number; descent: number; }; type PieceTools = { measurePiece: ( context: CanvasRenderingContext2D, piece: Piece ) => PromiseOr; renderPiece: ( context: CanvasRenderingContext2D, piece: Piece, x: number, y: number ) => PromiseOr; }; type PieceDef = { type: T; measure( context: CanvasRenderingContext2D, piece: Piece & { type: T }, tools: PieceTools ): PromiseOr; render( context: CanvasRenderingContext2D, piece: Piece & { type: T }, x: number, y: number, measure: NoInfer, tools: PieceTools ): PromiseOr; }; const pieceDef = ( def: PieceDef ) => { return def; }; const textPiece = pieceDef({ type: "text", measure(context, piece) { context.save(); const fontInfo = parseFont(context.font); if (piece.isBold) { fontInfo.weight = "bold"; } if (piece.isItalic) { fontInfo.style = "italic"; } const font = stringifyFont(fontInfo); context.font = font; const metrics = context.measureText(piece.text); context.restore(); return { type: "content", width: metrics.width, ascent: metrics.fontBoundingBoxAscent, descent: metrics.fontBoundingBoxDescent, font, }; }, render(context, piece, x, y, measure) { context.save(); context.font = measure.font; context.fillText(piece.text, x, y); context.restore(); }, }); const spacePiece = pieceDef({ type: "space", measure(context, _piece) { const metrics = context.measureText(" "); return { type: "space", width: metrics.width, ascent: metrics.fontBoundingBoxAscent, descent: metrics.fontBoundingBoxDescent, }; }, render() {}, }); const breakPiece = pieceDef({ type: "break", measure(context, _piece) { const metrics = context.measureText(" "); return { type: "break", width: 0, ascent: metrics.fontBoundingBoxAscent / 3, descent: metrics.fontBoundingBoxDescent / 3, }; }, render() {}, }); const hrPiece = pieceDef({ type: "hr", measure(context, _piece) { const metrics = context.measureText(" "); return { type: "content", width: 750, ascent: metrics.fontBoundingBoxAscent / 3, descent: metrics.fontBoundingBoxDescent / 3, }; }, render(context, _piece, x, y, measure) { context.save(); context.beginPath(); context.moveTo(x, y); context.lineTo(x + measure.width, y); context.lineWidth = 8; context.stroke(); context.restore(); }, }); const symbolPiece = pieceDef({ type: "symbol", measure(context, piece) { context.save(); const metrics = context.measureText(" "); const height = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; const coinImage = getImage(piece.symbol); context.restore(); return { type: "content", width: coinImage.width * (height / coinImage.height), ascent: metrics.fontBoundingBoxAscent, descent: metrics.fontBoundingBoxDescent, }; }, render(context, piece, x, y, measure) { context.save(); // context.fillStyle = "yellow"; const height = measure.ascent + measure.descent; // context.fillRect(x, y - measure.ascent, measure.width, height); context.drawImage( getImage(piece.symbol), x, y - measure.ascent, measure.width, height ); const fontInfo = parseFont(context.font); fontInfo.family = ["DominionSpecial"]; fontInfo.weight = "bold"; fontInfo.size = parseInt(fontInfo.size.toString()) * 1.2; const font = stringifyFont(fontInfo); context.font = font; context.fillStyle = piece.textColor; context.textAlign = "center"; context.fillText(piece.text, x + measure.width / 2, y); context.restore(); }, }); const pieceDefs = [textPiece, spacePiece, breakPiece, symbolPiece, hrPiece]; const tools: PieceTools = {} as any; const measurePiece = (context: CanvasRenderingContext2D, piece: Piece) => { const def = pieceDefs.find((def) => def.type === piece.type)!; return def.measure(context, piece as any, tools); }; const renderPiece = ( context: CanvasRenderingContext2D, piece: Piece, x: number, y: number ) => { const def = pieceDefs.find((def) => def.type === piece.type)!; const measure = def.measure(context, piece as any, tools); return def.render(context, piece as any, x, y, measure as any, tools); }; tools.measurePiece = measurePiece; tools.renderPiece = renderPiece; type DominionFont = { font: "text" | "title"; size: number; isBold: boolean; isItalic: boolean; }; type PieceWithInfo = { piece: Piece; measure: PieceMeasure; }; export const measureDominionText = async ( context: CanvasRenderingContext2D, pieces: Piece[], maxWidth = Infinity ) => { const data: PieceWithInfo[] = await Promise.all( pieces.map(async (piece) => ({ piece, measure: await measurePiece(context, piece), })) ); const lines: Line[] = [{ pieces: [], width: 0, ascent: 0, descent: 0 }]; for (const pieceInfo of data) { const line = lines[lines.length - 1]!; if (pieceInfo.measure.type === "break") { line.pieces.push({ ...pieceInfo, xOffset: line.width }); line.width += pieceInfo.measure.width; line.ascent = Math.max(line.ascent, pieceInfo.measure.ascent); line.descent = Math.max(line.descent, pieceInfo.measure.descent); lines.push({ pieces: [], width: 0, ascent: 0, descent: 0 }); } else { if (line.width + pieceInfo.measure.width > maxWidth) { lines.push({ pieces: [{ ...pieceInfo, xOffset: 0 }], width: pieceInfo.measure.width, ascent: pieceInfo.measure.ascent, descent: pieceInfo.measure.descent, }); } else { line.pieces.push({ ...pieceInfo, xOffset: line.width, }); line.width += pieceInfo.measure.width; line.ascent = Math.max(line.ascent, pieceInfo.measure.ascent); line.descent = Math.max( line.descent, pieceInfo.measure.descent ); } } } return { lines: lines.map((line) => { while ( line.pieces[line.pieces.length - 1] && line.pieces[line.pieces.length - 1]!.measure.type === "space" ) { line.pieces = line.pieces.slice(0, -1); } line.width = line.pieces .map((piece) => piece.measure.width) .reduce((a, b) => a + b, 0); return line; }), width: Math.max(...lines.map((line) => line.width)), height: lines .map((line) => line.ascent + line.descent) .reduce((a, b) => a + b, 0), }; }; const debug = false; export const renderDominionText = async ( context: CanvasRenderingContext2D, pieces: Piece[], x: number, y: number, maxWidth = Infinity ) => { const { lines, height } = await measureDominionText( context, pieces, maxWidth ); let yOffset = 0; for (const line of lines) { yOffset += line.ascent; for (const { piece, xOffset } of line.pieces) { await renderPiece( context, piece, x - line.width / 2 + xOffset, y - height / 2 + yOffset ); if (debug) { context.save(); context.strokeStyle = "blue"; context.lineWidth = 5; const pieceMeasure = await measurePiece(context, piece); context.strokeRect( x - line.width / 2 + xOffset, y - height / 2 - line.ascent + yOffset, pieceMeasure.width, pieceMeasure.ascent + pieceMeasure.descent ); context.strokeStyle = "red"; context.beginPath(); context.moveTo( x - line.width / 2 + xOffset - 5, y - height / 2 + yOffset ); context.lineTo( x - line.width / 2 + xOffset + 5, y - height / 2 + yOffset ); context.stroke(); context.restore(); } } yOffset += line.descent; } }; export const parse = (text: string): Piece[] => { const pieces: Piece[] = []; const symbolMap = { "$": { symbol: "coin", textColor: "black" }, "@": { symbol: "debt", textColor: "white" }, "^": { symbol: "potion", textColor: "white" }, "%": { symbol: "vp", textColor: "white" }, "#": { symbol: "vp-token", textColor: "black" }, } as const; for (let i = 0; i < text.length; i++) { const char = text[i]; if (char === " ") { pieces.push({ type: "space" }); } else if (char === "\n") { pieces.push({ type: "break" }); } else if (char && char in symbolMap) { const c = char as keyof typeof symbolMap; const end = text.slice(i).match(new RegExp(`\\${c}\\w*`))![0] .length; pieces.push({ type: "symbol", ...symbolMap[c], text: text.slice(i + 1, i + end), }); i += end - 1; } else if (char === "+") { const match = text.slice(i).match(/\+\d* \w+/); if (match) { const end = match[0].length; pieces.push({ type: "text", isBold: true, text: text.slice(i, i + end), }); i += end - 1; } else { pieces.push({ type: "text", isBold: true, text: "+", }); } } else if ( char === "-" && text[i - 1] === "\n" && text[i + 1] === "\n" ) { pieces.push({ type: "hr" }); } else { const end = text.slice(i).match(/[^$ \n]+/)![0].length; pieces.push({ type: "text", text: text.slice(i, i + end) }); i += end - 1; } } return pieces; };