mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
}
|