teqo-backup/src/lib/cardRender.ts

390 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<CardDrawContext, 'font' | 'measureText'>,
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<CardNameFit, { ok: true }>
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<CardNameBannerSlot, 'banner' | 'maxInkWidth'>,
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)
})
}