/** * S13 — the single canvas renderer shared by the preview and the PNG export: * `renderNameCard` draws the master base then the fitted name; `renderPhotoCard` * draws the visitor photo full-bleed then the master overlay (the overlay is * authoritative — never redraw bands or contra-forms). The context is a * structural subset of `CanvasRenderingContext2D` so unit tests drive a fake * recorder instead of jsdom canvas. */ import { COLINHA_DIGIT_FONT_FAMILY, COLINHA_ESTADUAL_LABEL, COLINHA_ROW_LAYOUT, fitColinhaRowName, } from './cardColinha' import { BRAND_FALLBACK_FONT, NAME_CARD_SLOT, TEAM_CARD_LABEL, TEAM_CARD_NAME_SLOT, type CardBanner, type CardModel, type CardNameBannerSlot, type CardNameSlot, type CardRect, } from './cardModels' import { fitCardName, resolveFontSizeForCapHeight, type CardMeasureText, type CardNameFit, type CardTextMetrics, } from './cardNameFit' import { cardPhotoDrawRect, clampCardPhotoTransform, type CardPhotoSize, type CardPhotoTransform, } from './cardPhotoTransform' import type { StateDeputyCatalogEntry } from './stateDeputyCatalog' export type CardDrawContext = { font: string letterSpacing: string globalAlpha: number fillStyle: string | CanvasGradient | CanvasPattern textAlign: CanvasTextAlign textBaseline: CanvasTextBaseline imageSmoothingEnabled: boolean imageSmoothingQuality: ImageSmoothingQuality drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void fillRect(x: number, y: number, width: number, height: number): void fillText(text: string, x: number, y: number): void measureText(text: string): CardTextMetrics save(): void restore(): void translate(x: number, y: number): void rotate(angle: number): void beginPath(): void arc(x: number, y: number, radius: number, startAngle: number, endAngle: number): void ellipse( x: number, y: number, radiusX: number, radiusY: number, rotation: number, startAngle: number, endAngle: number, ): void rect(x: number, y: number, width: number, height: number): void roundRect(x: number, y: number, width: number, height: number, radii?: number | number[]): void clip(): void createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient fill(): void } export const createCardMeasure = ( ctx: Pick, fontFamily: string, weight = 700, ): CardMeasureText => { return (text, fontSize) => { ctx.font = `${weight} ${fontSize}px ${fontFamily}, ${BRAND_FALLBACK_FONT}` const metrics = ctx.measureText(text) return { width: metrics.width, actualBoundingBoxAscent: metrics.actualBoundingBoxAscent ?? 0, actualBoundingBoxDescent: metrics.actualBoundingBoxDescent ?? 0, } } } export type NameCardRenderArgs = { image: CanvasImageSource name: string fontFamily: string measure: CardMeasureText slot?: CardNameSlot } export type CardNameDrawArgs = { fit: Extract fontFamily: string slot?: CardNameSlot } type CardBannerDrawArgs = { banner: CardBanner /** S16 — the drawn width: the model width, or the resolved dynamic one. */ width: number text: string fill: string fontSize: number capHeight: number fontFamily: string } /** * S16 — resolves the name banner width from the measured ink: the model's * reference width while the ink fits `width - padding` (= 470), then growing * with the ink so the model's side padding (`maxWidth - maxInkWidth` = 39) is * preserved, never past the ceiling. Assumes `maxWidth >= width` (fixed * banners use equal values and resolve to themselves). */ export const resolveCardBannerWidth = ( slot: Pick, inkWidth: number, ): number => { const { banner } = slot const padding = banner.maxWidth - slot.maxInkWidth return Math.max(banner.width, Math.min(banner.maxWidth, inkWidth + padding)) } /** * S15 — draws one official top banner: the rotated rectangle plus its centered * cap-height text. Both team banners (the fixed `TIME DE` label and the fitted * name) share this so the measured geometry lives once. */ const drawCardBanner = (ctx: CardDrawContext, args: CardBannerDrawArgs): void => { const { banner } = args const width = args.width ctx.save() ctx.translate(banner.centerX, banner.centerY) ctx.rotate((banner.rotationDeg * Math.PI) / 180) ctx.fillStyle = banner.background ctx.fillRect(-width / 2, -banner.height / 2, width, banner.height) ctx.font = `700 ${args.fontSize}px ${args.fontFamily}` ctx.fillStyle = args.fill ctx.textAlign = 'center' ctx.textBaseline = 'alphabetic' ctx.fillText(args.text, 0, args.capHeight / 2) ctx.restore() } /** * S14/S15 — draws only the fitted name (no base image) so the home tile preview * reuses the exact composer geometry. `fit.ok` is the caller's contract: a * failed fit never draws (names are never cut silently). Left slots draw on the * master's border; the team slot draws the blue banner + centered name. */ export const drawCardName = (ctx: CardDrawContext, args: CardNameDrawArgs): void => { const slot = args.slot ?? NAME_CARD_SLOT const { fit } = args if (slot.align === 'center') { drawCardBanner(ctx, { banner: slot.banner, width: resolveCardBannerWidth(slot, fit.inkWidth), text: fit.lines[0], fill: slot.fill, fontSize: fit.fontSize, capHeight: fit.capHeight, fontFamily: args.fontFamily, }) return } ctx.font = `700 ${fit.fontSize}px ${args.fontFamily}` ctx.fillStyle = slot.fill ctx.textAlign = 'left' ctx.textBaseline = 'alphabetic' if (fit.lines.length === 1) { ctx.fillText(fit.lines[0], slot.leftX, slot.capTop + fit.capHeight) return } const availableHeight = slot.maxBlockBottom - slot.capTop const blockHeight = fit.lineHeight + fit.capHeight const blockTop = slot.capTop + Math.max(0, (availableHeight - blockHeight) / 2) fit.lines.forEach((line, index) => { ctx.fillText(line, slot.leftX, blockTop + fit.capHeight + index * fit.lineHeight) }) } export const renderNameCard = ( ctx: CardDrawContext, model: CardModel, args: NameCardRenderArgs, ): CardNameFit => { const slot = args.slot ?? NAME_CARD_SLOT ctx.drawImage(args.image, 0, 0, model.width, model.height) const fit = fitCardName(args.name, args.measure, slot) if (!fit.ok) return fit drawCardName(ctx, { fit, fontFamily: args.fontFamily, slot }) return fit } export type PhotoCardRenderArgs = { photo: CanvasImageSource frame: CanvasImageSource photoSize: CardPhotoSize frameSize: CardPhotoSize window: CardRect transform: CardPhotoTransform } export const renderPhotoCard = ( ctx: CardDrawContext, args: PhotoCardRenderArgs, ): CardPhotoTransform => { const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window) ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height) ctx.drawImage(args.frame, 0, 0, args.frameSize.width, args.frameSize.height) return clampCardPhotoTransform(args.transform, args.photoSize, args.window) } /** S30 — the foreground subject of a team composition. */ type TeamCardSubject = { photo: CanvasImageSource photoSize: CardPhotoSize transform: CardPhotoTransform } export type TeamCardRenderArgs = { base: CanvasImageSource overlay: CanvasImageSource /** `null` leaves the window empty: the chosen art shows exactly as delivered. */ subject: TeamCardSubject | null window: CardRect name: string fontFamily: string measure: CardMeasureText slot?: CardNameBannerSlot } export type TeamCardRenderResult = { fit: CardNameFit /** * The normalized photo transform (zoom always in range; S33 — the offsets * follow the model's `photoPosition`, so a `free` model keeps them as sent); * `null` when there is no photo subject. */ transform: CardPhotoTransform | null } /** * S15/S30 — the team card composition, in the measured order: master base → * foreground subject (the framed cutout photo; `null` skips it and the window * stays empty) → master front overlay → `TIME DE` banner → blue name banner * (only when the name fits; never cut silently). The two banners are drawn by * the renderer because neither master carries them. */ export const renderTeamCard = ( ctx: CardDrawContext, model: CardModel, args: TeamCardRenderArgs, ): TeamCardRenderResult => { ctx.drawImage(args.base, 0, 0, model.width, model.height) let transform: CardPhotoTransform | null = null if (args.subject) { // S33 — the drawing and the returned transform follow the same policy read // from the model: a `free` team model never re-clamps the visitor position // (that would snap the photo back on the next paint). const options = { position: model.photoPosition ?? 'bounded' } const rect = cardPhotoDrawRect( args.subject.transform, args.subject.photoSize, args.window, options, ) ctx.drawImage(args.subject.photo, rect.x, rect.y, rect.width, rect.height) transform = clampCardPhotoTransform( args.subject.transform, args.subject.photoSize, args.window, options, ) } ctx.drawImage(args.overlay, 0, 0, model.width, model.height) drawCardBanner(ctx, { banner: TEAM_CARD_LABEL, width: TEAM_CARD_LABEL.width, text: TEAM_CARD_LABEL.text, fill: TEAM_CARD_LABEL.fill, fontSize: resolveFontSizeForCapHeight(args.measure, TEAM_CARD_LABEL.capHeight), capHeight: TEAM_CARD_LABEL.capHeight, fontFamily: args.fontFamily, }) const slot = args.slot ?? TEAM_CARD_NAME_SLOT const fit = fitCardName(args.name, args.measure, slot) if (fit.ok) drawCardName(ctx, { fit, fontFamily: args.fontFamily, slot }) return { fit, transform } } export type ColinhaCardRenderArgs = { /** The approved art (`/cards/modelo-colinha.jpeg`), drawn whole at 1.2×. */ image: CanvasImageSource /** The chosen state deputy; `null` leaves the art exactly as delivered. */ deputy: StateDeputyCatalogEntry | null fontFamily: string /** The 900-weight measure the estadual name is fitted and placed with. */ measure: CardMeasureText } /** * S34 — the `Minha colinha` slip: the approved art drawn whole (the 900×1600 * JPEG scaled 1.2× to the 1080×1920 output) and, only when a state deputy is * picked, the 2nd line: the burned label wiped by the white mask, the label * redrawn at the left, the name at the right (right-aligned pair) and one digit * per printed box. Without a pick not a single pixel is added — the art is the * empty state. */ export const renderColinhaCard = ( ctx: CardDrawContext, model: CardModel, args: ColinhaCardRenderArgs, ): void => { ctx.imageSmoothingEnabled = true ctx.imageSmoothingQuality = 'high' ctx.drawImage(args.image, 0, 0, model.width, model.height) const { deputy } = args if (!deputy) return const { mask, copy, office, name, gap, digit } = COLINHA_ROW_LAYOUT const nameText = deputy.name.toLocaleUpperCase('pt-BR') // The label keeps its own face/weight; the pair (label + gap + name) is // right-aligned on `copy.right` and may use the whole white mask band, so the // name only shrinks when it would leave the mask — the gate's own CSS does // the same (its `nowrap` pair overflows the declared copy width to the left). ctx.font = `${office.weight} ${office.fontSize}px ${args.fontFamily}` const officeWidth = ctx.measureText(COLINHA_ESTADUAL_LABEL).width const nameFit = fitColinhaRowName(nameText, args.measure, copy.right - mask.x - officeWidth - gap) const baseline = copy.top + args.measure('X', nameFit.fontSize).actualBoundingBoxAscent // The label sits just left of the drawn name: measure with the same tracking. ctx.letterSpacing = `${name.letterSpacingEm * nameFit.fontSize}px` const nameWidth = args.measure(nameFit.text, nameFit.fontSize).width ctx.letterSpacing = '0px' ctx.fillStyle = mask.fill ctx.fillRect(mask.x, mask.y, mask.width, mask.height) ctx.fillStyle = office.color ctx.font = `${office.weight} ${office.fontSize}px ${args.fontFamily}` ctx.textAlign = 'right' ctx.textBaseline = 'alphabetic' ctx.letterSpacing = '0px' ctx.fillText(COLINHA_ESTADUAL_LABEL, copy.right - nameWidth - gap, baseline) ctx.fillStyle = name.color ctx.font = `${name.weight} ${nameFit.fontSize}px ${args.fontFamily}` ctx.letterSpacing = `${name.letterSpacingEm * nameFit.fontSize}px` ctx.fillText(nameFit.text, copy.right, baseline) ctx.letterSpacing = '0px' ctx.fillStyle = digit.color ctx.font = `${digit.weight} ${digit.fontSize}px ${COLINHA_DIGIT_FONT_FAMILY}` ctx.textAlign = 'center' ctx.textBaseline = 'middle' const centerY = digit.top + digit.height / 2 const digits = [...deputy.ballotNumber] digits.forEach((glyph, index) => { ctx.fillText(glyph, digit.left + digit.width / 2 + index * digit.step, centerY) }) }