/*
 * O banner como DOCUMENTO de peças soltas (texto, forma, imagem), cada uma com
 * posição, tamanho, giro e estilo próprios: é o que o editor de banners mexe e
 * o que o servidor transforma em imagem.
 *
 * O desenho de cada peça sai daqui, num lugar só (`caixaDaPeca` e
 * `conteudoDaPeca`): o editor mostra exatamente o HTML que o Chrome do
 * servidor fotografa. Sem "@/" e sem banco, para o teste rodar no node puro.
 *
 * Tudo se pinta igual (cor única, degradê linear ou radial), tudo tem borda e
 * sombra próprias. Formas que não são retângulo nem círculo são SVG: assim a
 * borda e o degradê funcionam em qualquer uma (etiqueta, faixa, estrela...).
 *
 * Segurança: todo texto passa por `escapar`, cor só em #RRGGBB, fonte só da
 * lista, e imagem no HTML final só como data: (o servidor baixa e embute).
 */
import QRCode from "qrcode";
import { chromaLimpo, filtroDoChroma, type ChromaDaFoto } from "./chroma";
import { cssDosRecursos, recursosLimpos, type RecursosDaLetra } from "./glifos";
import { enderecoDoVetor, vetorLimpo, type VetorDaPeca } from "./vetores";
import { reticulaEmPixels, tratamentoLimpo, type TratamentoDaFoto } from "./tratamento-da-foto";
import { perspectivaLimpa, type PerspectivaDoGrupo } from "./perspectiva-do-grupo";
import { INSTANTE_DA_FOTO, ehParticula, particulasNoTempo, svgDasParticulas } from "./particulas";
import { caminhoDasCurvas, comAlcas, limitesDaCurva } from "./curvas";
import { animacaoDaPaginaLimpa, animacaoLimpa, transicaoLimpa, type AnimacaoDaPagina, type AnimacaoDaPeca, type TransicaoDaPagina } from "./animacao";

/* ================================================================ fontes */

/* `italico`: a família tem o desenho itálico de verdade (senão o navegador só inclina a letra). */
/* `propria`: fonte enviada pela loja (kit da marca), servida do depósito, não do Google. */
/* Os grupos do painel de fontes. */
export const TIPOS_DE_FONTE = [
  ["impacto", "Impacto (promoção)"],
  ["moderna", "Modernas"],
  ["redonda", "Arredondadas"],
  ["elegante", "Elegantes e de luxo"],
  ["serifa", "Serifas de revista e texto"],
  ["escrita", "Escritas à mão e caligrafia"],
  ["retro", "Retrô e divertidas"],
  ["tecnica", "Técnicas, máquina e pixel"],
] as const;
export type TipoDeFonte = (typeof TIPOS_DE_FONTE)[number][0];
export type Fonte = { chave: string; familia: string; pesos: number[]; tipo: TipoDeFonte; italico?: boolean; propria?: boolean; nome?: string };

/* As de sempre (as dos modelos e elementos prontos): carregam junto com o editor. */
const FONTES_DE_BASE: Fonte[] = [
  { chave: "anton", familia: "Anton", pesos: [400], tipo: "impacto" },
  { chave: "bebas-neue", familia: "Bebas Neue", pesos: [400], tipo: "impacto" },
  { chave: "archivo-black", familia: "Archivo Black", pesos: [400], tipo: "impacto" },
  { chave: "bangers", familia: "Bangers", pesos: [400], tipo: "impacto" },
  { chave: "oswald", familia: "Oswald", pesos: [500, 700], tipo: "impacto" },
  { chave: "barlow-condensed", familia: "Barlow Condensed", pesos: [600, 800], tipo: "impacto", italico: true },
  { chave: "montserrat", familia: "Montserrat", pesos: [500, 700, 900], tipo: "moderna", italico: true },
  { chave: "poppins", familia: "Poppins", pesos: [400, 600, 800], tipo: "moderna", italico: true },
  { chave: "inter", familia: "Inter", pesos: [400, 600, 800], tipo: "moderna", italico: true },
  { chave: "lilita-one", familia: "Lilita One", pesos: [400], tipo: "redonda" },
  { chave: "baloo-2", familia: "Baloo 2", pesos: [600, 800], tipo: "redonda" },
  { chave: "righteous", familia: "Righteous", pesos: [400], tipo: "redonda" },
  { chave: "playfair-display", familia: "Playfair Display", pesos: [700, 900], tipo: "elegante", italico: true },
  { chave: "pacifico", familia: "Pacifico", pesos: [400], tipo: "escrita" },
  { chave: "caveat", familia: "Caveat", pesos: [700], tipo: "escrita" },
  /* out/2026: mais famílias, para o editor de artes livre. */
  { chave: "russo-one", familia: "Russo One", pesos: [400], tipo: "impacto" },
  { chave: "bungee", familia: "Bungee", pesos: [400], tipo: "impacto" },
  { chave: "kanit", familia: "Kanit", pesos: [600, 800, 900], tipo: "impacto", italico: true },
  { chave: "teko", familia: "Teko", pesos: [500, 700], tipo: "impacto" },
  { chave: "unbounded", familia: "Unbounded", pesos: [600, 800], tipo: "impacto" },
  { chave: "monoton", familia: "Monoton", pesos: [400], tipo: "impacto" },
  { chave: "raleway", familia: "Raleway", pesos: [500, 700, 900], tipo: "moderna", italico: true },
  { chave: "outfit", familia: "Outfit", pesos: [400, 600, 800], tipo: "moderna" },
  { chave: "sora", familia: "Sora", pesos: [400, 700, 800], tipo: "moderna" },
  { chave: "space-grotesk", familia: "Space Grotesk", pesos: [500, 700], tipo: "moderna" },
  { chave: "syne", familia: "Syne", pesos: [700, 800], tipo: "moderna" },
  { chave: "dm-sans", familia: "DM Sans", pesos: [400, 700, 900], tipo: "moderna", italico: true },
  { chave: "rubik", familia: "Rubik", pesos: [500, 700, 900], tipo: "redonda", italico: true },
  { chave: "fredoka", familia: "Fredoka", pesos: [500, 700], tipo: "redonda" },
  { chave: "abril-fatface", familia: "Abril Fatface", pesos: [400], tipo: "elegante" },
  { chave: "dm-serif-display", familia: "DM Serif Display", pesos: [400], tipo: "elegante" },
  { chave: "cormorant-garamond", familia: "Cormorant Garamond", pesos: [500, 700], tipo: "elegante", italico: true },
  { chave: "cinzel", familia: "Cinzel", pesos: [600, 800], tipo: "elegante" },
  { chave: "lobster", familia: "Lobster", pesos: [400], tipo: "escrita" },
  { chave: "dancing-script", familia: "Dancing Script", pesos: [600, 700], tipo: "escrita" },
  { chave: "great-vibes", familia: "Great Vibes", pesos: [400], tipo: "escrita" },
  /* out/2026: as famílias das artes de post (coleção de modelos de Instagram). */
  { chave: "league-gothic", familia: "League Gothic", pesos: [400], tipo: "impacto" },
  { chave: "bricolage-grotesque", familia: "Bricolage Grotesque", pesos: [500, 700, 800], tipo: "moderna" },
  { chave: "plus-jakarta-sans", familia: "Plus Jakarta Sans", pesos: [500, 700, 800], tipo: "moderna", italico: true },
  { chave: "manrope", familia: "Manrope", pesos: [500, 700, 800], tipo: "moderna" },
  { chave: "libre-caslon-display", familia: "Libre Caslon Display", pesos: [400], tipo: "elegante" },
  { chave: "fraunces", familia: "Fraunces", pesos: [400, 700, 900], tipo: "elegante", italico: true },
  { chave: "smokum", familia: "Smokum", pesos: [400], tipo: "impacto" },
  { chave: "allura", familia: "Allura", pesos: [400], tipo: "escrita" },
  { chave: "permanent-marker", familia: "Permanent Marker", pesos: [400], tipo: "escrita" },
];

/*
 * 27/10/2026: a biblioteca grande (149 famílias do Google Fonts, todas de
 * licença aberta), de todos os estilos. Conferidas no Google com estes pesos
 * por .cache/banner/fontes-novas.mjs. Só carregam quando o painel de fontes
 * abre ou quando a arte usa (garantirFontes).
 */
const FONTES_A_MAIS: Fonte[] = [
  { chave: "staatliches", familia: "Staatliches", pesos: [400], tipo: "impacto" },
  { chave: "big-shoulders-display", familia: "Big Shoulders Display", pesos: [700, 900], tipo: "impacto" },
  { chave: "passion-one", familia: "Passion One", pesos: [400, 700, 900], tipo: "impacto" },
  { chave: "bowlby-one", familia: "Bowlby One", pesos: [400], tipo: "impacto" },
  { chave: "dela-gothic-one", familia: "Dela Gothic One", pesos: [400], tipo: "impacto" },
  { chave: "antonio", familia: "Antonio", pesos: [600, 700], tipo: "impacto" },
  { chave: "saira-condensed", familia: "Saira Condensed", pesos: [700, 900], tipo: "impacto" },
  { chave: "fjalla-one", familia: "Fjalla One", pesos: [400], tipo: "impacto" },
  { chave: "six-caps", familia: "Six Caps", pesos: [400], tipo: "impacto" },
  { chave: "squada-one", familia: "Squada One", pesos: [400], tipo: "impacto" },
  { chave: "tilt-warp", familia: "Tilt Warp", pesos: [400], tipo: "impacto" },
  { chave: "paytone-one", familia: "Paytone One", pesos: [400], tipo: "impacto" },
  { chave: "rubik-mono-one", familia: "Rubik Mono One", pesos: [400], tipo: "impacto" },
  { chave: "alumni-sans", familia: "Alumni Sans", pesos: [700, 900], tipo: "impacto" },
  { chave: "figtree", familia: "Figtree", pesos: [400, 600, 800], tipo: "moderna" },
  { chave: "lexend", familia: "Lexend", pesos: [400, 600, 800], tipo: "moderna" },
  { chave: "urbanist", familia: "Urbanist", pesos: [400, 600, 800], tipo: "moderna", italico: true },
  { chave: "epilogue", familia: "Epilogue", pesos: [400, 700, 900], tipo: "moderna", italico: true },
  { chave: "archivo", familia: "Archivo", pesos: [400, 700, 900], tipo: "moderna", italico: true },
  { chave: "work-sans", familia: "Work Sans", pesos: [400, 600, 800], tipo: "moderna", italico: true },
  { chave: "red-hat-display", familia: "Red Hat Display", pesos: [500, 700, 900], tipo: "moderna", italico: true },
  { chave: "onest", familia: "Onest", pesos: [400, 600, 800], tipo: "moderna" },
  { chave: "hanken-grotesk", familia: "Hanken Grotesk", pesos: [400, 600, 800], tipo: "moderna", italico: true },
  { chave: "schibsted-grotesk", familia: "Schibsted Grotesk", pesos: [400, 700, 900], tipo: "moderna", italico: true },
  { chave: "instrument-sans", familia: "Instrument Sans", pesos: [400, 600, 700], tipo: "moderna", italico: true },
  { chave: "albert-sans", familia: "Albert Sans", pesos: [400, 700, 900], tipo: "moderna", italico: true },
  { chave: "barlow", familia: "Barlow", pesos: [400, 600, 800], tipo: "moderna", italico: true },
  { chave: "josefin-sans", familia: "Josefin Sans", pesos: [400, 600, 700], tipo: "moderna", italico: true },
  { chave: "kumbh-sans", familia: "Kumbh Sans", pesos: [400, 700, 900], tipo: "moderna" },
  { chave: "mulish", familia: "Mulish", pesos: [400, 700, 900], tipo: "moderna", italico: true },
  { chave: "jost", familia: "Jost", pesos: [400, 600, 800], tipo: "moderna", italico: true },
  { chave: "wix-madefor-display", familia: "Wix Madefor Display", pesos: [400, 600, 800], tipo: "moderna" },
  { chave: "gabarito", familia: "Gabarito", pesos: [400, 700, 900], tipo: "moderna" },
  { chave: "familjen-grotesk", familia: "Familjen Grotesk", pesos: [400, 700], tipo: "moderna", italico: true },
  { chave: "inter-tight", familia: "Inter Tight", pesos: [400, 700, 900], tipo: "moderna", italico: true },
  { chave: "golos-text", familia: "Golos Text", pesos: [400, 600, 800], tipo: "moderna" },
  { chave: "tenor-sans", familia: "Tenor Sans", pesos: [400], tipo: "moderna" },
  { chave: "chivo", familia: "Chivo", pesos: [400, 700, 900], tipo: "moderna", italico: true },
  { chave: "nunito", familia: "Nunito", pesos: [400, 700, 900], tipo: "redonda", italico: true },
  { chave: "quicksand", familia: "Quicksand", pesos: [500, 700], tipo: "redonda" },
  { chave: "comfortaa", familia: "Comfortaa", pesos: [500, 700], tipo: "redonda" },
  { chave: "varela-round", familia: "Varela Round", pesos: [400], tipo: "redonda" },
  { chave: "m-plus-rounded-1c", familia: "M PLUS Rounded 1c", pesos: [400, 700, 800], tipo: "redonda" },
  { chave: "titan-one", familia: "Titan One", pesos: [400], tipo: "redonda" },
  { chave: "chewy", familia: "Chewy", pesos: [400], tipo: "redonda" },
  { chave: "grandstander", familia: "Grandstander", pesos: [600, 800], tipo: "redonda" },
  { chave: "mochiy-pop-one", familia: "Mochiy Pop One", pesos: [400], tipo: "redonda" },
  { chave: "sniglet", familia: "Sniglet", pesos: [400, 800], tipo: "redonda" },
  { chave: "gluten", familia: "Gluten", pesos: [500, 700], tipo: "redonda" },
  { chave: "bodoni-moda", familia: "Bodoni Moda", pesos: [500, 700, 900], tipo: "elegante", italico: true },
  { chave: "gloock", familia: "Gloock", pesos: [400], tipo: "elegante" },
  { chave: "instrument-serif", familia: "Instrument Serif", pesos: [400], tipo: "elegante", italico: true },
  { chave: "prata", familia: "Prata", pesos: [400], tipo: "elegante" },
  { chave: "marcellus", familia: "Marcellus", pesos: [400], tipo: "elegante" },
  { chave: "italiana", familia: "Italiana", pesos: [400], tipo: "elegante" },
  { chave: "bellefair", familia: "Bellefair", pesos: [400], tipo: "elegante" },
  { chave: "yeseva-one", familia: "Yeseva One", pesos: [400], tipo: "elegante" },
  { chave: "rozha-one", familia: "Rozha One", pesos: [400], tipo: "elegante" },
  { chave: "young-serif", familia: "Young Serif", pesos: [400], tipo: "elegante" },
  { chave: "gilda-display", familia: "Gilda Display", pesos: [400], tipo: "elegante" },
  { chave: "forum", familia: "Forum", pesos: [400], tipo: "elegante" },
  { chave: "cinzel-decorative", familia: "Cinzel Decorative", pesos: [700, 900], tipo: "elegante" },
  { chave: "noto-serif-display", familia: "Noto Serif Display", pesos: [500, 700, 900], tipo: "elegante", italico: true },
  { chave: "ibarra-real-nova", familia: "Ibarra Real Nova", pesos: [400, 600, 700], tipo: "elegante", italico: true },
  { chave: "lora", familia: "Lora", pesos: [400, 600, 700], tipo: "serifa", italico: true },
  { chave: "merriweather", familia: "Merriweather", pesos: [400, 700, 900], tipo: "serifa", italico: true },
  { chave: "eb-garamond", familia: "EB Garamond", pesos: [400, 600, 800], tipo: "serifa", italico: true },
  { chave: "libre-baskerville", familia: "Libre Baskerville", pesos: [400, 700], tipo: "serifa" },
  { chave: "crimson-pro", familia: "Crimson Pro", pesos: [400, 600, 800], tipo: "serifa", italico: true },
  { chave: "spectral", familia: "Spectral", pesos: [400, 600, 800], tipo: "serifa", italico: true },
  { chave: "source-serif-4", familia: "Source Serif 4", pesos: [400, 600, 800], tipo: "serifa", italico: true },
  { chave: "newsreader", familia: "Newsreader", pesos: [400, 600, 800], tipo: "serifa", italico: true },
  { chave: "bitter", familia: "Bitter", pesos: [400, 700, 900], tipo: "serifa", italico: true },
  { chave: "roboto-slab", familia: "Roboto Slab", pesos: [400, 700, 900], tipo: "serifa" },
  { chave: "zilla-slab", familia: "Zilla Slab", pesos: [400, 600, 700], tipo: "serifa", italico: true },
  { chave: "dm-serif-text", familia: "DM Serif Text", pesos: [400], tipo: "serifa", italico: true },
  { chave: "pt-serif", familia: "PT Serif", pesos: [400, 700], tipo: "serifa", italico: true },
  { chave: "sacramento", familia: "Sacramento", pesos: [400], tipo: "escrita" },
  { chave: "satisfy", familia: "Satisfy", pesos: [400], tipo: "escrita" },
  { chave: "kaushan-script", familia: "Kaushan Script", pesos: [400], tipo: "escrita" },
  { chave: "yellowtail", familia: "Yellowtail", pesos: [400], tipo: "escrita" },
  { chave: "parisienne", familia: "Parisienne", pesos: [400], tipo: "escrita" },
  { chave: "alex-brush", familia: "Alex Brush", pesos: [400], tipo: "escrita" },
  { chave: "pinyon-script", familia: "Pinyon Script", pesos: [400], tipo: "escrita" },
  { chave: "mrs-saint-delafield", familia: "Mrs Saint Delafield", pesos: [400], tipo: "escrita" },
  { chave: "homemade-apple", familia: "Homemade Apple", pesos: [400], tipo: "escrita" },
  { chave: "shadows-into-light", familia: "Shadows Into Light", pesos: [400], tipo: "escrita" },
  { chave: "gochi-hand", familia: "Gochi Hand", pesos: [400], tipo: "escrita" },
  { chave: "kalam", familia: "Kalam", pesos: [400, 700], tipo: "escrita" },
  { chave: "indie-flower", familia: "Indie Flower", pesos: [400], tipo: "escrita" },
  { chave: "amatic-sc", familia: "Amatic SC", pesos: [400, 700], tipo: "escrita" },
  { chave: "rock-salt", familia: "Rock Salt", pesos: [400], tipo: "escrita" },
  { chave: "marck-script", familia: "Marck Script", pesos: [400], tipo: "escrita" },
  { chave: "courgette", familia: "Courgette", pesos: [400], tipo: "escrita" },
  { chave: "cookie", familia: "Cookie", pesos: [400], tipo: "escrita" },
  { chave: "damion", familia: "Damion", pesos: [400], tipo: "escrita" },
  { chave: "mr-dafoe", familia: "Mr Dafoe", pesos: [400], tipo: "escrita" },
  { chave: "nothing-you-could-do", familia: "Nothing You Could Do", pesos: [400], tipo: "escrita" },
  { chave: "reenie-beanie", familia: "Reenie Beanie", pesos: [400], tipo: "escrita" },
  { chave: "patrick-hand", familia: "Patrick Hand", pesos: [400], tipo: "escrita" },
  { chave: "birthstone", familia: "Birthstone", pesos: [400], tipo: "escrita" },
  { chave: "ms-madi", familia: "Ms Madi", pesos: [400], tipo: "escrita" },
  { chave: "corinthia", familia: "Corinthia", pesos: [400, 700], tipo: "escrita" },
  { chave: "charm", familia: "Charm", pesos: [400, 700], tipo: "escrita" },
  { chave: "sedgwick-ave-display", familia: "Sedgwick Ave Display", pesos: [400], tipo: "escrita" },
  { chave: "shrikhand", familia: "Shrikhand", pesos: [400], tipo: "retro" },
  { chave: "alfa-slab-one", familia: "Alfa Slab One", pesos: [400], tipo: "retro" },
  { chave: "bungee-shade", familia: "Bungee Shade", pesos: [400], tipo: "retro" },
  { chave: "bungee-inline", familia: "Bungee Inline", pesos: [400], tipo: "retro" },
  { chave: "ultra", familia: "Ultra", pesos: [400], tipo: "retro" },
  { chave: "rye", familia: "Rye", pesos: [400], tipo: "retro" },
  { chave: "fascinate", familia: "Fascinate", pesos: [400], tipo: "retro" },
  { chave: "limelight", familia: "Limelight", pesos: [400], tipo: "retro" },
  { chave: "vast-shadow", familia: "Vast Shadow", pesos: [400], tipo: "retro" },
  { chave: "coiny", familia: "Coiny", pesos: [400], tipo: "retro" },
  { chave: "rampart-one", familia: "Rampart One", pesos: [400], tipo: "retro" },
  { chave: "bagel-fat-one", familia: "Bagel Fat One", pesos: [400], tipo: "retro" },
  { chave: "rubik-bubbles", familia: "Rubik Bubbles", pesos: [400], tipo: "retro" },
  { chave: "rubik-glitch", familia: "Rubik Glitch", pesos: [400], tipo: "retro" },
  { chave: "rubik-wet-paint", familia: "Rubik Wet Paint", pesos: [400], tipo: "retro" },
  { chave: "chicle", familia: "Chicle", pesos: [400], tipo: "retro" },
  { chave: "lobster-two", familia: "Lobster Two", pesos: [400, 700], tipo: "retro", italico: true },
  { chave: "fugaz-one", familia: "Fugaz One", pesos: [400], tipo: "retro" },
  { chave: "holtwood-one-sc", familia: "Holtwood One SC", pesos: [400], tipo: "retro" },
  { chave: "gravitas-one", familia: "Gravitas One", pesos: [400], tipo: "retro" },
  { chave: "pirata-one", familia: "Pirata One", pesos: [400], tipo: "retro" },
  { chave: "unifrakturmaguntia", familia: "UnifrakturMaguntia", pesos: [400], tipo: "retro" },
  { chave: "grenze-gotisch", familia: "Grenze Gotisch", pesos: [600, 800], tipo: "retro" },
  { chave: "creepster", familia: "Creepster", pesos: [400], tipo: "retro" },
  { chave: "mountains-of-christmas", familia: "Mountains of Christmas", pesos: [700], tipo: "retro" },
  { chave: "space-mono", familia: "Space Mono", pesos: [400, 700], tipo: "tecnica", italico: true },
  { chave: "jetbrains-mono", familia: "JetBrains Mono", pesos: [400, 700, 800], tipo: "tecnica", italico: true },
  { chave: "ibm-plex-mono", familia: "IBM Plex Mono", pesos: [400, 600, 700], tipo: "tecnica", italico: true },
  { chave: "courier-prime", familia: "Courier Prime", pesos: [400, 700], tipo: "tecnica", italico: true },
  { chave: "special-elite", familia: "Special Elite", pesos: [400], tipo: "tecnica" },
  { chave: "vt323", familia: "VT323", pesos: [400], tipo: "tecnica" },
  { chave: "press-start-2p", familia: "Press Start 2P", pesos: [400], tipo: "tecnica" },
  { chave: "silkscreen", familia: "Silkscreen", pesos: [400, 700], tipo: "tecnica" },
  { chave: "major-mono-display", familia: "Major Mono Display", pesos: [400], tipo: "tecnica" },
  { chave: "orbitron", familia: "Orbitron", pesos: [500, 700, 900], tipo: "tecnica" },
  { chave: "audiowide", familia: "Audiowide", pesos: [400], tipo: "tecnica" },
  { chave: "michroma", familia: "Michroma", pesos: [400], tipo: "tecnica" },
  { chave: "exo-2", familia: "Exo 2", pesos: [400, 700, 900], tipo: "tecnica", italico: true },
  { chave: "saira-stencil-one", familia: "Saira Stencil One", pesos: [400], tipo: "tecnica" },
  { chave: "stardos-stencil", familia: "Stardos Stencil", pesos: [400, 700], tipo: "tecnica" },
  { chave: "black-ops-one", familia: "Black Ops One", pesos: [400], tipo: "tecnica" },
  { chave: "share-tech-mono", familia: "Share Tech Mono", pesos: [400], tipo: "tecnica" },
  { chave: "chakra-petch", familia: "Chakra Petch", pesos: [400, 600, 700], tipo: "tecnica", italico: true },
  { chave: "oxanium", familia: "Oxanium", pesos: [400, 700, 800], tipo: "tecnica" },
];

export const FONTES: Fonte[] = [...FONTES_DE_BASE, ...FONTES_A_MAIS];
export const CHAVES_DAS_FONTES_DE_BASE = FONTES_DE_BASE.map((f) => f.chave);

const PELA_FAMILIA = new Map(FONTES.map((f) => [f.familia.toLowerCase(), f]));

const PELA_CHAVE = new Map(FONTES.map((f) => [f.chave, f]));
export const FONTE_PADRAO = "montserrat";

/* ------------------------------------------------ fontes da marca (enviadas) */

/** Uma fonte do kit da marca: a chave (kit-…), o nome que a pessoa vê e o arquivo no depósito. */
export type FonteDaMarca = { chave: string; nome: string; url: string };

const DA_MARCA = new Map<string, FonteDaMarca>();
const ARQUIVO_DE_FONTE = /^https:\/\/[a-z0-9-]+\.supabase\.co\/storage\/v1\/object\/public\/[^\s"'<>()]+\.(woff2?|ttf|otf)$/i;

/** A fonte da marca conferida (chave, nome e arquivo do depósito), ou null. */
export function fonteDaMarcaLimpa(bruto: unknown): FonteDaMarca | null {
  const x = bruto as Record<string, unknown> | null;
  if (!x || typeof x !== "object") return null;
  const chave = String(x.chave ?? "");
  const url = String(x.url ?? "");
  if (!/^kit-[a-z0-9]{4,40}$/.test(chave) || !ARQUIVO_DE_FONTE.test(url)) return null;
  const nome = String(x.nome ?? "").replace(/[<>"'`]/g, "").trim().slice(0, 60) || "Fonte da marca";
  return { chave, nome, url };
}

/** Passa a reconhecer as fontes da marca (no editor e no servidor): entram na lista como as do Google. */
export function registrarFontesDaMarca(lista: unknown) {
  for (const bruta of Array.isArray(lista) ? lista.slice(0, 20) : []) {
    const f = fonteDaMarcaLimpa(bruta);
    if (!f) continue;
    DA_MARCA.set(f.chave, f);
    const fonte: Fonte = { chave: f.chave, familia: f.chave, pesos: [400], tipo: "moderna", propria: true, nome: f.nome };
    PELA_CHAVE.set(f.chave, fonte);
    PELA_FAMILIA.set(f.chave.toLowerCase(), fonte);
  }
}

export function fontesDaMarca(): FonteDaMarca[] {
  return [...DA_MARCA.values()];
}

/** O @font-face das fontes da marca (o servidor passa o arquivo já embutido). */
export function cssDasFontesDaMarca(chaves: string[], arquivo?: (f: FonteDaMarca) => string | null) {
  return [...new Set(chaves)]
    .map((c) => DA_MARCA.get(c))
    .filter((f): f is FonteDaMarca => Boolean(f))
    .map((f) => {
      const src = arquivo ? arquivo(f) : f.url;
      return src ? `@font-face{font-family:'${f.chave}';src:url("${src.replace(/"/g, "")}");font-display:block}` : "";
    })
    .join("");
}

export function fonteDaChave(chave: unknown): Fonte {
  return PELA_CHAVE.get(String(chave ?? "")) ?? PELA_CHAVE.get(FONTE_PADRAO)!;
}

/** O peso mais perto do pedido que a fonte tem (sem negrito falso). */
export function pesoQueExiste(chave: string, peso: number) {
  const pesos = fonteDaChave(chave).pesos;
  return pesos.reduce((melhor, p) => (Math.abs(p - peso) < Math.abs(melhor - peso) ? p : melhor), pesos[0]);
}

/** A folha do Google Fonts com as fontes pedidas (só elas, só os pesos que existem). */
export function linkDasFontes(chaves: string[]) {
  const familias = [...new Set(chaves.map((c) => fonteDaChave(c)).filter((f) => !f.propria).map((f) => f.chave))].map((c) => {
    const f = fonteDaChave(c);
    const nome = f.familia.replace(/ /g, "+");
    if (f.italico) return `family=${nome}:ital,wght@${[...f.pesos.map((p) => `0,${p}`), ...f.pesos.map((p) => `1,${p}`)].join(";")}`;
    return f.pesos.length === 1 && f.pesos[0] === 400 ? `family=${nome}` : `family=${nome}:wght@${f.pesos.join(";")}`;
  });
  return `https://fonts.googleapis.com/css2?${familias.join("&")}&display=block`;
}

/* As famílias já pedidas ao Google nesta página (cada uma uma vez). */
const fontesPedidas = new Set<string>();

/**
 * Garante no navegador o CSS das fontes (de 30 em 30 famílias por link). O
 * CSS só declara a letra: o arquivo dela baixa quando algum texto usa.
 */
export function garantirFontes(chaves: Iterable<string>) {
  if (typeof document === "undefined") return;
  const novas = [...new Set([...chaves].map((c) => fonteDaChave(c)).filter((f) => !f.propria && !fontesPedidas.has(f.chave)).map((f) => f.chave))];
  for (let i = 0; i < novas.length; i += 30) {
    const parte = novas.slice(i, i + 30);
    const link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = linkDasFontes(parte);
    link.dataset.fontesDoEditor = "";
    document.head.appendChild(link);
    parte.forEach((c) => fontesPedidas.add(c));
  }
}

/** As fontes que as páginas usam (texto, trechos, tabela, gráfico). */
export function fontesDoDocumento(paginas: DocumentoDoBanner[]): string[] {
  const usadas = new Set<string>();
  for (const pagina of paginas)
    for (const p of pagina.pecas) {
      if (p.tipo === "texto") {
        usadas.add(p.fonte);
        fontesDosTrechos(p.html).forEach((f) => usadas.add(f));
      } else if (p.tipo === "tabela" || p.tipo === "grafico") usadas.add(p.fonte);
    }
  return [...usadas];
}

/* ================================================================= tipos */

/** A tinta de qualquer coisa: cor única ou degradê (linear, com ângulo; radial, do centro para fora). */
/* "nenhuma" = sem preenchimento (forma só com borda, letra só com contorno). */
/* `alfa` e `alfa2`: quanto cada cor cobre (0 a 1; sem o campo, cobre inteira). Degradê para transparente = alfa2 0. */
/* `pontos`: onde o degradê linear começa e termina, em fração da caixa (as alças no palco); sem eles, vale o `angulo` de ponta a ponta. */
/* `meio`: onde as duas cores ficam meio a meio (0 a 1; sem o campo, no meio). É o tracinho no meio da seta. */
/* `paradas`: as cores do meio (posição 0 a 1 entre a primeira e a segunda), para degradê de 3 ou mais cores. */
/* Radial com `pontos`: (x1, y1) é o centro e (x2, y2) a borda do círculo (o holofote pode ficar fora do centro). */
export type ParadaDoDegrade = { pos: number; cor: string; alfa?: number };
export type Pintura = { tipo: "solida" | "linear" | "radial" | "nenhuma"; cor: string; cor2: string; angulo: number; alfa?: number; alfa2?: number; pontos?: PontosDoDegrade; meio?: number; paradas?: ParadaDoDegrade[] };
/** Começo (x1, y1) e fim (x2, y2) de um degradê, em fração da caixa da peça (podem passar de 0 e 1). */
export type PontosDoDegrade = { x1: number; y1: number; x2: number; y2: number };
/** Transparência em degradê da peça inteira (a "transparência interativa" do Corel): cobre `alfa` no começo e `alfa2` no fim. */
/* `radial`: some do centro (x1, y1) para a borda (x2, y2), em círculo. */
export type TransparenciaDaPeca = PontosDoDegrade & { alfa: number; alfa2: number; meio?: number; radial?: boolean };
export type Borda = { cor: string; largura: number } | null;
/** Sombra: deslocamento e desfoque em px do banner; opacidade de 0 a 1. */
export type Sombra = { cor: string; x: number; y: number; desfoque: number; opacidade: number } | null;

/** Como a peça se mistura com o que está embaixo (os modos de mesclagem do Photoshop/Corel). */
export const MISTURAS = ["normal", "tela", "multiplicar", "sobrepor", "luz-suave", "luz-forte", "clarear", "escurecer", "subexpor", "superexpor", "diferenca", "cor", "luminosidade"] as const;
export type Mistura = (typeof MISTURAS)[number];
type MisturaDoCss = "normal" | "screen" | "multiply" | "overlay" | "soft-light" | "hard-light" | "lighten" | "darken" | "color-dodge" | "color-burn" | "difference" | "color" | "luminosity";
export const CSS_DA_MISTURA: Record<Mistura, MisturaDoCss> = {
  normal: "normal",
  tela: "screen",
  multiplicar: "multiply",
  sobrepor: "overlay",
  "luz-suave": "soft-light",
  "luz-forte": "hard-light",
  clarear: "lighten",
  escurecer: "darken",
  subexpor: "color-dodge",
  superexpor: "color-burn",
  diferenca: "difference",
  cor: "color",
  luminosidade: "luminosity",
};

/**
 * Os efeitos a mais da peça (vários juntos, além da `sombra` de sempre):
 * sombra e brilho por fora, sombra por dentro e contorno (que pode ser em
 * degradê, de `cor` para `cor2`). Medidas em px da arte.
 */
export type EfeitoDaPeca = { tipo: "sombra" | "brilho" | "sombra-interna" | "contorno"; cor: string; cor2?: string; x: number; y: number; desfoque: number; opacidade: number; largura?: number };

/** Distorcer/perspectiva: onde cada canto da caixa vai parar (fração da caixa; em cima à esquerda, à direita, embaixo à direita, à esquerda). */
/* `giro3d`: os graus do "Girar em 3D" (de cima para baixo, de lado) que deram esses cantos (o painel lembra). */
export type DistorcaoDaPeca = { cantos: [[number, number], [number, number], [number, number], [number, number]]; giro3d?: [number, number] };

export const FORMAS = [
  "retangulo",
  "circulo",
  "triangulo",
  "losango",
  "estrela",
  "explosao",
  "etiqueta",
  "faixa",
  "seta",
  "balao",
  "coracao",
  "onda",
  "hexagono",
  "pentagono",
  "octogono",
  "paralelogramo",
  "cruz",
  "raio",
  "nuvem",
  "gota",
  "semicirculo",
  "anel",
  /* Papel rasgado: a borda serrilhada sai das "pontas" (o desenho) e da "profundidade" (o tamanho do rasgo). */
  "rasgado",
  /* Fita ondulada que atravessa a caixa. */
  "fita",
] as const;
export type Forma = (typeof FORMAS)[number];
export const FORMAS_DE_FUNDO_DO_TEXTO = ["retangulo", "pilula", "circulo", "explosao", "estrela", "etiqueta", "faixa", "balao", "seta", "rasgado"] as const;
export type FormaDoFundoDoTexto = (typeof FORMAS_DE_FUNDO_DO_TEXTO)[number];

type Base = {
  id: string;
  nome?: string;
  x: number;
  y: number;
  w: number;
  h: number;
  /* Giro em graus, no centro da peça. */
  rot: number;
  /* 0 a 1. */
  opacidade: number;
  espelharX?: boolean;
  espelharY?: boolean;
  bloqueada?: boolean;
  oculta?: boolean;
  /* Some aos poucos de um ponto a outro (qualquer peça: forma, foto, texto, ícone). */
  transparencia?: TransparenciaDaPeca | null;
  /* Peças com o mesmo `grupo` andam juntas (Ctrl+G): um clique escolhe todas. */
  grupo?: string;
  /* Modo de mesclagem com o que está embaixo. */
  mistura?: Mistura;
  efeitos?: EfeitoDaPeca[];
  distorcao?: DistorcaoDaPeca | null;
  /* A perspectiva do grupo que entortou esta peça (a distorção dela sai dessa conta; perspectiva-do-grupo.ts). */
  perspectiva?: PerspectivaDoGrupo | null;
  /* Fixa em todas as páginas (a "página mestre"): a mesma peça, com o mesmo id, em cada página; mudou numa, muda em todas. */
  fixa?: boolean;
  /* "Colocar dentro de": a peça aparece só dentro da silhueta da outra (forma, desenho fechado, foto) ou, se a outra é texto e esta é foto, como o recheio das letras. */
  dentroDe?: string;
  /* A animação da peça no vídeo e no "Reproduzir" (animacao.ts). */
  animacao?: AnimacaoDaPeca;
};

export type FundoDoTexto = {
  forma: FormaDoFundoDoTexto;
  pintura: Pintura;
  borda: Borda;
  raio: number;
  /* Pontas e profundidade da explosão e da estrela. */
  pontas: number;
  profundidade: number;
};

export type PecaDeTexto = Base & {
  tipo: "texto";
  /* O texto puro (com "\n"). Com trechos formatados, `html` manda no desenho e `texto` acompanha. */
  texto: string;
  /* Trechos com negrito, itálico, cor, tamanho ou fonte próprios: HTML curto, sempre passado por `htmlDoTextoLimpo`. */
  html?: string | null;
  /* Quanto o editor encolheu a letra sozinho para o texto caber (1 = nada). O "Redimensionar" desconta: não é mudança da pessoa. */
  encaixe?: number;
  fonte: string;
  tamanho: number;
  peso: number;
  pintura: Pintura;
  alinhamento: "esquerda" | "centro" | "direita";
  vertical: "topo" | "meio" | "base";
  italico: boolean;
  caixaAlta: boolean;
  riscado: boolean;
  sublinhado: boolean;
  /* Espaço entre letras, em em (-0.1 a 1). */
  espaco: number;
  /* Altura da linha (0.7 a 2.5). */
  linha: number;
  fundo: FundoDoTexto | null;
  contorno: Borda;
  sombra: Sombra;
  /* Texto em curva (-100 a 100; 0 = reto). Positivo arqueia para cima; 100 fecha o círculo (selo). */
  curva?: number;
  /* Marca-texto atrás de cada linha (a faixa acompanha a quebra). */
  destaque?: DestaqueDoTexto | null;
  /* Estilo pronto da letra (eco, neon, sombra longa...): sombras de texto na cor e força escolhidas. */
  efeito?: EfeitoDoTexto | null;
  /* Lista: cada linha com um marcador. */
  lista?: "pontos" | "numeros" | "checks" | null;
  /* Recursos da fonte (o painel Glifos): floreios, alternativas, ligaduras, estilos ({ "swsh": 1, "liga": 0 }). */
  recursos?: RecursosDaLetra | null;
};

export const EFEITOS_DO_TEXTO = ["levantar", "eco", "sombra-longa", "neon", "contorno-duplo", "falha"] as const;
/* `forca`: 0 a 1. */
export type EfeitoDoTexto = { tipo: (typeof EFEITOS_DO_TEXTO)[number]; cor: string; forca: number };

/* `respiro`: folga dos lados, em em; `raio`: cantos, em px. */
export type DestaqueDoTexto = { pintura: Pintura; respiro: number; raio: number };

export type PecaDeForma = Base & {
  tipo: "forma";
  forma: Forma;
  pintura: Pintura;
  /* Cantos (só no retângulo). */
  raio: number;
  borda: Borda;
  sombra: Sombra;
  pontas: number;
  profundidade: number;
};

/*
 * Os ajustes da foto. Os quatro primeiros são os de sempre (CSS); os outros
 * entram só quando mexidos: matiz (graus), exposição e temperatura (-100 a
 * 100), níveis (pretos e brancos 0 a 255, gama 0,2 a 3), nitidez e vinheta
 * (0 a 100) e uma cor por cima (com a força e o modo de mesclagem).
 */
export type FiltrosDaImagem = {
  brilho: number;
  contraste: number;
  saturacao: number;
  desfoque: number;
  matiz?: number;
  exposicao?: number;
  temperatura?: number;
  pretos?: number;
  brancos?: number;
  gama?: number;
  nitidez?: number;
  vinheta?: number;
  sobreposicao?: { cor: string; forca: number; mistura: Mistura } | null;
};

export type PecaDeImagem = Base & {
  tipo: "imagem";
  src: string;
  papel: "foto" | "logo";
  ajuste: "cobrir" | "conter";
  raio: number;
  circulo: boolean;
  /* A borda grossa deixa a foto com cara de adesivo. */
  borda: Borda;
  sombra: Sombra;
  filtros: FiltrosDaImagem;
  /* Duas cores: o escuro da foto vira `escuro`, o claro vira `claro` (a foto "rosa" das colagens). */
  duotone?: { escuro: string; claro: string } | null;
  /* Contorno de adesivo em volta do recorte (foto sem fundo). Largura em px. */
  adesivo?: { cor: string; largura: number } | null;
  /* A foto some aos poucos para um lado (0 a 1 = quanto da foto o sumiço ocupa). */
  desvanecer?: { lado: "baixo" | "cima" | "esquerda" | "direita"; tamanho: number } | null;
  /* A foto dentro de uma forma (moldura): estrela, coração, hexágono... */
  mascara?: Forma | null;
  /* Enquadre da foto na moldura: zoom (1 a 5) e o ponto da foto que fica no centro (0 a 100). */
  enquadre?: { zoom: number; x: number; y: number } | null;
  /* Moldura vazia: soltar uma foto em cima troca a imagem e mantém a moldura. */
  moldura?: boolean;
  /* A foto de antes de tirar o fundo (a borracha restaura com ela) e o pedaço dela que a foto de agora é (fração). */
  semFundoDe?: string;
  corteDoFundo?: { x0: number; y0: number; x1: number; y1: number };
  /* Estampa curvada num cilindro (caneca, garrafa): `curva` 0 a 1 é quanto da volta aparece (1 = meia volta); `arco` é o quanto as bordas sobem (vista de cima). */
  cilindro?: { curva: number; arco: number } | null;
  /*
   * Vídeo: a peça mostra a capa (`src`, a foto do primeiro quadro) nas fotos
   * e no PDF; no "Reproduzir" e no MP4 toca o arquivo. `corte` = de que
   * segundo do arquivo começa (o clipe aparado), `duracao` = quanto o
   * arquivo tem.
   */
  video?: VideoDaPeca | null;
  /* Fundo verde (ou azul) que some (chroma.ts). */
  chroma?: ChromaDaFoto | null;
  /* Elemento em SVG com as cores trocáveis (vetores.ts): as originais e as trocas da pessoa. */
  vetor?: VetorDaPeca | null;
  /* Cores trocadas e retícula feitas nos pixels (tratamento-da-foto.ts): a foto de antes, o que foi feito e o arquivo gerado. */
  tratamento?: TratamentoDaFoto | null;
};

/* `velocidade`: 0,25 a 4 (1 = normal; 0 = quadro congelado no ponto do corte). */
export type VideoDaPeca = { src: string; duracao: number; corte: number; mudo: boolean; volume: number; velocidade?: number };
/** A velocidade do vídeo (0 congela; sem ela, 1). */
export const velocidadeDo = (v: Pick<VideoDaPeca, "velocidade"> | null | undefined) => (v?.velocidade === undefined ? 1 : v.velocidade);

/*
 * Um áudio do projeto (música, efeito sonoro ou o que a pessoa enviou), na
 * trilha de áudio da linha do tempo. Vale para o projeto inteiro (mora na
 * primeira página): `inicio` é o segundo do projeto em que começa, `corte`
 * de que segundo do arquivo, `dura` quanto toca; `suave` = entra e sai aos
 * poucos (meio segundo). `onda` é o desenho da forma de onda (0 a 100).
 */
/* `fadeDeEntrada` / `fadeDeSaida`: segundos do fade in e do fade out (sem eles, o "Suave" faz meio segundo). */
export type AudioDoProjeto = { id: string; nome: string; src: string; tipo: "musica" | "efeito" | "enviado"; duracao: number; inicio: number; corte: number; dura: number; volume: number; suave: boolean; fadeDeEntrada?: number; fadeDeSaida?: number; onda?: number[] };

export function audiosLimpos(bruto: unknown): AudioDoProjeto[] {
  if (!Array.isArray(bruto)) return [];
  return bruto.slice(0, 30).flatMap((item): AudioDoProjeto[] => {
    const a = (item ?? {}) as Record<string, unknown>;
    const src = String(a.src ?? "").trim();
    if (!ENDERECO_DE_IMAGEM.test(src)) return [];
    const duracao = numero(a.duracao, 0.1, 1800, 1);
    const corte = numero(a.corte, 0, Math.max(0, duracao - 0.1), 0);
    const tipo = a.tipo === "musica" || a.tipo === "efeito" ? a.tipo : "enviado";
    const onda = Array.isArray(a.onda) ? a.onda.slice(0, 200).map((v) => numero(v, 0, 100, 0)) : undefined;
    return [
      {
        id: String(a.id ?? "").replace(/[^a-z0-9_-]/gi, "").slice(0, 40) || novoId("a"),
        nome: String(a.nome ?? "Áudio").slice(0, 60),
        src,
        tipo,
        duracao,
        inicio: numero(a.inicio, 0, 1800, 0),
        corte,
        dura: numero(a.dura, 0.1, duracao - corte, duracao - corte),
        volume: numero(a.volume, 0, 1, 1),
        suave: a.suave !== false,
        ...(a.fadeDeEntrada !== undefined && a.fadeDeEntrada !== null ? { fadeDeEntrada: numero(a.fadeDeEntrada, 0, 20, 0) } : {}),
        ...(a.fadeDeSaida !== undefined && a.fadeDeSaida !== null ? { fadeDeSaida: numero(a.fadeDeSaida, 0, 20, 0) } : {}),
        ...(onda ? { onda } : {}),
      },
    ];
  });
}

/** O vídeo da peça conferido (endereço do depósito, tempos e volume nos limites). */
export function videoLimpo(bruto: unknown): VideoDaPeca | null {
  if (!bruto || typeof bruto !== "object") return null;
  const v = bruto as Record<string, unknown>;
  const src = String(v.src ?? "").trim();
  if (!ENDERECO_DE_IMAGEM.test(src)) return null;
  const duracao = numero(v.duracao, 0.1, 600, 1);
  const velocidade = v.velocidade === undefined || v.velocidade === null ? 1 : Number(v.velocidade) === 0 ? 0 : numero(v.velocidade, 0.25, 4, 1);
  return { src, duracao, corte: numero(v.corte, 0, Math.max(0, duracao - 0.1), 0), mudo: v.mudo === true, volume: numero(v.volume, 0, 1, 1), ...(velocidade !== 1 ? { velocidade } : {}) };
}

/* A textura é uma peça como as outras: tem caixa, giro, cor e transparência; o desenho repete dentro dela. */
/* "meio-tom": pontos de retícula que diminuem na direção do giro; "topografia": curvas de nível em volta do centro. */
/* "grade": papel quadriculado (linhas finas). As últimas são partículas que se mexem no vídeo (particulas.ts). */
export const ESTILOS_DE_TEXTURA = ["raios", "pontos", "listras", "xadrez", "grao", "ondas", "meio-tom", "topografia", "grade", "confete", "neve", "brilhos", "bolhas", "coracoes", "faiscas", "fogos"] as const;
export type EstiloDeTextura = (typeof ESTILOS_DE_TEXTURA)[number];

export type PecaDeTextura = Base & {
  tipo: "textura";
  estilo: EstiloDeTextura;
  cor: string;
  /* Raios: quantos; nos outros, o tamanho do desenho (px). */
  quantidade: number;
  tamanho: number;
  /* Onde nascem os raios (em % da caixa) e o giro do desenho. */
  centroX: number;
  centroY: number;
  giro: number;
  /* Partículas: a velocidade (0,2 a 3; 1 = normal). */
  velocidade?: number;
};

/* Ícone de traço (viewBox 24 × 24), da lista fixa ICONES: dá para pintar, engrossar o traço e preencher. */
export type PecaDeIcone = Base & {
  tipo: "icone";
  icone: string;
  pintura: Pintura;
  /* Grossura do traço, em unidades do desenho (0,75 a 4). */
  espessura: number;
  preenchido: boolean;
  sombra: Sombra;
  /* Ícone da biblioteca grande (Lucide): o desenho vai na própria peça (só formas: path, círculo, retângulo, linha...). */
  desenho?: string;
};

/**
 * O desenho de ícone conferido: só formas geométricas (path, circle, rect,
 * line, polyline, polygon, ellipse) com as medidas delas; o resto sai. Em
 * 24 × 24, como os ícones da casa.
 */
export function desenhoDeIconeLimpo(bruto: unknown): string {
  if (typeof bruto !== "string" || bruto.length > 8000) return "";
  const permitidos = new Set(["d", "cx", "cy", "r", "rx", "ry", "x", "y", "width", "height", "x1", "y1", "x2", "y2", "points", "fill"]);
  const saida: string[] = [];
  for (const m of bruto.matchAll(/<(path|circle|rect|line|polyline|polygon|ellipse)\b([^<>]*?)\/?>/gi)) {
    const atributos: string[] = [];
    for (const a of m[2].matchAll(/([a-z0-9-]+)\s*=\s*"([^"]*)"/gi)) {
      const nome = a[1].toLowerCase();
      const valor = a[2];
      if (!permitidos.has(nome)) continue;
      if (nome === "fill" ? valor !== "currentColor" && valor !== "none" : !/^[0-9a-zA-Z.,\s+-]*$/.test(valor)) continue;
      atributos.push(`${nome}="${valor}"`);
    }
    saida.push(`<${m[1].toLowerCase()} ${atributos.join(" ")}/>`);
  }
  return saida.join("").slice(0, 8000);
}

/* Linha, seta, caneta e desenho livre: pontos em fração da caixa (andam e crescem com ela). */
export const PONTAS_DO_TRACO = ["nada", "seta", "seta-aberta", "circulo", "barra", "losango"] as const;
export type PontaDoTraco = (typeof PONTAS_DO_TRACO)[number];
/*
 * Um nó do traço. Com alças é curva Bézier (como a caneta do Corel): "e" puxa
 * o trecho que chega, "s" o que sai (as duas na mesma medida dos pontos).
 * "suave" mantém as alças em linha; "canto" deixa cada uma livre. Ver curvas.ts.
 */
export type PontoDoTraco = { x: number; y: number; e?: { x: number; y: number } | null; s?: { x: number; y: number } | null; no?: "canto" | "suave" };
export type PecaDeTraco = Base & {
  tipo: "traco";
  pontos: PontoDoTraco[];
  /* Outros contornos do mesmo desenho (furos e pedaços soltos, do Soldar/Aparar): sempre fechados, com as alças escritas, pintados pela regra par-ímpar. */
  contornos?: PontoDoTraco[][];
  /* Fechado: a forma desenhada com a caneta (pode ter preenchimento). */
  fechado: boolean;
  /* Curvas suaves entre os pontos. */
  suave: boolean;
  cor: string;
  /* Grossura do traço em px. */
  espessura: number;
  tracejado: "solido" | "tracejado" | "pontilhado";
  inicio: PontaDoTraco;
  fim: PontaDoTraco;
  preenchimento: Pintura;
  sombra: Sombra;
};

/* QR code: o conteúdo (link, WhatsApp, texto, Pix copia e cola) desenhado na hora. */
export type PecaDeQr = Base & { tipo: "qr"; conteudo: string; cor: string; fundo: string | null; arredondado: boolean };

export type PecaDeTabela = Base & {
  tipo: "tabela";
  /* Linhas × colunas de texto; a primeira linha é o cabeçalho quando `cabecalho`. */
  celulas: string[][];
  fonte: string;
  tamanho: number;
  corDoTexto: string;
  cabecalho: boolean;
  corDoCabecalho: string;
  corDoTextoDoCabecalho: string;
  corDaLinha: string;
  corDaLinha2: string;
  corDaBorda: string;
  alinhamento: "esquerda" | "centro" | "direita";
  raio: number;
};

export const ESTILOS_DE_GRAFICO = ["colunas", "barras", "linha", "pizza", "rosca"] as const;
export type PecaDeGrafico = Base & {
  tipo: "grafico";
  estilo: (typeof ESTILOS_DE_GRAFICO)[number];
  dados: Array<{ rotulo: string; valor: number }>;
  cores: string[];
  fonte: string;
  corDoTexto: string;
  mostrarValores: boolean;
  prefixo: string;
  sufixo: string;
};

export type Peca = PecaDeTexto | PecaDeForma | PecaDeImagem | PecaDeTextura | PecaDeIcone | PecaDeTraco | PecaDeQr | PecaDeTabela | PecaDeGrafico;

/* ============================================================== ícones */

/* Desenhos próprios, de traço, no jeito dos ícones do painel. */
/*
 * Os ícones da casa (traço de 24 × 24). `cheio`: desenho preenchido, sem
 * contorno (os logos das redes, com o desenho oficial do Simple Icons, CC0, e
 * o laço de presente); `cores`: já vem nas cores oficiais da marca (a cor da
 * peça não muda).
 */
export const ICONES: Record<string, { nome: string; desenho: string; cheio?: boolean; cores?: boolean }> = {
  carrinho: { nome: "Carrinho", desenho: `<path d="M2 3h3l2.6 12.2a2 2 0 0 0 2 1.6h8.4a2 2 0 0 0 2-1.5L22 8H6.2"/><circle cx="9.5" cy="20.5" r="1.3"/><circle cx="18" cy="20.5" r="1.3"/>` },
  sacola: { nome: "Sacola", desenho: `<path d="M5 7.5h14l-1.2 13.5H6.2z"/><path d="M9 10.5V6.5a3 3 0 0 1 6 0v4"/>` },
  caminhao: { nome: "Entrega", desenho: `<path d="M2 5.5h11.5v10.5H2zM13.5 9.5h4.2l3.8 3.6v2.9h-8"/><circle cx="6.5" cy="17.8" r="1.9"/><circle cx="17.2" cy="17.8" r="1.9"/>` },
  etiqueta: { nome: "Etiqueta", desenho: `<path d="M3 12.2V4a1 1 0 0 1 1-1h8.2L21 11.8 11.8 21z"/><circle cx="7.6" cy="7.6" r="1.4"/>` },
  porcentagem: { nome: "Porcentagem", desenho: `<path d="M19 5L5 19"/><circle cx="7" cy="7" r="2.4"/><circle cx="17" cy="17" r="2.4"/>` },
  presente: { nome: "Presente", desenho: `<rect x="3" y="8" width="18" height="4.2" rx="1"/><path d="M5 12.2V21h14v-8.8M12 8v13M12 8S10.4 3 7.6 3.8C5.6 4.4 6.4 7.6 12 8zM12 8s1.6-5 4.4-4.2C18.4 4.4 17.6 7.6 12 8z"/>` },
  fogo: { nome: "Fogo", desenho: `<path d="M12 22c4 0 7-2.8 7-7 0-4-3-6.4-4-9.5-1.5 2-2 3.6-2 5-1.6-1-3-3.6-3-6.5C7 6.6 5 10.2 5 15c0 4.2 3 7 7 7z"/>` },
  raio: { nome: "Raio", desenho: `<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>` },
  cursor: { nome: "Cursor", desenho: `<path d="M5 2.8v16.6l4.3-4.1 2.9 6.4 3-1.4-2.9-6.3h6z"/>` },
  busca: { nome: "Busca", desenho: `<circle cx="10.5" cy="10.5" r="6.5"/><path d="m20 20-4.6-4.6"/>` },
  enviar: { nome: "Enviar", desenho: `<path d="M21.5 2.5 10.6 13.4M21.5 2.5l-7 19-3.9-8.1-8.1-3.9z"/>` },
  play: { nome: "Play", desenho: `<path d="M7 4.6v14.8a1 1 0 0 0 1.5.9l11.6-7.4a1 1 0 0 0 0-1.7L8.5 3.7A1 1 0 0 0 7 4.6z"/>` },
  sino: { nome: "Sino", desenho: `<path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.6 2H4.4z"/><path d="M10 21a2.2 2.2 0 0 0 4 0"/>` },
  wifi: { nome: "Wi-Fi", desenho: `<path d="M2.5 9a14 14 0 0 1 19 0M5.6 12.4a9.5 9.5 0 0 1 12.8 0M8.7 15.7a5 5 0 0 1 6.6 0"/><circle cx="12" cy="19" r="1"/>` },
  bateria: { nome: "Bateria", desenho: `<rect x="2.5" y="7" width="17" height="10" rx="2.2"/><path d="M22 10.5v3M5.5 10v4h9v-4z"/>` },
  compartilhar: { nome: "Compartilhar", desenho: `<circle cx="18" cy="5.5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="18.5" r="2.6"/><path d="m8.3 10.8 7.4-4M8.3 13.2l7.4 4"/>` },
  "mao-clique": { nome: "Mão clicando", desenho: `<path d="M9 11V4.6a1.6 1.6 0 0 1 3.2 0V10m0-.6a1.6 1.6 0 0 1 3.2 0v1.2m0-.4a1.6 1.6 0 0 1 3.2 0v4.4c0 3.6-2.6 6.4-6.2 6.4h-.8c-2 0-3.4-.8-4.6-2.4L3.6 15a1.6 1.6 0 0 1 2.6-1.9L9 15.6"/><path d="M5 3.5 3.6 2.2M6.4 1.6 6 0"/>` },
  estrela: { nome: "Estrela", desenho: `<path d="m12 2.6 2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5-4.7-4.6 6.5-.9z"/>` },
  coracao: { nome: "Coração", desenho: `<path d="M12 20.5S4 15.8 4 10a4.4 4.4 0 0 1 8-2.6A4.4 4.4 0 0 1 20 10c0 5.8-8 10.5-8 10.5z"/>` },
  brilho: { nome: "Brilho", desenho: `<path d="m12 2.5 2.1 6.4 6.4 2.1-6.4 2.1L12 19.5l-2.1-6.4L3.5 11l6.4-2.1z"/><path d="M19 17.5v4M17 19.5h4"/>` },
  coroa: { nome: "Coroa", desenho: `<path d="m3 7.5 4.5 4L12 4.5l4.5 7 4.5-4-2 11.5H5z"/>` },
  check: { nome: "Certo", desenho: `<path d="m4 12.5 5 5L20 6.5"/>` },
  "check-circulo": { nome: "Certo no círculo", desenho: `<circle cx="12" cy="12" r="9.5"/><path d="m7.6 12.4 3 3 5.8-6.4"/>` },
  escudo: { nome: "Compra segura", desenho: `<path d="M12 2.5 20 5.5v6c0 5-3.5 8.7-8 10-4.5-1.3-8-5-8-10v-6z"/><path d="m8.6 12 2.4 2.4 4.4-4.8"/>` },
  cartao: { nome: "Cartão", desenho: `<rect x="2.5" y="5" width="19" height="14" rx="2"/><path d="M2.5 10h19M6 15h4"/>` },
  relogio: { nome: "Relógio", desenho: `<circle cx="12" cy="12" r="9.5"/><path d="M12 6.6V12l3.5 2.1"/>` },
  calendario: { nome: "Calendário", desenho: `<rect x="3" y="4.5" width="18" height="16.5" rx="2"/><path d="M3 9.5h18M8 2.5v4M16 2.5v4"/>` },
  telefone: { nome: "Telefone", desenho: `<path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 5a2 2 0 0 1 2-2z"/>` },
  mensagem: { nome: "Mensagem", desenho: `<path d="M4 4h16v12.5H8.5L4 20.5z"/>` },
  local: { nome: "Endereço", desenho: `<path d="M12 22s7-6.2 7-12a7 7 0 0 0-14 0c0 5.8 7 12 7 12z"/><circle cx="12" cy="10" r="2.5"/>` },
  megafone: { nome: "Megafone", desenho: `<path d="M3 10v4h3.5l8.5 5V5L6.5 10z"/><path d="M18.2 9a4.2 4.2 0 0 1 0 6"/>` },
  joinha: { nome: "Joinha", desenho: `<path d="M7 10.5V21H3V10.5zM7 10.5l4-8c1.7 0 3 1.3 3 3v4h5.4a2 2 0 0 1 2 2.3l-1.4 7.4A2 2 0 0 1 18 21H7"/>` },
  "seta-direita": { nome: "Seta", desenho: `<path d="M4 12h16M14 6l6 6-6 6"/>` },
  "seta-baixo": { nome: "Seta para baixo", desenho: `<path d="M12 4v16M6 14l6 6 6-6"/>` },
  mais: { nome: "Mais", desenho: `<path d="M12 4.5v15M4.5 12h15"/>` },
  impressora: { nome: "Impressora", desenho: `<path d="M6.5 9V3h11v6M6.5 17.5h-3v-8.5h17v8.5h-3"/><rect x="6.5" y="14" width="11" height="7"/>` },
  camiseta: { nome: "Camiseta", desenho: `<path d="M8.5 3 3 6l2 4.5 2.5-1V21h9V9.5l2.5 1 2-4.5-5.5-3c-.4 1.6-1.8 2.6-3.5 2.6S8.9 4.6 8.5 3z"/>` },
  caneca: { nome: "Caneca", desenho: `<path d="M4 6h12v10a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z"/><path d="M16 9h1.5a2.5 2.5 0 0 1 0 5H16"/>` },
  aviao: { nome: "Avião", desenho: `<path d="M21.5 3.5 2.5 11l7 2.5 2.5 7z"/><path d="m21.5 3.5-12 10"/>` },
  /* out/2026: redes sociais e os desenhos das artes de post. */
  instagram: { nome: "Instagram", desenho: `<path d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 3.678c-3.405 0-6.162 2.76-6.162 6.162 0 3.405 2.76 6.162 6.162 6.162 3.405 0 6.162-2.76 6.162-6.162 0-3.405-2.76-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405c0 .795-.646 1.44-1.44 1.44-.795 0-1.44-.646-1.44-1.44 0-.794.646-1.439 1.44-1.439.793-.001 1.44.645 1.44 1.439z"/>`, cheio: true },
  facebook: { nome: "Facebook", desenho: `<path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/>`, cheio: true },
  whatsapp: { nome: "WhatsApp", desenho: `<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>`, cheio: true },
  tiktok: { nome: "TikTok", desenho: `<path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/>`, cheio: true },
  youtube: { nome: "YouTube", desenho: `<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>`, cheio: true },
  linkedin: { nome: "LinkedIn", desenho: `<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>`, cheio: true },
  arroba: { nome: "Arroba", desenho: `<circle cx="12" cy="12" r="3.8"/><path d="M15.8 8.4v5.1a2.6 2.6 0 0 0 5.2 0V12a9 9 0 1 0-3.6 7.2"/>` },
  globo: { nome: "Site", desenho: `<circle cx="12" cy="12" r="9.5"/><path d="M2.5 12h19M12 2.5c2.6 2.8 3.8 6 3.8 9.5s-1.2 6.7-3.8 9.5c-2.6-2.8-3.8-6-3.8-9.5S9.4 5.3 12 2.5z"/>` },
  comentario: { nome: "Comentário", desenho: `<path d="M20.6 11.6a8.6 8.2 0 0 1-12.5 7.3L3.4 20.4l1.4-4.1a8.6 8.2 0 1 1 15.8-4.7z"/>` },
  salvar: { nome: "Salvar", desenho: `<path d="M6 3h12v18l-6-4.6L6 21z"/>` },
  laco: { nome: "Laço (campanha)", desenho: `<path d="M12 11.22C11 9.997 10 9 10 8a2 2 0 0 1 4 0c0 1-.998 2.002-2.01 3.22"/><path d="m12 18 2.57-3.5"/><path d="M6.243 9.016a7 7 0 0 1 11.507-.009"/><path d="M9.35 14.53 12 11.22"/><path d="M9.35 14.53C7.728 12.246 6 10.221 6 7a6 5 0 0 1 12 0c-.005 3.22-1.778 5.235-3.43 7.5l3.557 4.527a1 1 0 0 1-.203 1.43l-1.894 1.36a1 1 0 0 1-1.384-.215L12 18l-2.679 3.593a1 1 0 0 1-1.39.213l-1.865-1.353a1 1 0 0 1-.203-1.422z"/>` },
  "laco-presente": { nome: "Laço de presente", desenho: `<path fill-rule="evenodd" d="M12 10.2C10.2 6.1 6.5 2.5 3.3 3.5C0.4 4.4 0.7 9.7 4.1 11.4C6.6 12.7 9.8 12 12 11.2Z M10.2 9.7C8.5 7.2 6 5.4 4.4 6C3 6.5 3.2 8.7 5.1 9.7C6.7 10.5 8.7 10.3 10.2 9.7Z M12 10.2C13.8 6.1 17.5 2.5 20.7 3.5C23.6 4.4 23.3 9.7 19.9 11.4C17.4 12.7 14.2 12 12 11.2ZM13.8 9.7C15.5 7.2 18 5.4 19.6 6C21 6.5 20.8 8.7 18.9 9.7C17.3 10.5 15.3 10.3 13.8 9.7Z"/><path d="M11 12.2L6.2 21.6L9.1 20.9L10.5 23.2L12.9 13Z"/><path d="M13 12.2L17.8 21.6L14.9 20.9L13.5 23.2L11.1 13Z"/><rect x="9.9" y="8.3" width="4.2" height="4.3" rx="1.5"/>`, cheio: true },
  "whatsapp-cor": { nome: "WhatsApp (cores)", desenho: `<circle cx="12" cy="12" r="12" fill="#25D366"/><g transform="translate(12 12) scale(0.66) translate(-12 -12)"><path fill="#FFFFFF" d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></g>`, cheio: true, cores: true },
  "instagram-cor": { nome: "Instagram (cores)", desenho: `<defs><radialGradient id="ktalogu-ig" cx="0.3" cy="1.07" r="1.3"><stop offset="0" stop-color="#FDF497"/><stop offset="0.05" stop-color="#FDF497"/><stop offset="0.45" stop-color="#FD5949"/><stop offset="0.6" stop-color="#D6249F"/><stop offset="0.9" stop-color="#285AEB"/></radialGradient></defs><rect x="0" y="0" width="24" height="24" rx="6.5" fill="url(#ktalogu-ig)"/><g transform="translate(12 12) scale(0.66) translate(-12 -12)"><path fill="#FFFFFF" d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 3.678c-3.405 0-6.162 2.76-6.162 6.162 0 3.405 2.76 6.162 6.162 6.162 3.405 0 6.162-2.76 6.162-6.162 0-3.405-2.76-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405c0 .795-.646 1.44-1.44 1.44-.795 0-1.44-.646-1.44-1.44 0-.794.646-1.439 1.44-1.439.793-.001 1.44.645 1.44 1.439z"/></g>`, cheio: true, cores: true },
  "facebook-cor": { nome: "Facebook (cores)", desenho: `<circle cx="12" cy="12" r="11.4" fill="#FFFFFF"/><path fill="#0866FF" d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/>`, cheio: true, cores: true },
  "youtube-cor": { nome: "YouTube (cores)", desenho: `<path fill="#FFFFFF" d="M8.5 7.5h8v9h-8z"/><path fill="#FF0000" d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>`, cheio: true, cores: true },
  "tiktok-cor": { nome: "TikTok (cores)", desenho: `<rect x="0" y="0" width="24" height="24" rx="6" fill="#010101"/><g transform="translate(12 12) scale(0.62) translate(-12 -12)"><path fill="#25F4EE" transform="translate(-0.9 -0.7)" d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/><path fill="#FE2C55" transform="translate(0.9 0.7)" d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/><path fill="#FFFFFF" d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></g>`, cheio: true, cores: true },
  "linkedin-cor": { nome: "LinkedIn (cores)", desenho: `<rect x="1" y="1" width="22" height="22" rx="2" fill="#FFFFFF"/><path fill="#0A66C2" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>`, cheio: true, cores: true },
  "estrela-4": { nome: "Brilho de 4 pontas", desenho: `<path d="M12 2c.7 5.4 3 7.6 10 10-7 2.4-9.3 4.6-10 10-.7-5.4-3-7.6-10-10 7-2.4 9.3-4.6 10-10z"/>` },
  "seta-rabisco": { nome: "Seta rabiscada", desenho: `<path d="M2.6 19.4c3.2-6.8 7.6-9.4 9.6-6.6 1.9 2.6-1.7 5-2.9 2.3-1.3-3.1 3.8-8.6 10.6-8.4"/><path d="m16.6 3.9 3.5 2.8-2.9 3.4"/>` },
  livro: { nome: "Livro", desenho: `<path d="M3 5.6c3-1.6 6-1.6 9 .4v14c-3-2-6-2-9-.4zM21 5.6c-3-1.6-6-1.6-9 .4v14c3-2 6-2 9-.4z"/>` },
  capelo: { nome: "Formatura", desenho: `<path d="M2 9.5 12 5l10 4.5-10 4.5z"/><path d="M6 11.3v4.6c3.5 2.6 8.5 2.6 12 0v-4.6M22 9.5v5"/>` },
};

export const CHAVES_DOS_ICONES = Object.keys(ICONES);

export type TexturaDoFundo = {
  tipo: "nenhuma" | "raios" | "pontos" | "listras" | "grao";
  cor: string;
  /* 0 a 1. */
  opacidade: number;
  /* Raios: quantos; listras e pontos: o tamanho (px). */
  quantidade: number;
  tamanho: number;
  /* Onde nascem os raios (0 a 100, em % do banner) e o giro deles / das listras. */
  centroX: number;
  centroY: number;
  giro: number;
};

export type FundoDoBanner = { pintura: Pintura; textura: TexturaDoFundo };

/* Desde a v3 o fundo de verdade é uma peça (a primeira camada) e as texturas também; `fundo` fica só como base por baixo de tudo. */
/* De que modelo pronto a arte nasceu (e com que cor, logo e foto): o "Redimensionar" remonta o modelo no tamanho novo. */
/* `pagina`: num modelo de carrossel, qual folha dele esta página é (0 = a primeira). */
export type OrigemDoModelo = { chave: string; cor: string; logo: string | null; proporcaoDoLogo: number | null; foto: string | null; pagina?: number };

/* `maisPaginas`: as páginas 2 em diante do projeto (carrossel); cada uma tem o próprio tamanho (dá para ter post e story juntos); a página 1 é o próprio documento. */
/* `fontesProprias`: as fontes do kit da marca que a arte conhece (vão junto para o servidor e para quem abrir). */
/* `impressao`: a página é para imprimir, nessa resolução (ppp): medidas em mm, margem de segurança e PDF para gráfica. */
/*
 * `fatias`: carrossel contínuo, uma arte larga que sai em N posts (cada um com
 * largura/N); os elementos atravessam de um para o outro. `vista`: a fatia
 * pronta para imprimir (mostra o pedaço que começa em x de uma arte de
 * larguraTotal); só existe na hora de baixar.
 */
export type DocumentoDoBanner = { versao: 3; largura: number; altura: number; fundo: FundoDoBanner; pecas: Peca[]; modelo?: OrigemDoModelo | null; maisPaginas?: DocumentoDoBanner[]; fontesProprias?: FonteDaMarca[]; impressao?: { dpi: number } | null; fatias?: number; vista?: { x: number; larguraTotal: number } | null; numeracao?: { pagina: number; total: number } | null; animacao?: AnimacaoDaPagina | null; transicao?: TransicaoDaPagina | null; audios?: AudioDoProjeto[] };

/**
 * O número de cada página entre os posts do projeto (o contador "03/06"):
 * cada fatia do carrossel contínuo conta como um post. Vai junto com a página
 * para baixar (o servidor recebe um post por vez e não saberia o total).
 */
export function numeracaoDosPosts(paginas: DocumentoDoBanner[]) {
  const total = paginas.reduce((s, p) => s + (p.fatias && p.fatias >= 2 ? p.fatias : 1), 0);
  let antes = 0;
  return paginas.map((p) => {
    const n = { pagina: antes + 1, total };
    antes += p.fatias && p.fatias >= 2 ? p.fatias : 1;
    return n;
  });
}

/** O carrossel contínuo em posts (cada fatia uma página para baixar); a página comum volta como está. */
export function fatiarCarrossel(doc: DocumentoDoBanner): DocumentoDoBanner[] {
  const n = doc.fatias && doc.fatias >= 2 ? Math.min(10, Math.round(doc.fatias)) : 0;
  if (!n) return [doc];
  const largura = doc.largura / n;
  return Array.from({ length: n }, (_, k) => ({ ...doc, largura: Math.round(largura), fatias: undefined, maisPaginas: undefined, vista: { x: k * largura, larguraTotal: doc.largura }, ...(doc.numeracao ? { numeracao: { pagina: doc.numeracao.pagina + k, total: doc.numeracao.total } } : {}) }));
}

/** Até quantas páginas um projeto guarda. */
export const LIMITE_DE_PAGINAS = 30;

/** As páginas do projeto, uma por uma (cada uma sem `maisPaginas`). */
export function paginasDoDocumento(doc: DocumentoDoBanner): DocumentoDoBanner[] {
  const { maisPaginas, ...primeira } = doc;
  return [primeira, ...(maisPaginas || []).map(({ maisPaginas: _ignorada, ...pagina }) => (void _ignorada, pagina))];
}

/** O projeto guardado: a página 1 com as outras em `maisPaginas` (a galeria mostra a 1). */
export function documentoDasPaginas(paginas: DocumentoDoBanner[]): DocumentoDoBanner {
  const [primeira, ...resto] = paginas.map(({ maisPaginas: _ignorada, ...pagina }) => (void _ignorada, pagina));
  return resto.length ? { ...primeira, maisPaginas: resto } : primeira;
}

/* ============================================================== utilidades */

export function escapar(valor: unknown) {
  return String(valor ?? "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

export function corValida(valor: unknown, reserva: string): string {
  const limpo = String(valor ?? "").trim().replace(/^#/, "").toUpperCase();
  if (/^[0-9A-F]{6}$/.test(limpo)) return `#${limpo}`;
  if (/^[0-9A-F]{3}$/.test(limpo)) return `#${limpo.split("").map((l) => l + l).join("")}`;
  return reserva;
}

function rgb(hex: string) {
  const h = corValida(hex, "#000000").slice(1);
  return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
}

export function rgba(hex: string, alfa: number) {
  const [r, g, b] = rgb(hex);
  return `rgba(${r},${g},${b},${Math.max(0, Math.min(1, alfa))})`;
}

export function luminancia(hex: string) {
  const [r, g, b] = rgb(hex).map((c) => {
    const v = c / 255;
    return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

export function misturar(a: string, b: string, t: number) {
  const x = rgb(a);
  const y = rgb(b);
  return `#${x.map((c, i) => Math.round(c + (y[i] - c) * t).toString(16).padStart(2, "0")).join("").toUpperCase()}`;
}

/** Branco ou quase preto, o que ler melhor sobre a cor. */
export function textoSobre(fundo: string) {
  const l = luminancia(fundo);
  const comBranco = 1.05 / (l + 0.05);
  const comPreto = (l + 0.05) / 0.0635;
  return comBranco >= 3 || comBranco >= comPreto ? "#FFFFFF" : "#141414";
}

/* ===================================================== texto com trechos */

/*
 * O texto com trechos formatados (negrito só numa palavra, outra cor, outro
 * tamanho) é HTML curto. Entra só o que está aqui: <b>, <i>, <u>, <s>, <br> e
 * <span> com cor, peso, estilo, decoração, tamanho (px) e fonte da lista.
 * Qualquer outra marca some (o texto dela fica, escapado). Roda sem DOM: o
 * mesmo limpador serve ao editor e ao servidor.
 */

const MARCAS_SIMPLES: Record<string, string> = { b: "b", strong: "b", i: "i", em: "i", u: "u", s: "s", strike: "s", del: "s" };

function corDoTrecho(valor: string): string | null {
  const v = valor.trim().toLowerCase();
  const rgbX = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*[\d.]+)?\s*\)$/.exec(v);
  if (rgbX) return `#${rgbX.slice(1, 4).map((c) => Math.min(255, Number(c)).toString(16).padStart(2, "0")).join("").toUpperCase()}`;
  const hex = corValida(v, "");
  return hex || null;
}

/* Cada propriedade sai uma vez só e sempre na mesma ordem: limpar de novo dá o mesmo resultado. */
function estiloDoTrecho(bruto: string): string {
  const v: { cor?: string; contorno?: string; peso?: string; estilo?: string; decoracao?: string; tamanho?: string; familia?: string; recursos?: string } = {};
  for (const declaracao of bruto.split(";")) {
    const i = declaracao.indexOf(":");
    if (i < 0) continue;
    const nome = declaracao.slice(0, i).trim().toLowerCase();
    const valor = declaracao.slice(i + 1).trim().replace(/!important/i, "").trim();
    if (nome === "color" || (nome === "-webkit-text-fill-color" && !v.cor)) {
      const cor = valor.toLowerCase() === "transparent" ? "transparent" : corDoTrecho(valor);
      if (cor) v.cor = cor;
    } else if (nome === "-webkit-text-stroke") {
      /* Contorno só no trecho (letra vazada): grossura em px e cor. */
      const contorno = /^([\d.]+)px\s+(.+)$/.exec(valor);
      const cor = contorno ? corDoTrecho(contorno[2]) : null;
      if (contorno && cor) v.contorno = `${Math.min(60, Math.max(0.5, Number(contorno[1])))}px ${cor}`;
    } else if (nome === "font-weight") {
      const peso = valor === "bold" ? 800 : valor === "normal" ? 400 : Number(valor);
      if (Number.isFinite(peso) && peso >= 100 && peso <= 900) v.peso = String(Math.round(peso / 100) * 100);
    } else if (nome === "font-style") {
      if (valor === "italic" || valor === "normal") v.estilo = valor;
    } else if (nome === "text-decoration" || nome === "text-decoration-line") {
      const linhas = valor.split(/\s+/).filter((x) => x === "underline" || x === "line-through" || x === "none");
      if (linhas.length) v.decoracao = linhas.join(" ");
    } else if (nome === "font-size") {
      const tamanho = /^([\d.]+)px$/.exec(valor);
      if (tamanho) v.tamanho = `${Math.min(1200, Math.max(4, Math.round(Number(tamanho[1]) * 10) / 10))}px`;
    } else if (nome === "font-family") {
      const fonte = PELA_FAMILIA.get(valor.split(",")[0].replace(/["']/g, "").trim().toLowerCase());
      if (fonte) v.familia = fonte.familia;
    } else if (nome === "font-feature-settings") {
      /* A letra com floreio (painel Glifos): 'salt' 2, 'ss01' 1... Aspas simples, que vai dentro do style="...". */
      const recursos = [...valor.replace(/&quot;|&#34;/g, '"').replace(/&#39;|&apos;/g, "'").matchAll(/["']([a-z0-9]{4})["']\s*(\d{1,2})?/g)].slice(0, 8).map((m) => `'${m[1]}' ${m[2] ?? 1}`);
      if (recursos.length) v.recursos = recursos.join(",");
    }
  }
  return [
    v.cor ? `color:${v.cor};-webkit-text-fill-color:${v.cor}` : "",
    v.contorno ? `-webkit-text-stroke:${v.contorno};paint-order:stroke fill` : "",
    v.peso ? `font-weight:${v.peso}` : "",
    v.estilo ? `font-style:${v.estilo}` : "",
    v.decoracao ? `text-decoration:${v.decoracao}` : "",
    v.tamanho ? `font-size:${v.tamanho}` : "",
    v.familia ? `font-family:'${v.familia}',sans-serif` : "",
    v.recursos ? `font-feature-settings:${v.recursos}` : "",
  ]
    .filter(Boolean)
    .join(";");
}

function atributo(atributos: string, nome: string) {
  const achado = new RegExp(`${nome}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(atributos);
  return achado ? (achado[2] ?? achado[3] ?? achado[4] ?? "") : "";
}

/* Texto solto entre as marcas: entidade conhecida fica, o resto é escapado. */
function textoDoTrecho(t: string) {
  return t
    .replace(/&(?!(?:amp|lt|gt|quot|#39|#x?[0-9a-f]{1,6}|nbsp);)/gi, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

/** O HTML do texto, conferido marca por marca (sem marca aberta sobrando). */
export function htmlDoTextoLimpo(bruto: unknown): string {
  const s = String(bruto ?? "").slice(0, 12000).replace(/<!--[\s\S]*?-->/g, "").replace(/\r/g, "");
  const saida: string[] = [];
  const pilha: string[] = [];
  const marca = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)([^>]*)>/g;
  let ultimo = 0;
  let achado: RegExpExecArray | null;
  let comecou = false;
  while ((achado = marca.exec(s))) {
    const antes = s.slice(ultimo, achado.index).replace(/\n/g, "");
    if (antes) {
      saida.push(textoDoTrecho(antes));
      comecou = true;
    }
    ultimo = marca.lastIndex;
    const fecha = achado[1] === "/";
    const nome = achado[2].toLowerCase();
    if (nome === "br") {
      saida.push("<br>");
      comecou = true;
      continue;
    }
    /* Blocos do campo editável (div, p) viram quebra de linha. */
    if (nome === "div" || nome === "p") {
      if (!fecha && comecou && saida.at(-1) !== "<br>") saida.push("<br>");
      continue;
    }
    const simples = MARCAS_SIMPLES[nome];
    const normal = simples || (nome === "span" || nome === "font" ? "span" : null);
    if (!normal) continue;
    if (fecha) {
      const i = pilha.lastIndexOf(normal);
      if (i < 0) continue;
      while (pilha.length > i) saida.push(`</${pilha.pop()}>`);
      continue;
    }
    if (pilha.length >= 12) continue;
    if (normal === "span") {
      const cor = nome === "font" ? atributo(achado[3], "color") : "";
      const face = nome === "font" ? atributo(achado[3], "face") : "";
      const estilo = estiloDoTrecho([atributo(achado[3], "style"), cor ? `color:${cor}` : "", face ? `font-family:${face}` : ""].join(";"));
      saida.push(estilo ? `<span style="${estilo}">` : "<span>");
    } else saida.push(`<${normal}>`);
    pilha.push(normal);
  }
  const resto = s.slice(ultimo).replace(/\n/g, "");
  if (resto) saida.push(textoDoTrecho(resto));
  while (pilha.length) saida.push(`</${pilha.pop()}>`);
  return saida.join("").replace(/(<br>)+$/, "");
}

/** O texto puro de um HTML de trechos ("\n" no lugar das quebras). */
export function textoDoHtml(html: string) {
  return html
    .replace(/<br\s*\/?>/gi, "\n")
    .replace(/<[^>]*>/g, "")
    .replace(/&nbsp;/g, " ")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&amp;/g, "&");
}

/** Se o HTML tem alguma formatação de trecho (senão o texto puro basta). */
export function temTrechos(html: string | null | undefined) {
  return Boolean(html && /<(b|i|u|s|span)[\s>]/i.test(html));
}

/** As famílias de fonte que os trechos usam (para carregar no servidor). */
export function fontesDosTrechos(html: string | null | undefined): string[] {
  if (!html) return [];
  const lista: string[] = [];
  for (const achado of html.matchAll(/font-family:'([^']+)'/g)) {
    const fonte = PELA_FAMILIA.get(achado[1].toLowerCase());
    if (fonte) lista.push(fonte.chave);
  }
  return lista;
}

export const solida = (cor: string): Pintura => ({ tipo: "solida", cor, cor2: cor, angulo: 135 });
export const degrade = (cor: string, cor2: string, angulo = 135): Pintura => ({ tipo: "linear", cor, cor2, angulo });
export const sombraSuave = (opacidade = 0.3): Sombra => ({ cor: "#000000", x: 0, y: 12, desfoque: 28, opacidade });
export const sombraDura = (opacidade = 0.32): Sombra => ({ cor: "#000000", x: 5, y: 6, desfoque: 0, opacidade });
export const SEM_FILTROS: FiltrosDaImagem = { brilho: 100, contraste: 100, saturacao: 100, desfoque: 0 };

const n = (valor: number) => Math.round(valor * 100) / 100;
const px = (valor: number) => `${n(valor)}px`;

let contador = 0;
export function novoId(prefixo = "p") {
  contador += 1;
  return `${prefixo}${Date.now().toString(36)}${contador.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}

/* =============================================================== desenho */

/** A pintura como CSS (fundo de caixa). */
/* A cor com a transparência da pintura (sem `alfa`, a cor cheia). */
function tom(cor: string, alfa: number | undefined) {
  return alfa === undefined || alfa >= 1 ? cor : rgba(cor, alfa);
}

/** O ângulo do CSS (0 = para cima, sentido horário) que vai do começo ao fim, numa caixa w × h. */
export function anguloDosPontos(pt: PontosDoDegrade, w: number, h: number) {
  const a = (Math.atan2((pt.x2 - pt.x1) * w, -(pt.y2 - pt.y1) * h) * 180) / Math.PI;
  return Math.round(((a % 360) + 360) % 360);
}

/** Os pontos de ponta a ponta de um ângulo (onde o CSS começa e termina o degradê naquela caixa). */
export function pontosDoAngulo(angulo: number, w: number, h: number): PontosDoDegrade {
  const a = (angulo * Math.PI) / 180;
  const [ux, uy] = [Math.sin(a), -Math.cos(a)];
  const metade = (Math.abs(w * ux) + Math.abs(h * uy)) / 2;
  return { x1: 0.5 - (ux * metade) / w, y1: 0.5 - (uy * metade) / h, x2: 0.5 + (ux * metade) / w, y2: 0.5 + (uy * metade) / h };
}

/**
 * O degradê linear do CSS que passa exatamente pelos dois pontos numa caixa
 * w × h: o ângulo sai do vetor e as paradas viram a posição de cada ponto na
 * linha do degradê (que o CSS estica de canto a canto).
 */
export function degradeEntrePontos(pt: PontosDoDegrade, w: number, h: number, cor: string, cor2: string, meio?: number, paradas?: ParadaDoDegrade[]) {
  const angulo = (Math.atan2((pt.x2 - pt.x1) * w, -(pt.y2 - pt.y1) * h) * 180) / Math.PI;
  const a = (angulo * Math.PI) / 180;
  const [ux, uy] = [Math.sin(a), -Math.cos(a)];
  const linha = Math.max(1, Math.abs(w * ux) + Math.abs(h * uy));
  const onde = (x: number, y: number) => (((x - 0.5) * w * ux + (y - 0.5) * h * uy) / linha + 0.5) * 100;
  const [a1, a2] = [onde(pt.x1, pt.y1), onde(pt.x2, pt.y2)];
  return `linear-gradient(${n(angulo)}deg, ${paradasCss(cor, cor2, a1, a2, meio, paradas)})`;
}

/** As paradas do CSS de `de` a `ate` (%): a primeira cor, as do meio (cada uma na sua posição) e a segunda. */
function paradasCss(cor: string, cor2: string, de: number, ate: number, meio?: number, paradas?: ParadaDoDegrade[]) {
  if (!paradas?.length) return `${cor} ${n(de)}%,${dicaDoMeio(meio, de, ate)} ${cor2} ${n(ate)}%`;
  const meios = paradas.map((q) => `${tom(corValida(q.cor, "#000000"), q.alfa)} ${n(de + (ate - de) * q.pos)}%`).join(", ");
  return `${cor} ${n(de)}%, ${meios}, ${cor2} ${n(ate)}%`;
}

/** O raio do radial com pontos, em px da caixa (do centro até a borda). */
function raioDoRadial(pt: PontosDoDegrade, w: number, h: number) {
  return Math.max(1, Math.hypot((pt.x2 - pt.x1) * w, (pt.y2 - pt.y1) * h));
}

/** A "dica de cor" do CSS entre duas paradas (o ponto meio a meio); vazio quando é o meio exato. */
function dicaDoMeio(meio: number | undefined, de = 0, ate = 100) {
  return meio === undefined || Math.abs(meio - 0.5) < 0.005 ? "" : ` ${n(de + (ate - de) * meio)}%,`;
}

/** `caixa`: o tamanho real da peça, para o degradê com pontos (sem ela, vale o ângulo). */
export function cssDaPintura(p: Pintura, caixa?: { w: number; h: number }) {
  const cor = tom(corValida(p.cor, "#000000"), p.alfa);
  const cor2 = tom(corValida(p.cor2, corValida(p.cor, "#000000")), p.alfa2 ?? p.alfa);
  if (p.tipo === "nenhuma") return "transparent";
  if (p.tipo === "linear" && p.pontos && caixa) return degradeEntrePontos(p.pontos, caixa.w, caixa.h, cor, cor2, p.meio, p.paradas);
  if (p.tipo === "linear") return `linear-gradient(${n(p.angulo)}deg, ${paradasCss(cor, cor2, 0, 100, p.meio, p.paradas)})`;
  if (p.tipo === "radial" && p.pontos) {
    const pt = p.pontos;
    /* Com a caixa, um círculo de verdade; sem ela, a elipse na proporção da caixa. */
    const forma = caixa ? `circle ${n(raioDoRadial(pt, caixa.w, caixa.h))}px` : `ellipse ${n(Math.hypot(pt.x2 - pt.x1, pt.y2 - pt.y1) * 100)}% ${n(Math.hypot(pt.x2 - pt.x1, pt.y2 - pt.y1) * 100)}%`;
    return `radial-gradient(${forma} at ${n(pt.x1 * 100)}% ${n(pt.y1 * 100)}%, ${paradasCss(cor, cor2, 0, 100, p.meio, p.paradas)})`;
  }
  /* farthest-side: num círculo, a segunda cor chega exatamente na borda (o brilho some sem marca). */
  if (p.tipo === "radial") return `radial-gradient(circle farthest-side at 50% 50%, ${paradasCss(cor, cor2, 0, 100, p.meio, p.paradas)})`;
  return cor;
}

/** A pintura como `fill` de SVG (com o degradê definido dentro do próprio SVG). */
/** As paradas do degradê no SVG; com o meio fora do centro, paradas no caminho seguindo a mesma curva da "dica" do CSS. */
function paradasSvg(p: Pintura) {
  const [c1, c2] = [corValida(p.cor, "#000000"), corValida(p.cor2, corValida(p.cor, "#000000"))];
  const [a1, a2] = [p.alfa ?? 1, p.alfa2 ?? p.alfa ?? 1];
  if (p.paradas?.length) {
    const meios = p.paradas.map((q) => `<stop offset="${n(q.pos)}" stop-color="${tom(corValida(q.cor, "#000000"), q.alfa)}"/>`).join("");
    return `<stop offset="0" stop-color="${tom(c1, p.alfa)}"/>${meios}<stop offset="1" stop-color="${tom(c2, p.alfa2 ?? p.alfa)}"/>`;
  }
  const meio = p.meio === undefined ? 0.5 : Math.max(0.02, Math.min(0.98, p.meio));
  if (Math.abs(meio - 0.5) < 0.005) return `<stop offset="0" stop-color="${tom(c1, p.alfa)}"/><stop offset="1" stop-color="${tom(c2, p.alfa2 ?? p.alfa)}"/>`;
  const curva = Math.log(0.5) / Math.log(meio);
  return Array.from({ length: 13 }, (_, i) => {
    const t = i / 12;
    const f = t ** curva;
    return `<stop offset="${n(t)}" stop-color="${tom(misturar(c1, c2, f), a1 + (a2 - a1) * f)}"/>`;
  }).join("");
}

function preenchimentoSvg(p: Pintura, id: string) {
  const cor = tom(corValida(p.cor, "#000000"), p.alfa);
  if (p.tipo === "nenhuma") return { defs: "", fill: "none" };
  if (p.tipo === "solida") return { defs: "", fill: cor };
  if (p.tipo === "radial" && p.pontos) {
    const pt = p.pontos;
    return { defs: `<radialGradient id="${id}" cx="${n(pt.x1)}" cy="${n(pt.y1)}" fx="${n(pt.x1)}" fy="${n(pt.y1)}" r="${n(Math.hypot(pt.x2 - pt.x1, pt.y2 - pt.y1))}">${paradasSvg(p)}</radialGradient>`, fill: `url(#${id})` };
  }
  if (p.tipo === "radial") {
    return { defs: `<radialGradient id="${id}" cx="50%" cy="50%" r="50%">${paradasSvg(p)}</radialGradient>`, fill: `url(#${id})` };
  }
  if (p.pontos) {
    const pt = p.pontos;
    return {
      defs: `<linearGradient id="${id}" x1="${n(pt.x1)}" y1="${n(pt.y1)}" x2="${n(pt.x2)}" y2="${n(pt.y2)}">${paradasSvg(p)}</linearGradient>`,
      fill: `url(#${id})`,
    };
  }
  /* O ângulo do CSS (0 = de baixo para cima, sentido horário) vira o vetor do SVG. */
  const a = ((p.angulo - 90) * Math.PI) / 180;
  const x = Math.cos(a) / 2;
  const y = Math.sin(a) / 2;
  return {
    defs: `<linearGradient id="${id}" x1="${n(0.5 - x)}" y1="${n(0.5 - y)}" x2="${n(0.5 + x)}" y2="${n(0.5 + y)}">${paradasSvg(p)}</linearGradient>`,
    fill: `url(#${id})`,
  };
}

/** A sombra como filtro CSS (acompanha o contorno de qualquer forma e de letra em degradê). */
export function filtroDaSombra(s: Sombra) {
  if (!s || s.opacidade <= 0) return "";
  return `drop-shadow(${px(s.x)} ${px(s.y)} ${px(Math.max(0, s.desfoque) / 2)} ${rgba(corValida(s.cor, "#000000"), s.opacidade)})`;
}

function pontosDaEstrela(pontas: number, profundidade: number) {
  const lista: string[] = [];
  const total = Math.max(3, Math.min(48, Math.round(pontas)));
  const dentro = Math.max(0.2, Math.min(0.97, 1 - profundidade));
  for (let i = 0; i < total * 2; i += 1) {
    const r = i % 2 === 0 ? 50 : 50 * dentro;
    const a = (Math.PI * i) / total - Math.PI / 2;
    lista.push(`${(50 + r * Math.cos(a)).toFixed(2)},${(50 + r * Math.sin(a)).toFixed(2)}`);
  }
  return lista.join(" ");
}

/* Sorteio com semente: o mesmo desenho no editor e no servidor, sempre. */
function sorteio(semente: number) {
  let a = Math.round(semente * 7919 + 13) >>> 0 || 1;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Papel rasgado no tamanho real da caixa (o serrilhado não estica): "pontas" escolhe o desenho, "profundidade" o tamanho do rasgo. */
export function papelRasgado(w: number, h: number, pontas: number, profundidade: number) {
  const r = sorteio(pontas);
  const rasgo = Math.max(1.5, Math.min(w, h) * Math.max(0.03, profundidade) * 0.35);
  const pontos: string[] = [];
  const lado = (x0: number, y0: number, x1: number, y1: number, nx: number, ny: number) => {
    const passos = Math.max(3, Math.min(160, Math.round(Math.hypot(x1 - x0, y1 - y0) / (rasgo * 1.3))));
    for (let i = 0; i < passos; i += 1) {
      const t = Math.min(1, Math.max(0, (i + (i ? (r() - 0.5) * 0.7 : 0)) / passos));
      const fundo = rasgo * (0.25 + 0.75 * r());
      pontos.push(`${n(x0 + (x1 - x0) * t + nx * fundo)},${n(y0 + (y1 - y0) * t + ny * fundo)}`);
    }
  };
  lado(0, 0, w, 0, 0, 1);
  lado(w, 0, w, h, -1, 0);
  lado(w, h, 0, h, 0, -1);
  lado(0, h, 0, 0, 1, 0);
  return `<polygon points="${pontos.join(" ")}"/>`;
}

/** A fita ondulada (uma onda inteira de lado a lado; a grossura vem da "profundidade"). */
function fitaOndulada(profundidade: number) {
  const grossura = 12 + Math.max(0.03, Math.min(0.8, profundidade)) * 80;
  const altura = (100 - grossura) / 2 - 1;
  const cima: string[] = [];
  const baixo: string[] = [];
  for (let i = 0; i <= 48; i += 1) {
    const x = (i / 48) * 100;
    const meio = 50 + altura * Math.sin((x / 100) * Math.PI * 2);
    cima.push(`${n(x)},${n(meio - grossura / 2)}`);
    baixo.unshift(`${n(x)},${n(meio + grossura / 2)}`);
  }
  return `<polygon points="${[...cima, ...baixo].join(" ")}"/>`;
}

/** O desenho de uma forma (viewBox 100 × 100, esticado na caixa). */
export function geometria(forma: string, pontas: number, profundidade: number): string {
  switch (forma) {
    case "fita":
      return fitaOndulada(profundidade);
    case "triangulo":
      return `<polygon points="50,0 100,100 0,100"/>`;
    case "losango":
      return `<polygon points="50,0 100,50 50,100 0,50"/>`;
    case "estrela":
      return `<polygon points="${pontosDaEstrela(pontas || 5, profundidade || 0.55)}"/>`;
    case "explosao":
      return `<polygon points="${pontosDaEstrela(pontas || 16, profundidade || 0.2)}"/>`;
    case "etiqueta":
      return `<polygon points="14,0 100,0 100,100 14,100 0,50"/>`;
    case "faixa":
      return `<polygon points="0,0 100,0 94,50 100,100 0,100 6,50"/>`;
    case "seta":
      return `<polygon points="0,30 62,30 62,0 100,50 62,100 62,70 0,70"/>`;
    case "balao":
      return `<path d="M8 0 H92 Q100 0 100 8 V66 Q100 74 92 74 H42 L22 100 L27 74 H8 Q0 74 0 66 V8 Q0 0 8 0 Z"/>`;
    case "coracao":
      return `<path d="M50 96 C20 74 0 56 0 32 C0 14 14 2 29 2 C39 2 46 8 50 16 C54 8 61 2 71 2 C86 2 100 14 100 32 C100 56 80 74 50 96 Z"/>`;
    case "onda":
      return `<path d="M0 30 Q 12.5 0 25 30 T 50 30 T 75 30 T 100 30 L100 100 L0 100 Z"/>`;
    case "hexagono":
      return `<polygon points="25,0 75,0 100,50 75,100 25,100 0,50"/>`;
    case "pentagono":
      return `<polygon points="50,0 100,38 81,100 19,100 0,38"/>`;
    case "octogono":
      return `<polygon points="29,0 71,0 100,29 100,71 71,100 29,100 0,71 0,29"/>`;
    case "paralelogramo":
      return `<polygon points="22,0 100,0 78,100 0,100"/>`;
    case "cruz":
      return `<polygon points="34,0 66,0 66,34 100,34 100,66 66,66 66,100 34,100 34,66 0,66 0,34 34,34"/>`;
    case "raio":
      return `<polygon points="60,0 10,58 44,58 32,100 90,38 56,38 72,0"/>`;
    case "nuvem":
      return `<path d="M24 86 C10 86 0 75 0 62 C0 50 9 41 21 40 C22 25 34 14 49 14 C62 14 73 22 77 34 C90 35 100 46 100 60 C100 74 89 86 75 86 Z"/>`;
    case "gota":
      return `<path d="M50 0 C50 0 88 42 88 64 A38 36 0 0 1 12 64 C12 42 50 0 50 0 Z"/>`;
    case "semicirculo":
      return `<path d="M0 100 A50 100 0 0 1 100 100 Z"/>`;
    case "anel":
      return `<path fill-rule="evenodd" d="M0 50 A50 50 0 1 0 100 50 A50 50 0 1 0 0 50 Z M24 50 A26 26 0 1 0 76 50 A26 26 0 1 0 24 50 Z"/>`;
    default:
      return `<rect x="0" y="0" width="100" height="100"/>`;
  }
}

/** Uma forma pintada (retângulo e círculo em CSS, para os cantos não esticarem; o resto em SVG). */
function desenhoDaForma(opcoes: {
  id: string;
  forma: string;
  pintura: Pintura;
  borda: Borda;
  raio: number;
  pontas: number;
  profundidade: number;
  sombra: Sombra;
  absoluto?: boolean;
  /* O tamanho real da caixa (o papel rasgado desenha nele, para o serrilhado não esticar). */
  w?: number;
  h?: number;
}) {
  const { id, forma, pintura, borda, raio, sombra } = opcoes;
  const posicao = opcoes.absoluto ? "position:absolute;inset:0;" : "";
  const filtro = filtroDaSombra(sombra);
  const filtroCss = filtro ? `filter:${filtro};` : "";
  if (forma === "retangulo" || forma === "circulo" || forma === "pilula") {
    const r = forma === "circulo" ? "50%" : forma === "pilula" ? "9999px" : px(raio);
    const b = borda && borda.largura > 0 ? `border:${px(borda.largura)} solid ${corValida(borda.cor, "#FFFFFF")};` : "";
    const caixa = opcoes.w && opcoes.h ? { w: opcoes.w, h: opcoes.h } : undefined;
    return `<div style="${posicao}width:100%;height:100%;box-sizing:border-box;background:${cssDaPintura(pintura, caixa)};border-radius:${r};${b}${filtroCss}"></div>`;
  }
  const { defs, fill } = preenchimentoSvg(pintura, `g${id}`);
  const traco = borda && borda.largura > 0 ? ` stroke="${corValida(borda.cor, "#FFFFFF")}" stroke-width="${n(borda.largura)}" stroke-linejoin="round" vector-effect="non-scaling-stroke"` : "";
  if (forma === "rasgado") {
    const w = Math.max(4, opcoes.w ?? 100);
    const h = Math.max(4, opcoes.h ?? 100);
    return `<svg viewBox="0 0 ${n(w)} ${n(h)}" preserveAspectRatio="none" style="${posicao}width:100%;height:100%;display:block;overflow:visible;${filtroCss}">${defs ? `<defs>${defs}</defs>` : ""}<g fill="${fill}"${traco}>${papelRasgado(w, h, opcoes.pontas, opcoes.profundidade)}</g></svg>`;
  }
  return `<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="${posicao}width:100%;height:100%;display:block;overflow:visible;${filtroCss}">${defs ? `<defs>${defs}</defs>` : ""}<g fill="${fill}"${traco}>${geometria(forma, opcoes.pontas, opcoes.profundidade)}</g></svg>`;
}

/** Onde a peça fica (o mesmo para o editor e para a imagem final). */
export function caixaDaPeca(p: Peca) {
  const partes: string[] = [];
  if (p.rot) partes.push(`rotate(${n(p.rot)}deg)`);
  return {
    left: px(p.x),
    top: px(p.y),
    width: px(p.w),
    height: px(p.h),
    transform: partes.length ? partes.join(" ") : "none",
    opacity: String(Math.max(0, Math.min(1, p.opacidade))),
    ...(p.mistura && p.mistura !== "normal" ? { mixBlendMode: CSS_DA_MISTURA[p.mistura] } : {}),
  };
}

export function estiloDaCaixa(p: Peca) {
  const c = caixaDaPeca(p);
  return `position:absolute;left:${c.left};top:${c.top};width:${c.width};height:${c.height};transform:${c.transform};opacity:${c.opacity}${c.mixBlendMode ? `;mix-blend-mode:${c.mixBlendMode}` : ""}`;
}

function espelho(p: Peca) {
  const sx = p.espelharX ? -1 : 1;
  const sy = p.espelharY ? -1 : 1;
  return sx === 1 && sy === 1 ? "" : `transform:scale(${sx},${sy});`;
}

/**
 * A transparência em degradê como máscara da peça. A máscara vai numa
 * moldura 3 × maior que a caixa (com a peça no meio), para a sombra e o
 * contorno que passam da caixa seguirem o mesmo sumiço em vez de serem
 * cortados (a máscara recorta tudo o que fica fora dela).
 */
export function mascaraDaPeca(p: Pick<Peca, "w" | "h" | "transparencia">) {
  const t = p.transparencia;
  if (!t) return "";
  const pontos = { x1: (t.x1 + 1) / 3, y1: (t.y1 + 1) / 3, x2: (t.x2 + 1) / 3, y2: (t.y2 + 1) / 3 };
  const [c1, c2] = [`rgba(0,0,0,${n(Math.max(0, Math.min(1, t.alfa)))})`, `rgba(0,0,0,${n(Math.max(0, Math.min(1, t.alfa2)))})`];
  /* Radial: do centro para fora, em círculo; o que passa da borda fica como a segunda ponta. */
  const corte = t.radial
    ? `radial-gradient(circle ${n(raioDoRadial(t, p.w, p.h))}px at ${n(pontos.x1 * 100)}% ${n(pontos.y1 * 100)}%, ${paradasCss(c1, c2, 0, 100, t.meio)})`
    : degradeEntrePontos(pontos, p.w * 3, p.h * 3, c1, c2, t.meio);
  return `-webkit-mask-image:${corte};mask-image:${corte};-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;`;
}

/** O que vai dentro da caixa da peça, como HTML. `src` troca o endereço da imagem (o servidor passa data:). */
export function conteudoDaPeca(p: Peca, src?: (peca: PecaDeImagem) => string | null, recheio?: PecaDeImagem | null): string {
  /* De dentro para fora: o desenho, a perspectiva, os efeitos (seguem o desenho já torto) e o sumiço. */
  const desenho = comEfeitos(p, comDistorcao(p, desenhoDaPeca(p, src, recheio)));
  const mascara = mascaraDaPeca(p);
  if (!mascara || !desenho) return desenho;
  /* A moldura não pega clique (só a peça, no meio dela). */
  return `<div style="position:absolute;left:-100%;top:-100%;width:300%;height:300%;pointer-events:none;${mascara}"><div style="position:absolute;left:33.3333%;top:33.3333%;width:33.3334%;height:33.3334%;pointer-events:auto">${desenho}</div></div>`;
}

/**
 * A perspectiva (os 4 cantos em qualquer lugar) como matrix3d: a homografia
 * do quadrado da caixa para o quadrilátero (Heckbert), em px da caixa.
 */
export function matrizDaDistorcao(d: DistorcaoDaPeca, w: number, h: number) {
  const [[x0, y0], [x1, y1], [x2, y2], [x3, y3]] = d.cantos.map(([x, y]) => [x * w, y * h]);
  const dx1 = x1 - x2;
  const dx2 = x3 - x2;
  const dx3 = x0 - x1 + x2 - x3;
  const dy1 = y1 - y2;
  const dy2 = y3 - y2;
  const dy3 = y0 - y1 + y2 - y3;
  let g = 0;
  let k = 0;
  if (Math.abs(dx3) > 1e-9 || Math.abs(dy3) > 1e-9) {
    const det = dx1 * dy2 - dx2 * dy1 || 1e-9;
    g = (dx3 * dy2 - dx2 * dy3) / det;
    k = (dx1 * dy3 - dx3 * dy1) / det;
  }
  const a = x1 - x0 + g * x1;
  const b = x3 - x0 + k * x3;
  const dd = y1 - y0 + g * y1;
  const e = y3 - y0 + k * y3;
  const W = Math.max(1, w);
  const H = Math.max(1, h);
  const v = [a / W, dd / W, 0, g / W, b / H, e / H, 0, k / H, 0, 0, 1, 0, x0, y0, 0, 1];
  return `matrix3d(${v.map((x) => Math.round(x * 1e6) / 1e6).join(",")})`;
}

function distorcaoAtiva(d: DistorcaoDaPeca | null | undefined): d is DistorcaoDaPeca {
  if (!d) return false;
  const reto: Array<[number, number]> = [[0, 0], [1, 0], [1, 1], [0, 1]];
  return d.cantos.some(([x, y], i) => Math.abs(x - reto[i][0]) > 0.001 || Math.abs(y - reto[i][1]) > 0.001);
}

function comDistorcao(p: Peca, desenho: string) {
  if (!desenho || !distorcaoAtiva(p.distorcao)) return desenho;
  return `<div style="position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:0 0;transform:${matrizDaDistorcao(p.distorcao, p.w, p.h)}">${desenho}</div>`;
}

/** Os efeitos a mais por fora do desenho: sombras e brilhos (drop-shadow, um atrás do outro) e os filtros SVG (sombra interna, contorno). */
function comEfeitos(p: Peca, desenho: string) {
  const lista = (p.efeitos || []).filter((e) => e.opacidade > 0);
  if (!desenho || !lista.length) return desenho;
  const filtros: string[] = [];
  const defs: string[] = [];
  lista.forEach((e, i) => {
    const cor = rgba(corValida(e.cor, "#000000"), Math.max(0, Math.min(1, e.opacidade)));
    if (e.tipo === "sombra") filtros.push(`drop-shadow(${px(e.x)} ${px(e.y)} ${px(Math.max(0, e.desfoque) / 2)} ${cor})`);
    else if (e.tipo === "brilho") {
      /* Brilho por fora: duas camadas sem deslocamento (uma justa, uma larga) para o halo ter corpo. */
      filtros.push(`drop-shadow(0 0 ${px(Math.max(1, e.desfoque) / 4)} ${cor}) drop-shadow(0 0 ${px(Math.max(1, e.desfoque) / 2)} ${cor})`);
    } else if (e.tipo === "sombra-interna") {
      const id = `si${p.id}${i}`;
      defs.push(
        `<filter id="${id}" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"><feComponentTransfer in="SourceAlpha" result="fora"><feFuncA type="table" tableValues="1 0"/></feComponentTransfer><feOffset in="fora" dx="${n(e.x)}" dy="${n(e.y)}" result="andou"/><feGaussianBlur in="andou" stdDeviation="${n(Math.max(0, e.desfoque) / 2)}" result="macio"/><feFlood flood-color="${corValida(e.cor, "#000000")}" flood-opacity="${n(Math.max(0, Math.min(1, e.opacidade)))}"/><feComposite in2="macio" operator="in" result="tinta"/><feComposite in="tinta" in2="SourceAlpha" operator="in" result="dentro"/><feMerge><feMergeNode in="SourceGraphic"/><feMergeNode in="dentro"/></feMerge></filter>`,
      );
      filtros.push(`url(#${id})`);
    } else if (e.tipo === "contorno") {
      const id = `ct${p.id}${i}`;
      const largura = Math.max(0.5, e.largura ?? 4);
      /* Em degradê: a tinta é uma imagem (SVG) com o degradê, recortada pela silhueta engordada. */
      const tinta = e.cor2
        ? `<feImage x="0" y="0" width="${n(Math.max(1, p.w))}" height="${n(Math.max(1, p.h))}" preserveAspectRatio="none" href="${escapar(`data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100" preserveAspectRatio="none"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${corValida(e.cor, "#000000")}"/><stop offset="1" stop-color="${corValida(e.cor2, "#FFFFFF")}"/></linearGradient></defs><rect width="100" height="100" fill="url(#g)"/></svg>`)}`)}" result="tinta"/>`
        : `<feFlood flood-color="${corValida(e.cor, "#000000")}" result="tinta"/>`;
      defs.push(
        `<filter id="${id}" x="-30%" y="-30%" width="160%" height="160%" color-interpolation-filters="sRGB"><feMorphology in="SourceAlpha" operator="dilate" radius="${n(largura)}" result="grosso"/>${tinta}<feComposite in="tinta" in2="grosso" operator="in" result="borda"/><feComponentTransfer in="borda" result="bordaFraca"><feFuncA type="linear" slope="${n(Math.max(0, Math.min(1, e.opacidade)))}"/></feComponentTransfer><feMerge><feMergeNode in="bordaFraca"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`,
      );
      filtros.push(`url(#${id})`);
    }
  });
  const svg = defs.length ? `<svg width="0" height="0" style="position:absolute;width:0;height:0" aria-hidden="true">${defs.join("")}</svg>` : "";
  return `${svg}<div style="position:absolute;left:0;top:0;width:100%;height:100%;filter:${filtros.join(" ")}">${desenho}</div>`;
}

/**
 * A estampa no cilindro: a imagem em fatias verticais; cada fatia vai para
 * onde a volta do cilindro a leva (as das pontas encolhem) e as bordas sobem
 * na curva da boca, como a caneca vista um pouco de cima. Só HTML (o mesmo
 * desenho no palco e no servidor). A imagem ocupa a caixa inteira.
 */
function estampaNoCilindro(endereco: string, w: number, h: number, c: { curva: number; arco: number }, filtro: string) {
  const fatias = 32;
  const volta = Math.max(0.05, Math.min(1, c.curva)) * Math.PI;
  const meia = Math.sin(volta / 2);
  const onde = (u: number) => 0.5 + Math.sin((u - 0.5) * volta) / (2 * meia);
  const raio = w / (2 * meia);
  const sobe = (u: number) => -Math.max(0, Math.min(0.5, c.arco)) * raio * (1 - Math.cos((u - 0.5) * volta));
  const url = escapar(endereco).replace(/'/g, "%27");
  const partes: string[] = [];
  for (let i = 0; i < fatias; i += 1) {
    const u0 = i / fatias;
    const u1 = (i + 1) / fatias;
    const largura = (u1 - u0) * w;
    const escalaX = ((onde(u1) - onde(u0)) * w) / largura;
    partes.push(
      `<div style="position:absolute;top:0;left:${n(onde(u0) * w)}px;width:${n(largura + 2)}px;height:100%;transform-origin:0 0;transform:translateY(${n(sobe((u0 + u1) / 2))}px) scaleX(${n(escalaX * 1000) / 1000});background-image:url('${url}');background-size:${n(w)}px ${n(h)}px;background-position:${n(-u0 * w)}px 0;background-repeat:no-repeat"></div>`,
    );
  }
  /* A sombra das pontas vem da própria foto do produto (a estampa fica em "Multiplicar" sobre ela). */
  return `<div style="position:relative;width:100%;height:100%;${filtro ? `filter:${filtro};` : ""}">${partes.join("")}</div>`;
}

/** Os ajustes novos da foto (exposição, temperatura, níveis, nitidez) como um filtro SVG; vazio quando nenhum foi mexido. */
/* Quanto a máscara de nitidez realça (com o desfoque de 0,5): medido contra a matriz 3×3 de antes, diferença média abaixo de 1 tom em 255. */
const NITIDEZ_SUAVE = 2;

function filtroDosAjustes(f: FiltrosDaImagem, id: string) {
  const exposicao = (f.exposicao ?? 0) / 100;
  const temperatura = (f.temperatura ?? 0) / 100;
  const pretos = (f.pretos ?? 0) / 255;
  const brancos = (f.brancos ?? 255) / 255;
  const gama = f.gama ?? 1;
  const nitidez = (f.nitidez ?? 0) / 100;
  if (!exposicao && !temperatura && !pretos && brancos === 1 && gama === 1 && !nitidez) return "";
  /* Níveis + exposição viram uma reta por canal: saída = (entrada − pretos) / (brancos − pretos) × 2^exposição. */
  const faixa = Math.max(0.02, brancos - pretos);
  const ganho = 2 ** (exposicao * 1.5) / faixa;
  const canal = (mult: number) => `type="linear" slope="${n(ganho * mult * 1000) / 1000}" intercept="${n(-pretos * ganho * mult * 1000) / 1000}"`;
  const partes = [`<feComponentTransfer><feFuncR ${canal(1 + temperatura * 0.18)}/><feFuncG ${canal(1 + temperatura * 0.03)}/><feFuncB ${canal(1 - temperatura * 0.2)}/></feComponentTransfer>`];
  if (gama !== 1) {
    const g = Math.round((1 / Math.max(0.2, Math.min(3, gama))) * 1000) / 1000;
    partes.push(`<feComponentTransfer><feFuncR type="gamma" exponent="${g}"/><feFuncG type="gamma" exponent="${g}"/><feFuncB type="gamma" exponent="${g}"/></feComponentTransfer>`);
  }
  if (nitidez > 0) {
    /* Máscara de nitidez (a foto mais a diferença para ela borrada): o feConvolveMatrix fazia o mesmo, mas o Chrome
       calcula ele ponto a ponto e uma foto grande levava 30 s para baixar (e travava o palco); o desfoque é rápido. */
    const k = NITIDEZ_SUAVE * nitidez;
    partes.push(`<feComponentTransfer result="base"/><feGaussianBlur in="base" stdDeviation="0.5" result="borrada"/><feComposite in="base" in2="borrada" operator="arithmetic" k1="0" k2="${n((1 + k) * 1000) / 1000}" k3="${n(-k * 1000) / 1000}" k4="0"/>`);
  }
  return `<filter id="${id}" color-interpolation-filters="sRGB">${partes.join("")}</filter>`;
}

/**
 * Os filtros da própria foto, na ordem em que entram: duotone, os ajustes do
 * SVG, os do CSS e por último a cor que tinge. A cor era uma camada por cima da
 * caixa e pintava o retângulo inteiro numa foto sem fundo; como filtro, ela só
 * tinge onde a foto existe. Serve à peça de foto e à foto dentro do texto
 * (lá sem o desfoque, que borraria a borda das letras).
 */
function filtrosDaFoto(p: PecaDeImagem, id: string, comDesfoque = true) {
  const f = p.filtros;
  const defs: string[] = [];
  if (p.duotone) {
    const [e, c] = [rgb(p.duotone.escuro), rgb(p.duotone.claro)];
    const canal = (i: number) => `${n(e[i] / 255)} ${n(c[i] / 255)}`;
    defs.push(`<filter id="duo${id}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values=".2126 .7152 .0722 0 0 .2126 .7152 .0722 0 0 .2126 .7152 .0722 0 0 0 0 0 1 0"/><feComponentTransfer><feFuncR type="table" tableValues="${canal(0)}"/><feFuncG type="table" tableValues="${canal(1)}"/><feFuncB type="table" tableValues="${canal(2)}"/></feComponentTransfer></filter>`);
  }
  if (p.chroma) defs.push(filtroDoChroma(p.chroma, id));
  const ajustes = filtroDosAjustes(f, `aj${id}`);
  if (ajustes) defs.push(ajustes);
  const s = f.sobreposicao && f.sobreposicao.forca > 0 ? f.sobreposicao : null;
  if (s) {
    /* A mistura do feBlend é a mesma conta do mix-blend-mode; o "in" corta pela transparência da foto. */
    defs.push(`<filter id="tg${id}" color-interpolation-filters="sRGB"><feFlood flood-color="${corValida(s.cor, "#FF7A00")}" flood-opacity="${n(Math.min(1, s.forca / 100))}" result="cor"/><feBlend in="cor" in2="SourceGraphic" mode="${CSS_DA_MISTURA[s.mistura] || "soft-light"}" result="junta"/><feComposite in="junta" in2="SourceGraphic" operator="in"/></filter>`);
  }
  const lista = [
    p.chroma ? `url(#crm${id})` : "",
    p.duotone ? `url(#duo${id})` : "",
    ajustes ? `url(#aj${id})` : "",
    f.brilho !== 100 ? `brightness(${n(f.brilho / 100)})` : "",
    f.contraste !== 100 ? `contrast(${n(f.contraste / 100)})` : "",
    f.saturacao !== 100 ? `saturate(${n(f.saturacao / 100)})` : "",
    comDesfoque && f.desfoque > 0 ? `blur(${px(f.desfoque)})` : "",
    f.matiz ? `hue-rotate(${n(f.matiz)}deg)` : "",
    s ? `url(#tg${id})` : "",
  ]
    .filter(Boolean)
    .join(" ");
  return { defs, lista };
}

/** A vinheta da foto (cantos escuros) como degradê do CSS; vazio sem vinheta. */
function degradeDaVinheta(f: FiltrosDaImagem) {
  return f.vinheta ? `radial-gradient(ellipse 75% 75% at 50% 50%, rgba(0,0,0,0) ${n(70 - f.vinheta * 0.35)}%, rgba(0,0,0,${n(Math.min(0.9, f.vinheta / 100))}) 100%)` : "";
}

/** Onde a peça B fica dentro da caixa da peça A (sem o giro de A): canto de cima e giro que sobra. */
function posicaoDentro(b: Peca, a: Peca) {
  const rad = (-a.rot * Math.PI) / 180;
  const dx = b.x + b.w / 2 - (a.x + a.w / 2);
  const dy = b.y + b.h / 2 - (a.y + a.h / 2);
  const cx = dx * Math.cos(rad) - dy * Math.sin(rad) + a.w / 2;
  const cy = dx * Math.sin(rad) + dy * Math.cos(rad) + a.h / 2;
  return { x: cx - b.w / 2, y: cy - b.h / 2, rot: b.rot - a.rot };
}

/** Uma máscara do CSS a partir de um SVG (só formas, sem fonte nem foto). */
function mascaraDoSvg(svg: string) {
  const url = `data:image/svg+xml,${encodeURIComponent(svg).replace(/'/g, "%27")}`;
  return `-webkit-mask-image:url('${url}');mask-image:url('${url}');-webkit-mask-size:100% 100%;mask-size:100% 100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;`;
}

/** A silhueta da peça A que recorta o que vai dentro dela (CSS da casca); null = A não serve de recipiente. */
export function recorteDoRecipiente(a: Peca, src?: (peca: PecaDeImagem) => string | null): string | null {
  if (a.tipo === "forma") {
    if (a.forma === "retangulo") return `overflow:hidden;border-radius:${px(a.raio)};`;
    if (a.forma === "circulo") return "overflow:hidden;border-radius:50%;";
    if (a.forma === "rasgado") return mascaraDoSvg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(a.w)} ${n(a.h)}" preserveAspectRatio="none">${papelRasgado(a.w, a.h, a.pontas, a.profundidade)}</svg>`);
    return mascaraDoSvg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none">${geometria(a.forma, a.pontas, a.profundidade)}</svg>`);
  }
  if (a.tipo === "traco" && a.fechado) return mascaraDoSvg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none"><path${a.contornos?.length ? ` fill-rule="evenodd"` : ""} d="${caminhoDasCurvas(a.pontos, true, a.suave, 100, 100, n)}${contornosDoTraco(a, 100, 100)}"/></svg>`);
  if (a.tipo === "imagem") {
    /* A foto como máscara: vale a transparência dela (recorte sem fundo, logo em PNG). */
    const endereco = src ? src(a) : a.src;
    if (!endereco) return null;
    const url = escapar(endereco).replace(/'/g, "%27");
    const tamanho = a.ajuste === "conter" ? "contain" : "cover";
    const onde = a.ajuste === "cobrir" && a.enquadre ? `${n(a.enquadre.x)}% ${n(a.enquadre.y)}%` : "50% 50%";
    return `${a.circulo || a.raio ? `overflow:hidden;border-radius:${a.circulo ? "50%" : px(a.raio)};` : ""}-webkit-mask-image:url('${url}');mask-image:url('${url}');-webkit-mask-size:${tamanho};mask-size:${tamanho};-webkit-mask-position:${onde};mask-position:${onde};-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;`;
  }
  return null;
}

/** Quem pode ser recipiente: forma, desenho fechado e foto recortam por dentro; texto aceita foto como recheio. */
export function podeSerRecipiente(a: Peca, b: Peca) {
  if (a.id === b.id) return false;
  if (a.tipo === "texto") return b.tipo === "imagem" && !(a.curva && Math.abs(a.curva) >= 1);
  return recorteDoRecipiente(a) !== null;
}

/**
 * Como a peça aparece na arte (o mesmo no palco, nas miniaturas e no
 * servidor): normal; dentro da casca com a silhueta do recipiente; ou nada
 * (a foto que é recheio de um texto aparece dentro dele). `estilo` vai como
 * objeto (o palco) ou texto (estiloComoTexto).
 */
export type ExibicaoDaPeca = { estilo: Record<string, string>; html: string } | null;
export type ContagemDaPagina = { pagina: number; total: number };

/** O contador das páginas: {pagina} e {total} no texto viram "01" e "05" (o da página onde a peça aparece). */
export function comContagem<T extends Peca>(p: T, contagem?: ContagemDaPagina | null): T {
  if (!contagem || p.tipo !== "texto" || !/\{(pagina|total)\}/.test(p.texto + (p.html || ""))) return p;
  const dois = (v: number) => String(v).padStart(2, "0");
  const trocar = (t: string) => t.replace(/\{pagina\}/g, dois(contagem.pagina)).replace(/\{total\}/g, dois(contagem.total));
  return { ...p, texto: trocar(p.texto), html: p.html ? trocar(p.html) : p.html };
}

export function exibicaoDaPeca(peca: Peca, pecas: Peca[], src?: (peca: PecaDeImagem) => string | null, contagem?: ContagemDaPagina | null): ExibicaoDaPeca {
  const p = comContagem(peca, contagem);
  const recipiente = p.dentroDe ? pecas.find((q) => q.id === p.dentroDe && !q.oculta) : undefined;
  if (recipiente && recipiente.tipo === "texto" && p.tipo === "imagem") return null;
  const recorte = recipiente ? recorteDoRecipiente(recipiente, src) : null;
  if (recipiente && recorte) {
    const local = posicaoDentro(p, recipiente);
    const caixa = caixaDaPeca(recipiente);
    const estilo: Record<string, string> = { left: caixa.left, top: caixa.top, width: caixa.width, height: caixa.height, transform: caixa.transform };
    if (p.mistura && p.mistura !== "normal") estilo.mixBlendMode = CSS_DA_MISTURA[p.mistura];
    const giro = local.rot ? `transform:rotate(${n(local.rot)}deg);` : "";
    return {
      estilo: { ...estilo, ...estiloDoTexto(recorte) },
      html: `<div style="position:absolute;left:${px(local.x)};top:${px(local.y)};width:${px(p.w)};height:${px(p.h)};${giro}opacity:${n(Math.max(0, Math.min(1, p.opacidade)))}">${conteudoDaPeca(p, src)}</div>`,
    };
  }
  const recheio = p.tipo === "texto" ? (pecas.find((q) => q.dentroDe === p.id && q.tipo === "imagem" && !q.oculta) as PecaDeImagem | undefined) : undefined;
  const caixa = caixaDaPeca(p);
  return { estilo: { ...caixa } as Record<string, string>, html: conteudoDaPeca(p, src, recheio) };
}

/** CSS em texto ("a:b;c:d") virando objeto do React (com as chaves em camelCase; as -webkit- viram Webkit). */
function estiloDoTexto(css: string): Record<string, string> {
  const objeto: Record<string, string> = {};
  for (const parte of css.split(/;(?![^(]*\))/)) {
    const i = parte.indexOf(":");
    if (i < 0) continue;
    const chave = parte.slice(0, i).trim().replace(/^-webkit-/, "webkit-").replace(/-([a-z])/g, (_, l: string) => l.toUpperCase()).replace(/^webkit/, "Webkit");
    if (chave) objeto[chave] = parte.slice(i + 1).trim();
  }
  return objeto;
}

/**
 * As peças fixas ("em todas as páginas") iguais em todas: a página aberta
 * manda. Cada fixa dela entra (ou é trocada, no mesmo lugar da pilha) nas
 * outras; a fixa que sumiu dela (apagada ou desafixada) sai das outras.
 */
export function sincronizarFixas(paginas: DocumentoDoBanner[], aberta: number): DocumentoDoBanner[] {
  const mestre = paginas[aberta];
  if (!mestre || paginas.length < 2) return paginas;
  const fixas = mestre.pecas.filter((p) => p.fixa);
  const ids = new Set(fixas.map((p) => p.id));
  const emAlguma = paginas.some((pg, i) => i !== aberta && pg.pecas.some((p) => p.fixa || ids.has(p.id)));
  if (!fixas.length && !emAlguma) return paginas;
  return paginas.map((pg, i) => {
    if (i === aberta) return pg;
    const pecas = pg.pecas.filter((p) => !(p.fixa && !ids.has(p.id))).map((p) => (ids.has(p.id) ? fixas.find((f) => f.id === p.id)! : p));
    for (const f of fixas) if (!pecas.some((p) => p.id === f.id)) pecas.push(f);
    const igual = pecas.length === pg.pecas.length && pecas.every((p, j) => p === pg.pecas[j]);
    return igual ? pg : { ...pg, pecas };
  });
}

/** O objeto de estilo de volta para texto de CSS (para o HTML do servidor). */
export function estiloComoTexto(estilo: Record<string, string>) {
  return Object.entries(estilo)
    .map(([chave, valor]) => `${chave.replace(/^Webkit/, "-webkit-").replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`)}:${valor}`)
    .join(";");
}

/* ---------------------------------------------------------------- traço */

/** A ponta do traço (seta, círculo...) na ponta T, apontando para fora na direção u. */
function pontaDoTraco(tipo: PontaDoTraco, t: [number, number], u: [number, number], esp: number, cor: string) {
  if (tipo === "nada") return "";
  const L = Math.max(10, esp * 3.6);
  const v: [number, number] = [-u[1], u[0]];
  const em = (a: number, b: number) => `${n(t[0] + u[0] * a + v[0] * b)},${n(t[1] + u[1] * a + v[1] * b)}`;
  if (tipo === "seta") return `<polygon points="${em(0, 0)} ${em(-L, L * 0.55)} ${em(-L, -L * 0.55)}" fill="${cor}"/>`;
  if (tipo === "seta-aberta") return `<polyline points="${em(-L, L * 0.6)} ${em(0, 0)} ${em(-L, -L * 0.6)}" fill="none" stroke="${cor}" stroke-width="${n(esp)}" stroke-linecap="round" stroke-linejoin="round"/>`;
  if (tipo === "circulo") return `<circle cx="${n(t[0])}" cy="${n(t[1])}" r="${n(Math.max(4, esp * 1.7))}" fill="${cor}"/>`;
  if (tipo === "barra") return `<line x1="${n(t[0] + v[0] * L * 0.6)}" y1="${n(t[1] + v[1] * L * 0.6)}" x2="${n(t[0] - v[0] * L * 0.6)}" y2="${n(t[1] - v[1] * L * 0.6)}" stroke="${cor}" stroke-width="${n(esp)}" stroke-linecap="round"/>`;
  return `<polygon points="${em(0, 0)} ${em(-L / 2, L * 0.4)} ${em(-L, 0)} ${em(-L / 2, -L * 0.4)}" fill="${cor}"/>`;
}

/** Os outros contornos do desenho fechado, no caminho SVG (cada um começa com M e fecha com Z). */
function contornosDoTraco(p: PecaDeTraco, w: number, h: number) {
  if (!p.fechado || !p.contornos?.length) return "";
  return p.contornos.map((c) => (c.length >= 2 ? ` ${caminhoDasCurvas(c, true, false, w, h, n)}` : "")).join("");
}

/* Quanto o traço recua antes da ponta (para a linha não passar da seta). */
const RECUO_DA_PONTA: Record<PontaDoTraco, number> = { nada: 0, seta: 0.85, "seta-aberta": 0.05, circulo: 0, barra: 0, losango: 0.9 };

function desenhoDoTraco(p: PecaDeTraco) {
  if (p.pontos.length < 2) return "";
  const w = Math.max(1, p.w);
  const h = Math.max(1, p.h);
  const cor = corValida(p.cor, "#141414");
  const esp = p.fechado ? Math.max(0, p.espessura) : Math.max(0.5, p.espessura);
  const L = Math.max(10, esp * 3.6);
  /* Em px, com as alças escritas (a curva automática vira alças, o desenho é o mesmo). */
  const nos = comAlcas(p.pontos, p.fechado, p.suave).map((q) => ({ ...q, x: q.x * w, y: q.y * h, e: q.e ? { x: q.e.x * w, y: q.e.y * h } : q.e, s: q.s ? { x: q.s.x * w, y: q.s.y * h } : q.s }));
  const unit = (a: { x: number; y: number }, b: { x: number; y: number }): [number, number] => {
    const d = Math.hypot(b.x - a.x, b.y - a.y) || 1;
    return [(b.x - a.x) / d, (b.y - a.y) / d];
  };
  const ultimo = nos.length - 1;
  /* A direção das pontas sai da alça (curva) ou do nó vizinho (reto). */
  const uFim = unit(nos[ultimo].e ?? nos[ultimo - 1], nos[ultimo]);
  const uInicio = unit(nos[0].s ?? nos[1], nos[0]);
  const linha = nos.map((q) => ({ ...q }));
  if (!p.fechado) {
    /* A linha recua antes da seta (com a alça junto, a curva não muda de jeito). */
    const recuar = (i: number, u: [number, number], r: number) => {
      const q = linha[i];
      const d = { x: -u[0] * r, y: -u[1] * r };
      linha[i] = { ...q, x: q.x + d.x, y: q.y + d.y, e: q.e ? { x: q.e.x + d.x, y: q.e.y + d.y } : q.e, s: q.s ? { x: q.s.x + d.x, y: q.s.y + d.y } : q.s };
    };
    recuar(ultimo, uFim, RECUO_DA_PONTA[p.fim] * L);
    recuar(0, uInicio, RECUO_DA_PONTA[p.inicio] * L);
  }
  const traco = p.tracejado === "tracejado" ? ` stroke-dasharray="${n(esp * 3)} ${n(esp * 2.2)}"` : p.tracejado === "pontilhado" ? ` stroke-dasharray="0.01 ${n(esp * 2.2)}"` : "";
  const { defs, fill } = p.fechado ? preenchimentoSvg(p.preenchimento, `tr${p.id}`) : { defs: "", fill: "none" };
  const sombra = filtroDaSombra(p.sombra);
  const pontas = p.fechado ? "" : pontaDoTraco(p.inicio, [nos[0].x, nos[0].y], uInicio, esp, cor) + pontaDoTraco(p.fim, [nos[ultimo].x, nos[ultimo].y], uFim, esp, cor);
  const contorno = esp > 0 ? ` stroke="${cor}" stroke-width="${n(esp)}" stroke-linecap="round" stroke-linejoin="round"${traco}` : ` stroke="none"`;
  return `<svg width="100%" height="100%" viewBox="0 0 ${n(w)} ${n(h)}" preserveAspectRatio="none" style="display:block;overflow:visible;${sombra ? `filter:${sombra};` : ""}${espelho(p)}" aria-hidden="true">${defs ? `<defs>${defs}</defs>` : ""}<path d="${caminhoDasCurvas(linha, p.fechado, false, 1, 1, n)}${contornosDoTraco(p, w, h)}" fill="${fill}"${p.fechado && p.contornos?.length ? ` fill-rule="evenodd"` : ""}${contorno}/>${pontas}</svg>`;
}

/**
 * Os pontos (na arte, com as alças também na arte) viram a peça: a caixa justa
 * em volta da curva (com folga para a grossura e a ponta) e tudo em fração dela.
 */
export function caixaDoTraco(pontos: PontoDoTraco[], espessura: number, fechado = false, suave = false) {
  const folga = Math.max(8, espessura * 3);
  const { x0, y0, x1, y1 } = limitesDaCurva(pontos, fechado, suave);
  const x = x0 - folga;
  const y = y0 - folga;
  const w = Math.max(1, x1 + folga - x);
  const h = Math.max(1, y1 + folga - y);
  const fracao = (q: { x: number; y: number }) => ({ x: (q.x - x) / w, y: (q.y - y) / h });
  return {
    x,
    y,
    w,
    h,
    pontos: pontos.map((q) => ({ ...fracao(q), ...(q.e ? { e: fracao(q.e) } : {}), ...(q.s ? { s: fracao(q.s) } : {}), ...(q.no ? { no: q.no } : {}) })),
  };
}

/* -------------------------------------------------------------- QR code */

function desenhoDoQr(p: PecaDeQr) {
  let tamanho = 0;
  let dados: ArrayLike<number> = [];
  try {
    const qr = QRCode.create(p.conteudo || " ", { errorCorrectionLevel: "M" });
    tamanho = qr.modules.size;
    dados = qr.modules.data;
  } catch {
    return "";
  }
  const cor = corValida(p.cor, "#141414");
  const margem = p.fundo ? 2 : 0;
  const partes: string[] = [];
  for (let y = 0; y < tamanho; y += 1) {
    for (let x = 0; x < tamanho; x += 1) {
      if (!dados[y * tamanho + x]) continue;
      partes.push(p.arredondado ? `M${x + 0.5} ${y + 0.08}a.42 .42 0 1 1 0 .84a.42 .42 0 1 1 0-.84` : `M${x} ${y}h1v1h-1z`);
    }
  }
  const lado = tamanho + margem * 2;
  return `<svg width="100%" height="100%" viewBox="${-margem} ${-margem} ${lado} ${lado}" preserveAspectRatio="xMidYMid meet" shape-rendering="${p.arredondado ? "geometricPrecision" : "crispEdges"}" style="display:block" aria-hidden="true">${p.fundo ? `<rect x="${-margem}" y="${-margem}" width="${lado}" height="${lado}" rx="${p.arredondado ? 1.2 : 0}" fill="${corValida(p.fundo, "#FFFFFF")}"/>` : ""}<path d="${partes.join("")}" fill="${cor}"/></svg>`;
}

/* --------------------------------------------------------------- tabela */

function desenhoDaTabela(p: PecaDeTabela) {
  const fonte = fonteDaChave(p.fonte);
  const alinhar = p.alinhamento === "centro" ? "center" : p.alinhamento === "direita" ? "right" : "left";
  const borda = corValida(p.corDaBorda, "#E4E4E7");
  const linhas = p.celulas
    .map((linha, i) => {
      const ehCabecalho = p.cabecalho && i === 0;
      const fundo = ehCabecalho ? corValida(p.corDoCabecalho, "#141414") : i % 2 === (p.cabecalho ? 1 : 0) ? corValida(p.corDaLinha, "#FFFFFF") : corValida(p.corDaLinha2, "#F4F4F5");
      const cor = ehCabecalho ? corValida(p.corDoTextoDoCabecalho, "#FFFFFF") : corValida(p.corDoTexto, "#141414");
      const celulas = linha
        .map((c, j) => `<td style="padding:.35em .6em;background:${fundo};color:${cor};font-weight:${ehCabecalho ? 800 : 500};border-bottom:1px solid ${borda};${j ? `border-left:1px solid ${borda};` : ""}overflow:hidden;overflow-wrap:break-word">${escapar(c).replace(/\n/g, "<br>")}</td>`)
        .join("");
      return `<tr>${celulas}</tr>`;
    })
    .join("");
  return `<div style="width:100%;height:100%;border-radius:${px(p.raio)};overflow:hidden;border:1px solid ${borda};${espelho(p)}"><table style="width:100%;height:100%;border-collapse:collapse;table-layout:fixed;font-family:'${fonte.familia}',sans-serif;font-size:${px(p.tamanho)};line-height:1.25;text-align:${alinhar}">${linhas}</table></div>`;
}

/* -------------------------------------------------------------- gráfico */

function desenhoDoGrafico(p: PecaDeGrafico) {
  const { w, h } = p;
  const dados = p.dados.slice(0, 24);
  if (!dados.length) return "";
  const fonte = fonteDaChave(p.fonte);
  const letra = Math.max(10, Math.min(w, h) * 0.045);
  const corTexto = corValida(p.corDoTexto, "#141414");
  const cores = p.cores.length ? p.cores.map((c) => corValida(c, "#7C3AED")) : ["#7C3AED"];
  const corDe = (i: number) => cores[i % cores.length];
  const valorEm = (v: number) => escapar(`${p.prefixo}${v.toLocaleString("pt-BR", { maximumFractionDigits: 2 })}${p.sufixo}`);
  const texto = (x: number, y: number, conteudo: string, ancora = "middle", peso = 600, tam = letra) => `<text x="${n(x)}" y="${n(y)}" text-anchor="${ancora}" font-size="${n(tam)}" font-weight="${peso}" fill="${corTexto}">${conteudo}</text>`;
  const maior = Math.max(...dados.map((d) => Math.abs(d.valor)), 1);
  const partes: string[] = [];
  if (p.estilo === "colunas") {
    const base = h - letra * 2;
    const topo = p.mostrarValores ? letra * 1.8 : letra * 0.4;
    const passo = w / dados.length;
    dados.forEach((d, i) => {
      const alto = (Math.max(0, d.valor) / maior) * (base - topo);
      const x = i * passo + passo * 0.18;
      partes.push(`<rect x="${n(x)}" y="${n(base - alto)}" width="${n(passo * 0.64)}" height="${n(alto)}" rx="${n(passo * 0.06)}" fill="${corDe(i)}"/>`);
      if (p.mostrarValores) partes.push(texto(x + passo * 0.32, base - alto - letra * 0.45, valorEm(d.valor), "middle", 800));
      partes.push(texto(i * passo + passo / 2, h - letra * 0.5, escapar(d.rotulo)));
    });
  } else if (p.estilo === "barras") {
    const esquerda = w * 0.3;
    const direita = p.mostrarValores ? w * 0.16 : w * 0.02;
    const passo = h / dados.length;
    dados.forEach((d, i) => {
      const largo = (Math.max(0, d.valor) / maior) * (w - esquerda - direita);
      const y = i * passo + passo * 0.18;
      partes.push(`<rect x="${n(esquerda)}" y="${n(y)}" width="${n(largo)}" height="${n(passo * 0.64)}" rx="${n(passo * 0.08)}" fill="${corDe(i)}"/>`);
      partes.push(texto(esquerda - letra * 0.5, y + passo * 0.32 + letra * 0.35, escapar(d.rotulo), "end"));
      if (p.mostrarValores) partes.push(texto(esquerda + largo + letra * 0.4, y + passo * 0.32 + letra * 0.35, valorEm(d.valor), "start", 800));
    });
  } else if (p.estilo === "linha") {
    const base = h - letra * 2;
    const topo = letra * 1.8;
    const passo = dados.length > 1 ? (w - letra * 2) / (dados.length - 1) : 0;
    const pts = dados.map((d, i) => [letra + i * passo, base - (Math.max(0, d.valor) / maior) * (base - topo)] as [number, number]);
    const cor = corDe(0);
    partes.push(`<path d="M${n(pts[0][0])} ${n(base)} L${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(" L")} L${n(pts.at(-1)![0])} ${n(base)}Z" fill="${rgba(cor, 0.16)}"/>`);
    partes.push(`<polyline points="${pts.map(([x, y]) => `${n(x)},${n(y)}`).join(" ")}" fill="none" stroke="${cor}" stroke-width="${n(Math.max(2, letra * 0.22))}" stroke-linejoin="round" stroke-linecap="round"/>`);
    pts.forEach(([x, y], i) => {
      partes.push(`<circle cx="${n(x)}" cy="${n(y)}" r="${n(Math.max(3, letra * 0.3))}" fill="#FFFFFF" stroke="${cor}" stroke-width="${n(Math.max(2, letra * 0.18))}"/>`);
      if (p.mostrarValores) partes.push(texto(x, y - letra * 0.7, valorEm(dados[i].valor), "middle", 800));
      partes.push(texto(x, h - letra * 0.5, escapar(dados[i].rotulo)));
    });
  } else {
    /* pizza e rosca: o círculo à esquerda (ou em cima) e a legenda ao lado */
    const deitado = w >= h * 1.15;
    const raio = (deitado ? Math.min(h, w * 0.55) : Math.min(w, h * 0.62)) / 2 - 2;
    const cx = deitado ? raio + 2 : w / 2;
    const cy = deitado ? h / 2 : raio + 2;
    const total = dados.reduce((s, d) => s + Math.max(0, d.valor), 0) || 1;
    let angulo = -Math.PI / 2;
    dados.forEach((d, i) => {
      const fatia = (Math.max(0, d.valor) / total) * Math.PI * 2;
      const fim = angulo + fatia;
      const [x1, y1, x2, y2] = [cx + raio * Math.cos(angulo), cy + raio * Math.sin(angulo), cx + raio * Math.cos(fim), cy + raio * Math.sin(fim)];
      partes.push(fatia >= Math.PI * 2 - 0.0001 ? `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(raio)}" fill="${corDe(i)}"/>` : `<path d="M${n(cx)} ${n(cy)} L${n(x1)} ${n(y1)} A${n(raio)} ${n(raio)} 0 ${fatia > Math.PI ? 1 : 0} 1 ${n(x2)} ${n(y2)}Z" fill="${corDe(i)}" stroke="#FFFFFF" stroke-width="${n(Math.max(1, raio * 0.012))}"/>`);
      angulo = fim;
    });
    if (p.estilo === "rosca") partes.push(`<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(raio * 0.58)}" fill="#FFFFFF"/>`);
    const x0 = deitado ? cx + raio + letra * 1.4 : letra;
    const y0 = deitado ? cy - (dados.length * letra * 1.7) / 2 + letra : cy + raio + letra * 1.8;
    dados.forEach((d, i) => {
      const y = y0 + i * letra * 1.7;
      partes.push(`<rect x="${n(x0)}" y="${n(y - letra * 0.8)}" width="${n(letra)}" height="${n(letra)}" rx="${n(letra * 0.25)}" fill="${corDe(i)}"/>`);
      const parte = `${Math.round((Math.max(0, d.valor) / total) * 100)}%`;
      partes.push(texto(x0 + letra * 1.5, y, `${escapar(d.rotulo)}${p.mostrarValores ? ` · ${valorEm(d.valor)} (${parte})` : ` · ${parte}`}`, "start", 600));
    });
  }
  return `<svg width="100%" height="100%" viewBox="0 0 ${n(w)} ${n(h)}" preserveAspectRatio="none" style="display:block;overflow:visible;font-family:'${fonte.familia}',sans-serif;${espelho(p)}" aria-hidden="true">${partes.join("")}</svg>`;
}

/* ------------------------------------------------------ efeitos de texto */

/** O estilo pronto da letra como sombras de texto (escalam com o tamanho da letra). */
function sombrasDoEfeito(p: PecaDeTexto) {
  const e = p.efeito;
  if (!e) return "";
  const c = corValida(e.cor, "#141414");
  const t = p.tamanho;
  const f = Math.max(0, Math.min(1, e.forca));
  if (e.tipo === "levantar") return `0 ${px(t * (0.04 + f * 0.06))} ${px(t * (0.12 + f * 0.18))} ${rgba(c, 0.2 + f * 0.35)}`;
  if (e.tipo === "eco") {
    const d = t * (0.025 + f * 0.05);
    return [1, 2, 3].map((i) => `${px(d * i)} ${px(d * i)} 0 ${rgba(c, 0.7 - i * 0.18)}`).join(",");
  }
  if (e.tipo === "sombra-longa") {
    const comprimento = t * (0.25 + f * 0.9);
    const passos = 28;
    return Array.from({ length: passos }, (_, i) => {
      const d = ((i + 1) * comprimento) / passos;
      return `${px(d)} ${px(d)} 0 ${rgba(c, 1 - (i / passos) * 0.85)}`;
    }).join(",");
  }
  if (e.tipo === "neon") {
    const b = 0.5 + f;
    return `0 0 ${px(t * 0.03)} ${rgba("#FFFFFF", 0.9)},0 0 ${px(t * 0.1 * b)} ${c},0 0 ${px(t * 0.22 * b)} ${c},0 0 ${px(t * 0.45 * b)} ${rgba(c, 0.8)}`;
  }
  if (e.tipo === "contorno-duplo") {
    const d = t * (0.03 + f * 0.05);
    const dd = d * 0.71;
    return [`${px(d)} 0`, `${px(-d)} 0`, `0 ${px(d)}`, `0 ${px(-d)}`, `${px(dd)} ${px(dd)}`, `${px(-dd)} ${px(dd)}`, `${px(dd)} ${px(-dd)}`, `${px(-dd)} ${px(-dd)}`].map((o) => `${o} 0 ${c}`).join(",");
  }
  const d = t * (0.02 + f * 0.04);
  return `${px(-d)} 0 0 ${rgba("#00E5FF", 0.9)},${px(d)} 0 0 ${rgba("#FF2D87", 0.9)}`;
}

/** As linhas do texto com o marcador da lista (recuo pendurado: a segunda linha alinha com o texto). */
function textoEmLista(corrido: string, lista: NonNullable<PecaDeTexto["lista"]>, justificar: string) {
  return corrido
    .split("<br>")
    .map((linha, i) => {
      const marca = lista === "numeros" ? `${i + 1}.` : lista === "checks" ? "✓" : "•";
      return `<div style="display:flex;gap:.45em;justify-content:${justificar}"><span style="flex:none;min-width:${lista === "numeros" ? "1.2em" : ".6em"}">${marca}</span><span style="min-width:0">${linha || "&nbsp;"}</span></div>`;
    })
    .join("");
}

function desenhoDaPeca(p: Peca, src?: (peca: PecaDeImagem) => string | null, recheio?: PecaDeImagem | null): string {
  if (p.tipo === "traco") return desenhoDoTraco(p);
  if (p.tipo === "qr") return desenhoDoQr(p);
  if (p.tipo === "tabela") return desenhoDaTabela(p);
  if (p.tipo === "grafico") return desenhoDoGrafico(p);
  if (p.tipo === "forma") {
    const desenho = desenhoDaForma({ id: p.id, forma: p.forma, pintura: p.pintura, borda: p.borda, raio: p.raio, pontas: p.pontas, profundidade: p.profundidade, sombra: p.sombra, w: p.w, h: p.h });
    const virar = espelho(p);
    return virar ? `<div style="width:100%;height:100%;${virar}">${desenho}</div>` : desenho;
  }

  if (p.tipo === "textura") {
    return `<div style="width:100%;height:100%;overflow:hidden;${espelho(p)}">${desenhoDaTextura(p)}</div>`;
  }

  if (p.tipo === "icone") {
    /* O "currentColor" dos ícones da biblioteca grande vira a cor do ícone. */
    const daCasa = p.desenho ? null : ICONES[p.icone];
    const desenho = p.desenho ? p.desenho.replace(/currentColor/g, corValida(p.pintura.cor, "#141414")) : daCasa?.desenho;
    const cheio = Boolean(daCasa?.cheio);
    if (!desenho) return "";
    const { defs, fill } = preenchimentoSvg(p.pintura.tipo === "nenhuma" ? solida(p.pintura.cor) : p.pintura, `ic${p.id}`);
    const sombra = filtroDaSombra(p.sombra);
    return `<svg viewBox="-1 -1 26 26" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;display:block;overflow:visible;${sombra ? `filter:${sombra};` : ""}${espelho(p)}">${defs ? `<defs>${defs}</defs>` : ""}${cheio ? `<g fill="${fill}" stroke="none">${desenho}</g>` : `<g fill="${p.preenchido ? fill : "none"}" stroke="${fill}" stroke-width="${n(Math.max(0.5, Math.min(4, p.espessura)))}" stroke-linecap="round" stroke-linejoin="round">${desenho}</g>`}</svg>`;
  }

  if (p.tipo === "imagem") {
    /* SVG com cor trocada: o próprio desenho com as cores novas (quando o texto dele já chegou). */
    const endereco = p.vetor ? enderecoDoVetor(p, (src ? src(p) : p.src) || p.src) : src ? src(p) : p.src;
    if (!endereco) return "";
    const raio = p.circulo ? "50%" : px(p.raio);
    const borda = p.borda && p.borda.largura > 0 ? `border:${px(p.borda.largura)} solid ${corValida(p.borda.cor, "#FFFFFF")};` : "";
    const fundoDaMoldura = p.borda && p.ajuste === "conter" ? `background:${corValida(p.borda.cor, "#FFFFFF")};` : "";
    const f = p.filtros;
    const sombra = filtroDaSombra(p.sombra);
    /* Duotone, ajustes, cor e adesivo são filtros SVG da própria peça (o mesmo desenho no editor e no Chrome do servidor). */
    const { defs, lista: interno } = filtrosDaFoto(p, p.id);
    if (p.adesivo && p.adesivo.largura > 0) {
      const l = p.adesivo.largura;
      /* O contorno sai do desfoque do recorte com um corte no alfa (não do feMorphology, que custa a grossura vezes a área e levava 20 s no Chrome do servidor numa caneca): o desfoque tem custo fixo e a borda já sai redonda. */
      defs.push(`<filter id="ade${p.id}" x="-40%" y="-40%" width="180%" height="180%" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="${n(Math.max(0.5, l * 0.62))}" result="macio"/><feComponentTransfer in="macio" result="liso"><feFuncA type="linear" slope="70" intercept="-3.7"/></feComponentTransfer><feFlood flood-color="${corValida(p.adesivo.cor, "#FFFFFF")}"/><feComposite in2="liso" operator="in" result="contorno"/><feMerge><feMergeNode in="contorno"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`);
    }
    const externo = [p.adesivo && p.adesivo.largura > 0 ? `url(#ade${p.id})` : "", sombra].filter(Boolean).join(" ");
    let mascara = "";
    if (p.desvanecer && p.desvanecer.tamanho > 0) {
      const para = { baixo: "to bottom", cima: "to top", esquerda: "to left", direita: "to right" }[p.desvanecer.lado] ?? "to bottom";
      const corte = `linear-gradient(${para},#000 ${n((1 - Math.min(1, p.desvanecer.tamanho)) * 100)}%,transparent)`;
      mascara = `-webkit-mask-image:${corte};mask-image:${corte};`;
    }
    /* Foto dentro de uma forma: recorte pela forma (em fração da caixa). */
    const forma = p.mascara && p.mascara !== "retangulo" && !p.circulo ? p.mascara : null;
    if (forma) {
      /* O recorte não aceita grupo (<g>): a escala vai na própria forma (e o furo do anel vira clip-rule). */
      const desenho = (forma === "circulo" ? `<circle cx="50" cy="50" r="50"/>` : geometria(forma, forma === "estrela" ? 5 : 16, forma === "estrela" ? 0.55 : 0.2)).replace(/^<(\w+)/, '<$1 transform="scale(0.01)"').replace("fill-rule=", "clip-rule=");
      defs.push(`<clipPath id="msc${p.id}" clipPathUnits="objectBoundingBox">${desenho}</clipPath>`);
    }
    const recorte = forma ? `clip-path:url(#msc${p.id});` : "";
    /* Enquadre: o ponto da foto no centro e o zoom (só quando a foto preenche a moldura). */
    const e = p.ajuste === "cobrir" && p.enquadre ? p.enquadre : null;
    /* Com espelho, vira a foto já enquadrada pelo meio da moldura: virar em volta do ponto do zoom deixava uma faixa vazia de um lado. */
    const virar = p.espelharX || p.espelharY ? `translate(${n(50 - (e?.x ?? 50))}%,${n(50 - (e?.y ?? 50))}%) scale(${p.espelharX ? -1 : 1},${p.espelharY ? -1 : 1}) translate(${n((e?.x ?? 50) - 50)}%,${n((e?.y ?? 50) - 50)}%) ` : "";
    const enquadre = e ? `object-position:${n(e.x)}% ${n(e.y)}%;transform-origin:${n(e.x)}% ${n(e.y)}%;transform:${virar}scale(${n(Math.max(1, e.zoom))});` : espelho(p);
    /* A vinheta por cima da foto, recortada pela própria foto (sem fundo, os cantos vazios ficam vazios): a camada
       anda com o enquadre da foto e o degradê volta para a caixa (desfaz o zoom; o espelho não muda o degradê, que é simétrico). */
    const degrade = degradeDaVinheta(f);
    let vinheta = "";
    if (degrade && p.cilindro) vinheta = `<div style="position:absolute;inset:0;pointer-events:none;background:${degrade}"></div>`;
    else if (degrade) {
      const url = escapar(endereco).replace(/'/g, "%27");
      const tamanho = p.ajuste === "conter" ? "contain" : "cover";
      const onde = e ? `${n(e.x)}% ${n(e.y)}%` : "50% 50%";
      const volta = e ? `transform-origin:${n(e.x)}% ${n(e.y)}%;transform:scale(${Math.round(10000 / Math.max(1, e.zoom)) / 10000});` : "";
      vinheta = `<div style="position:absolute;inset:0;pointer-events:none;-webkit-mask-image:url('${url}');mask-image:url('${url}');-webkit-mask-size:${tamanho};mask-size:${tamanho};-webkit-mask-position:${onde};mask-position:${onde};-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;${enquadre.replace(/object-position:[^;]*;/, "")}"><div style="position:absolute;inset:0;background:${degrade};${volta}"></div></div>`;
    }
    const svgDosFiltros = defs.length ? `<svg width="0" height="0" style="position:absolute;width:0;height:0" aria-hidden="true">${defs.join("")}</svg>` : "";
    /* O pontilhado da retícula aumenta sem borrar (cada pixel é tinta ou papel). */
    const pixelado = p.tratamento?.meioTom && reticulaEmPixels(p.tratamento.meioTom.estilo) && p.tratamento.feito === p.src ? "image-rendering:pixelated;" : "";
    return `${svgDosFiltros}<div style="width:100%;height:100%;${externo ? `filter:${externo};` : ""}"><div style="position:relative;width:100%;height:100%;box-sizing:border-box;overflow:hidden;border-radius:${forma ? "0" : raio};${forma ? "" : borda}${fundoDaMoldura}${mascara}${recorte}">${p.cilindro ? estampaNoCilindro(endereco, p.w, p.h, p.cilindro, interno) : `<img src="${escapar(endereco)}" alt="" draggable="false" style="width:100%;height:100%;display:block;object-fit:${p.ajuste === "conter" ? "contain" : "cover"};pointer-events:none;${pixelado}${interno ? `filter:${interno};` : ""}${enquadre}">`}${vinheta}</div></div>`;
  }

  /* texto */
  const fonte = fonteDaChave(p.fonte);
  const peso = pesoQueExiste(fonte.chave, p.peso);
  if (p.curva && Math.abs(p.curva) >= 1) return textoEmCurva(p, fonte, peso);
  const alinhar = p.alinhamento === "centro" ? "center" : p.alinhamento === "direita" ? "right" : "left";
  const justificar = p.alinhamento === "centro" ? "center" : p.alinhamento === "direita" ? "flex-end" : "flex-start";
  const vertical = p.vertical === "topo" ? "flex-start" : p.vertical === "base" ? "flex-end" : "center";
  const contorno = p.contorno && p.contorno.largura > 0 ? `-webkit-text-stroke:${px(p.contorno.largura)} ${corValida(p.contorno.cor, "#000000")};paint-order:stroke fill;` : "";
  /* Letra em degradê: o fundo do texto recortado na letra. */
  /* Com marca-texto a letra em degradê não dá (o fundo do trecho cobriria a letra): fica a primeira cor. */
  const tinta =
    p.pintura.tipo === "nenhuma"
      ? "color:transparent;-webkit-text-fill-color:transparent;"
      : p.pintura.tipo === "solida" || p.destaque || p.efeito
      ? `color:${tom(corValida(p.pintura.cor, "#141414"), p.pintura.alfa)};`
      : `background:${cssDaPintura(p.pintura, { w: p.w, h: p.h })};-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;`;
  const sombra = filtroDaSombra(p.sombra);
  const decoracao = [p.riscado ? "line-through" : "", p.sublinhado ? "underline" : ""].filter(Boolean).join(" ");
  let fundo = "";
  let respiro = "0";
  if (p.fundo) {
    const forma = p.fundo.forma;
    fundo = desenhoDaForma({ id: `${p.id}f`, forma, pintura: p.fundo.pintura, borda: p.fundo.borda, raio: p.fundo.raio, pontas: p.fundo.pontas, profundidade: p.fundo.profundidade, sombra: null, absoluto: true, w: p.w, h: p.h });
    respiro =
      forma === "explosao" || forma === "estrela" || forma === "circulo"
        ? "12%"
        : forma === "etiqueta"
          ? "0 6% 0 16%"
          : forma === "faixa"
            ? "0 9%"
            : forma === "balao"
              ? "0 8% 26% 8%"
              : forma === "seta"
                ? "0 34% 0 6%"
                : `0 ${px(Math.min(p.h * 0.5, p.tamanho * 0.9))}`;
  }
  const corrido = apertarPontuacao(p.html && temTrechos(p.html) ? p.html : escapar(p.texto).replace(/\n/g, "<br>"), fonte.chave);
  /* O marca-texto acompanha cada linha (box-decoration-break: clone repete a folga e os cantos na quebra). */
  const comMarca = p.destaque
    ? `<span style="background:${cssDaPintura(p.destaque.pintura)};-webkit-box-decoration-break:clone;box-decoration-break:clone;padding:.04em ${n(p.destaque.respiro)}em .08em;border-radius:${px(p.destaque.raio)}">${corrido}</span>`
    : corrido;
  const linhas = p.lista ? textoEmLista(comMarca, p.lista, justificar) : comMarca;
  const sombrasDaLetra = sombrasDoEfeito(p);
  /* Foto dentro do texto: a foto é o fundo da caixa inteira, recortado nas letras (anda e cresce com a peça da foto). */
  const enderecoDoRecheio = recheio ? (src ? src(recheio) : recheio.src) : null;
  /* Os ajustes da foto (duotone, cor, brilho…) vão como filtro na caixa das letras; a vinheta, como uma camada a mais do fundo. */
  const filtrosDoRecheio = recheio && enderecoDoRecheio ? filtrosDaFoto(recheio, `${p.id}r`, false) : null;
  const recheioCss =
    recheio && enderecoDoRecheio
      ? (() => {
          const local = posicaoDentro(recheio, p);
          const tamanho = `${px(recheio.w)} ${px(recheio.h)}`;
          const onde = `${px(local.x)} ${px(local.y)}`;
          const vinheta = degradeDaVinheta(recheio.filtros);
          const antes = vinheta ? { imagem: `${vinheta},`, tamanho: `${tamanho},`, onde: `${onde},` } : { imagem: "", tamanho: "", onde: "" };
          return `background-image:${antes.imagem}url('${escapar(enderecoDoRecheio).replace(/'/g, "%27")}');background-size:${antes.tamanho}${tamanho};background-position:${antes.onde}${onde};background-repeat:no-repeat;-webkit-background-clip:text;background-clip:text;${filtrosDoRecheio?.lista ? `filter:${filtrosDoRecheio.lista};` : ""}`;
        })()
      : "";
  const svgDoRecheio = filtrosDoRecheio?.defs.length ? `<svg width="0" height="0" style="position:absolute;width:0;height:0" aria-hidden="true">${filtrosDoRecheio.defs.join("")}</svg>` : "";
  const letraDoRecheio = recheioCss ? "color:transparent;-webkit-text-fill-color:transparent;" : tinta;
  return `${svgDoRecheio}<div style="position:relative;width:100%;height:100%;${espelho(p)}${sombra && p.fundo ? `filter:${sombra};` : ""}">${recheioCss ? "" : fundo}<div style="position:relative;width:100%;height:100%;box-sizing:border-box;padding:${respiro};${recheioCss}display:flex;align-items:${vertical};justify-content:${justificar};text-align:${alinhar}"><div data-texto="1" style="max-width:100%;min-width:1px;font-family:'${fonte.familia}',sans-serif;font-size:${px(p.tamanho)};font-weight:${peso};line-height:${n(p.linha)};letter-spacing:${n(p.espaco)}em;${cssDosRecursos(p.recursos) ? `font-feature-settings:${cssDosRecursos(p.recursos)};` : ""}${letraDoRecheio}${p.italico ? "font-style:italic;" : ""}${p.caixaAlta ? "text-transform:uppercase;" : ""}${decoracao ? `text-decoration:${decoracao};text-decoration-thickness:.08em;` : ""}${contorno}${sombra && !p.fundo ? `filter:${sombra};` : ""}${sombrasDaLetra ? `text-shadow:${sombrasDaLetra};` : ""}overflow-wrap:break-word;white-space:normal">${linhas}</div></div></div>`;
}

/*
 * Fontes cujo ponto e vírgula vêm com folga demais dos lados (medido no
 * Chrome: na Plus Jakarta Sans o ponto tem 0,11 em de folga, contra ~0,03 nas
 * outras), e parece "desorganização ." com espaço. A pontuação logo depois de
 * letra chega mais perto, só no desenho (o texto guardado não muda).
 */
const APERTO_DA_PONTUACAO: Record<string, number> = { "plus-jakarta-sans": 0.075, manrope: 0.05, inter: 0.045 };

/** Aproxima a pontuação da letra de antes nas fontes da lista (só nos pedaços de texto: nunca dentro das etiquetas nem das entidades como &amp;). */
export function apertarPontuacao(html: string, fonte: string) {
  const aperto = APERTO_DA_PONTUACAO[fonte];
  if (!aperto) return html;
  const letra = /[\p{L}\p{N}]/u;
  let anterior = "";
  return html
    .split(/(<[^>]*>|&[a-z0-9#]+;)/i)
    .map((pedaco) => {
      if (!pedaco) return pedaco;
      if (pedaco.startsWith("<")) {
        if (/^<br/i.test(pedaco)) anterior = "";
        return pedaco;
      }
      if (/^&[a-z0-9#]+;$/i.test(pedaco)) {
        anterior = "&";
        return pedaco;
      }
      let saida = "";
      let i = 0;
      while (i < pedaco.length) {
        const c = pedaco[i];
        if (/[.,;:!?…]/.test(c) && letra.test(anterior)) {
          let fimDosSinais = i;
          while (fimDosSinais < pedaco.length && /[.,;:!?…]/.test(pedaco[fimDosSinais])) fimDosSinais += 1;
          saida += `<span style="margin-left:-${aperto}em">${pedaco.slice(i, fimDosSinais)}</span>`;
          anterior = pedaco[fimDosSinais - 1];
          i = fimDosSinais;
          continue;
        }
        saida += c;
        anterior = c;
        i += 1;
      }
      return saida;
    })
    .join("");
}

/**
 * O texto em curva (SVG com textPath): o arco cabe na largura da caixa; com
 * curva 100 vira círculo fechado e as letras se espalham pela volta inteira
 * (o selo). Positivo arqueia para cima; negativo, para baixo (sorriso).
 */
function textoEmCurva(p: PecaDeTexto, fonte: Fonte, peso: number) {
  const c = Math.max(-100, Math.min(100, p.curva ?? 0));
  const tam = p.tamanho;
  const angulo = (Math.abs(c) / 100) * Math.PI * 2;
  const cheio = Math.abs(c) >= 99.5;
  const cx = p.w / 2;
  let r: number;
  let cy: number;
  if (angulo < Math.PI) {
    r = Math.max(tam, p.w - tam * 0.4) / (2 * Math.sin(angulo / 2));
    cy = c > 0 ? tam * 0.9 + r : p.h - tam * 0.28 - r;
  } else {
    r = Math.max(tam * 0.6, Math.min(p.w, p.h) / 2 - tam * 0.85);
    cy = p.h / 2;
  }
  /* O caminho é a volta inteira, com o meio dele no topo (para cima) ou embaixo (para baixo): o texto centra ali. */
  const caminho =
    c > 0
      ? `M${n(cx)} ${n(cy + r)}A${n(r)} ${n(r)} 0 1 1 ${n(cx)} ${n(cy - r)}A${n(r)} ${n(r)} 0 1 1 ${n(cx)} ${n(cy + r)}`
      : `M${n(cx)} ${n(cy - r)}A${n(r)} ${n(r)} 0 1 0 ${n(cx)} ${n(cy + r)}A${n(r)} ${n(r)} 0 1 0 ${n(cx)} ${n(cy - r)}`;
  const { defs, fill } = preenchimentoSvg(p.pintura, `tc${p.id}`);
  const contorno = p.contorno && p.contorno.largura > 0 ? ` stroke="${corValida(p.contorno.cor, "#000000")}" stroke-width="${n(p.contorno.largura * 2)}" stroke-linejoin="round" paint-order="stroke fill"` : "";
  const sombra = filtroDaSombra(p.sombra);
  const texto = p.texto.replace(/\s*\n\s*/g, " ");
  const volta = cheio ? ` textLength="${n(2 * Math.PI * r * 0.985)}" lengthAdjust="spacing"` : "";
  const decoracao = [p.riscado ? "line-through" : "", p.sublinhado ? "underline" : ""].filter(Boolean).join(" ");
  return `<svg width="100%" height="100%" style="display:block;overflow:visible;${sombra ? `filter:${sombra};` : ""}${espelho(p)}" aria-hidden="true"><defs><path id="curva${p.id}" d="${caminho}"/>${defs}</defs><text data-texto="1" fill="${fill}"${contorno} style="font-family:'${fonte.familia}',sans-serif;font-size:${px(tam)};font-weight:${peso};letter-spacing:${n(p.espaco)}em;${cssDosRecursos(p.recursos) ? `font-feature-settings:${cssDosRecursos(p.recursos)};` : ""}${p.italico ? "font-style:italic;" : ""}${decoracao ? `text-decoration:${decoracao};` : ""}"><textPath href="#curva${p.id}" startOffset="50%" text-anchor="middle"${volta}>${escapar(p.caixaAlta ? texto.toLocaleUpperCase("pt-BR") : texto)}</textPath></text></svg>`;
}

/** Retícula de verdade: o ponto encolhe na direção do giro (0 = some para cima, 180 = some para baixo). */
function meioTom(t: Pick<PecaDeTextura, "giro" | "w" | "h" | "tamanho">, cor: string) {
  let passo = Math.max(6, t.tamanho || Math.min(t.w, t.h) * 0.035);
  while ((t.w / passo) * (t.h / passo) > 3200) passo *= 1.12;
  const a = (t.giro * Math.PI) / 180;
  const dx = Math.sin(a);
  const dy = -Math.cos(a);
  const metade = (Math.abs(t.w * dx) + Math.abs(t.h * dy)) / 2;
  const bolas: string[] = [];
  for (let linha = 0, y = passo / 2; y < t.h + passo; linha += 1, y += passo * 0.866) {
    for (let x = linha % 2 ? passo : passo / 2; x < t.w + passo; x += passo) {
      const ida = ((x - t.w / 2) * dx + (y - t.h / 2) * dy + metade) / (2 * metade);
      const raio = passo * 0.5 * (1 - Math.min(1, Math.max(0, ida)));
      if (raio > passo * 0.04) bolas.push(`<circle cx="${n(x)}" cy="${n(y)}" r="${n(raio)}"/>`);
    }
  }
  return `<svg width="100%" height="100%" viewBox="0 0 ${n(t.w)} ${n(t.h)}" preserveAspectRatio="none" style="display:block" aria-hidden="true"><g fill="${cor}">${bolas.join("")}</g></svg>`;
}

/** Curvas de nível em volta do centro (quantidade = quantas voltas; giro muda o desenho). */
function topografia(t: Pick<PecaDeTextura, "giro" | "w" | "h" | "tamanho" | "quantidade" | "centroX" | "centroY">, cor: string) {
  const voltas = Math.max(3, Math.min(40, Math.round(t.quantidade)));
  const cx = (t.centroX / 100) * t.w;
  const cy = (t.centroY / 100) * t.h;
  const longe = Math.max(Math.hypot(cx, cy), Math.hypot(t.w - cx, cy), Math.hypot(cx, t.h - cy), Math.hypot(t.w - cx, t.h - cy)) * 1.05;
  const fase = (t.giro * Math.PI) / 180;
  const traco = Math.max(1, t.tamanho ? t.tamanho * 0.08 : Math.min(t.w, t.h) * 0.0028);
  const caminhos: string[] = [];
  for (let i = 1; i <= voltas; i += 1) {
    const base = (longe * i) / voltas;
    const k = base / longe;
    const pontos: [number, number][] = [];
    for (let j = 0; j < 72; j += 1) {
      const th = (j / 72) * Math.PI * 2;
      const ruido = 0.2 * Math.sin(3 * th + fase + k * 2.2) + 0.1 * Math.sin(5 * th + 1.7 + fase * 0.5 - k * 1.6) + 0.05 * Math.sin(7 * th + 0.4 + k * 3);
      const r = base * (1 + ruido);
      pontos.push([cx + r * Math.cos(th), cy + r * Math.sin(th) * 0.86]);
    }
    const meio = (a: [number, number], b: [number, number]) => `${n((a[0] + b[0]) / 2)} ${n((a[1] + b[1]) / 2)}`;
    let d = `M${meio(pontos[71], pontos[0])}`;
    for (let j = 0; j < 72; j += 1) d += `Q${n(pontos[j][0])} ${n(pontos[j][1])} ${meio(pontos[j], pontos[(j + 1) % 72])}`;
    caminhos.push(`<path d="${d}Z"/>`);
  }
  return `<svg width="100%" height="100%" viewBox="0 0 ${n(t.w)} ${n(t.h)}" preserveAspectRatio="none" style="display:block" aria-hidden="true"><g fill="none" stroke="${cor}" stroke-width="${n(traco)}">${caminhos.join("")}</g></svg>`;
}

/** O desenho que repete dentro da peça de textura. */
export function desenhoDaTextura(t: Pick<PecaDeTextura, "id" | "estilo" | "cor" | "quantidade" | "tamanho" | "centroX" | "centroY" | "giro" | "w" | "h" | "velocidade">) {
  const cor = corValida(t.cor, "#FFFFFF");
  /* Partículas: a foto do instante 1,2 s (no vídeo elas se mexem). */
  if (ehParticula(t.estilo)) return svgDasParticulas(particulasNoTempo({ ...t, estilo: t.estilo, cor }, INSTANTE_DA_FOTO), t.w, t.h);
  const u = Math.max(8, t.tamanho || Math.min(t.w, t.h) * 0.06);
  if (t.estilo === "meio-tom") return meioTom(t, cor);
  if (t.estilo === "topografia") return topografia(t, cor);
  if (t.estilo === "grao") {
    return `<svg width="100%" height="100%" style="display:block;mix-blend-mode:overlay" aria-hidden="true"><filter id="grao${t.id}"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter><rect width="100%" height="100%" filter="url(#grao${t.id})"/></svg>`;
  }
  if (t.estilo === "ondas") {
    const a = u * 0.5;
    return `<svg width="100%" height="100%" style="display:block" aria-hidden="true"><defs><pattern id="ondas${t.id}" width="${n(u * 2)}" height="${n(u)}" patternUnits="userSpaceOnUse" patternTransform="rotate(${n(t.giro)})"><path d="M0 ${n(a)} Q ${n(u / 2)} ${n(a - u * 0.35)} ${n(u)} ${n(a)} T ${n(u * 2)} ${n(a)}" fill="none" stroke="${cor}" stroke-width="${n(Math.max(1.5, u * 0.08))}"/></pattern></defs><rect width="100%" height="100%" fill="url(#ondas${t.id})"/></svg>`;
  }
  let fundo = "";
  if (t.estilo === "raios") {
    const passo = 360 / Math.max(4, Math.min(72, Math.round(t.quantidade)));
    fundo = `repeating-conic-gradient(from ${n(t.giro)}deg at ${n(t.centroX)}% ${n(t.centroY)}%, ${cor} 0deg ${n(passo * 0.42)}deg, transparent ${n(passo * 0.42)}deg ${n(passo)}deg)`;
  } else if (t.estilo === "pontos") {
    fundo = `radial-gradient(${cor} ${px(u * 0.16)}, transparent ${px(u * 0.19)}) 0 0/${px(u)} ${px(u)}`;
  } else if (t.estilo === "listras") {
    fundo = `repeating-linear-gradient(${n(t.giro || 135)}deg, ${cor} 0 ${px(u / 2)}, transparent ${px(u / 2)} ${px(u)})`;
  } else if (t.estilo === "xadrez") {
    fundo = `conic-gradient(${cor} 25%, transparent 0 50%, ${cor} 0 75%, transparent 0) 0 0/${px(u)} ${px(u)}`;  } else if (t.estilo === "grade") {
    const fio = px(Math.max(1, u * 0.035));
    fundo = `linear-gradient(${cor} ${fio}, transparent ${fio}) 0 0/${px(u)} ${px(u)}, linear-gradient(90deg, ${cor} ${fio}, transparent ${fio}) 0 0/${px(u)} ${px(u)}`;
  }
  return `<div style="width:100%;height:100%;background:${fundo}${t.giro && t.estilo !== "raios" && t.estilo !== "listras" ? `;transform:rotate(${n(t.giro)}deg) scale(1.5)` : ""}"></div>`;
}

/** O fundo do banner como CSS (pintura e textura). */
export function estiloDoFundo(f: FundoDoBanner, largura: number, altura: number) {
  const base = cssDaPintura(f.pintura);
  const t = f.textura;
  const tinta = rgba(corValida(t.cor, "#FFFFFF"), t.opacidade);
  const u = Math.min(largura, altura);
  let textura = "";
  if (t.tipo === "raios") {
    const passo = 360 / Math.max(4, Math.min(72, Math.round(t.quantidade)));
    textura = `repeating-conic-gradient(from ${n(t.giro)}deg at ${n(t.centroX)}% ${n(t.centroY)}%, ${tinta} 0deg ${n(passo * 0.42)}deg, transparent ${n(passo * 0.42)}deg ${n(passo)}deg), `;
  } else if (t.tipo === "pontos") {
    const lado = Math.max(6, t.tamanho || u * 0.04);
    textura = `radial-gradient(${tinta} ${px(lado * 0.16)}, transparent ${px(lado * 0.19)}) 0 0/${px(lado)} ${px(lado)}, `;
  } else if (t.tipo === "listras") {
    const lado = Math.max(4, t.tamanho || u * 0.03);
    textura = `repeating-linear-gradient(${n(t.giro || 135)}deg, ${tinta} 0 ${px(lado)}, transparent ${px(lado)} ${px(lado * 2)}), `;
  }
  return `background:${textura}${base}`;
}

/** Grão de impressão: desenhado aqui, sem buscar nada de fora. */
function camadaDeGrao(f: FundoDoBanner, largura: number, altura: number) {
  if (f.textura.tipo !== "grao") return "";
  return `<svg width="${largura}" height="${altura}" style="position:absolute;inset:0;opacity:${n(Math.max(0.05, f.textura.opacidade))};mix-blend-mode:overlay;pointer-events:none" aria-hidden="true"><filter id="grao"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter><rect width="100%" height="100%" filter="url(#grao)"/></svg>`;
}

export function camadaDoFundo(f: FundoDoBanner, largura: number, altura: number) {
  return `<div style="position:absolute;inset:0;${estiloDoFundo(f, largura, altura)}"></div>${camadaDeGrao(f, largura, altura)}`;
}

/** O HTML do banner inteiro, no tamanho exato. O servidor fotografa isto. */
/** A peça é o fundo da arte (o retângulo travado do tamanho dela)? Some na arte de fundo transparente. */
export function ehFundoDaArte(p: Peca, largura: number, altura: number) {
  return p.tipo === "forma" && p.nome === "Fundo" && p.x <= 1 && p.y <= 1 && p.w >= largura - 1 && p.h >= altura - 1;
}

export function htmlDoDocumento(doc: DocumentoDoBanner, src: (peca: PecaDeImagem) => string | null, opcoes: { semFundo?: boolean } = {}): string {
  return htmlDasPaginas([doc], src, opcoes);
}

/**
 * Uma ou mais páginas, uma embaixo da outra: cada página é uma seção do
 * tamanho exato dela, com quebra de página entre elas (o PDF sai com uma
 * folha por página, cada folha no tamanho da sua página; a imagem fotografa
 * a primeira).
 */
export function htmlDasPaginas(paginas: DocumentoDoBanner[], src: (peca: PecaDeImagem) => string | null, opcoes: { semFundo?: boolean; arquivoDaFonte?: (f: FonteDaMarca) => string | null } = {}): string {
  const { largura, altura } = paginas[0];
  for (const pagina of paginas) registrarFontesDaMarca(pagina.fontesProprias);
  const fontes = paginas.flatMap((doc) => doc.pecas.flatMap((p) => (p.tipo === "texto" ? [p.fonte, ...fontesDosTrechos(p.html)] : p.tipo === "tabela" || p.tipo === "grafico" ? [p.fonte] : [])));
  const proprias = cssDasFontesDaMarca(fontes.filter((c) => fonteDaChave(c).propria), opcoes.arquivoDaFonte);
  const doGoogle = fontes.filter((c) => !fonteDaChave(c).propria);
  const carregar = [...new Set(fontes.map((c) => fonteDaChave(c)))]
    .map((f) => [...f.pesos.map((peso) => `document.fonts.load("${peso} 40px '${f.familia}'")`), ...(f.italico ? f.pesos.map((peso) => `document.fonts.load("italic ${peso} 40px '${f.familia}'")`) : [])].join(","))
    .join(",");
  const secoes = paginas
    .map((doc, indiceDaPagina) => {
      const larguraDaArte = doc.vista ? doc.vista.larguraTotal : doc.largura;
      const desenhar = (lista: Peca[]) =>
        lista
          .filter((p) => !p.oculta && !(opcoes.semFundo && ehFundoDaArte(p, larguraDaArte, doc.altura)))
          .map((p) => {
            const ex = exibicaoDaPeca(p, doc.pecas, src, doc.numeracao ?? { pagina: indiceDaPagina + 1, total: paginas.length });
            /* data-peca: o vídeo fotografa uma peça (ou um grupo) de cada vez. */
            return ex ? `<div data-peca="${escapar(p.id)}" style="position:absolute;${estiloComoTexto(ex.estilo)}">${ex.html}</div>` : "";
          })
          .join("\n");
      const fundo = opcoes.semFundo ? "" : `<div data-fundo>${camadaDoFundo(doc.fundo, larguraDaArte, doc.altura)}</div>`;
      /* Fatia do carrossel contínuo: a arte inteira andada para a esquerda (o fundo e o que atravessa continuam); as fixas ficam no lugar delas em cada post. */
      const conteudo = doc.vista
        ? `<div style="position:absolute;left:${px(-doc.vista.x)};top:0;width:${px(doc.vista.larguraTotal)};height:${px(doc.altura)}">${fundo}\n${desenhar(doc.pecas.filter((p) => !p.fixa))}</div>\n${desenhar(doc.pecas.filter((p) => p.fixa))}`
        : `${fundo}\n${desenhar(doc.pecas)}`;
      return `<section style="width:${doc.largura}px;height:${doc.altura}px;page:p${doc.largura}x${doc.altura}">${conteudo}</section>`;
    })
    .join("\n");
  /* Uma folha nomeada por tamanho (o Chrome usa a do tamanho de cada seção no PDF). */
  const folhas = [...new Set(paginas.map((d) => `${d.largura}x${d.altura}`))].map((t) => `@page p${t}{size:${t.replace("x", "px ")}px;margin:0}`).join("");
  const maisLarga = Math.max(...paginas.map((d) => d.largura));
  const alturaTotal = paginas.reduce((soma, d) => soma + d.altura, 0);
  return `<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
${doGoogle.length ? `<link rel="stylesheet" href="${linkDasFontes(doGoogle)}">` : ""}
<style>${proprias}@page{size:${largura}px ${altura}px;margin:0}${folhas}*{box-sizing:border-box;margin:0;padding:0}html,body{width:${maisLarga}px;height:${alturaTotal}px;overflow:hidden${opcoes.semFundo ? ";background:transparent" : ""}}body{-webkit-font-smoothing:antialiased;-webkit-print-color-adjust:exact;print-color-adjust:exact}section{position:relative;overflow:hidden;break-after:page}section:last-child{break-after:auto}</style>
</head><body>
${secoes}
<script>(async function(){try{await Promise.race([Promise.all([${carregar}]),new Promise(function(r){setTimeout(r,8000)})]);await document.fonts.ready}catch(e){}var imgs=[].slice.call(document.images);await Promise.all(imgs.map(function(i){return i.complete?0:new Promise(function(r){i.onload=i.onerror=r})}));document.body.setAttribute("data-pronto","1")})();</script>
</body></html>`;
}

/* ============================================================ conferência */

const ENDERECO_DE_IMAGEM = /^https:\/\/[a-z0-9-]+\.supabase\.co\/storage\/v1\/object\/public\/[^\s"'<>]+$/i;

/* Peças por página: o calendário de 12 meses (modelos-calendarios.ts) tem ~420; o editor segue leve com isso. */
export const LIMITE_DE_PECAS = 500;

const numero = (valor: unknown, min: number, max: number, reserva: number) => {
  const v = Number(valor);
  return Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : reserva;
};
const escolha = <T extends string>(valor: unknown, opcoes: readonly T[], reserva: T): T =>
  (opcoes as readonly string[]).includes(String(valor)) ? (valor as T) : reserva;

/** A pintura conferida; aceita o formato antigo (cor + cor2 soltos). */
export function pinturaLimpa(bruto: unknown, antiga: { cor?: unknown; cor2?: unknown; angulo?: unknown } = {}, reserva = "#6D28D9"): Pintura {
  const p = bruto as Record<string, unknown> | null | undefined;
  if (p && typeof p === "object") {
    const cor = corValida(p.cor, reserva);
    const limpa: Pintura = { tipo: escolha(p.tipo, ["solida", "linear", "radial", "nenhuma"] as const, "solida"), cor, cor2: corValida(p.cor2, cor), angulo: numero(p.angulo, -360, 360, 135) };
    /* Transparência só fica guardada quando existe (a pintura cheia continua do tamanho de antes). */
    if (p.alfa !== undefined && numero(p.alfa, 0, 1, 1) < 1) limpa.alfa = numero(p.alfa, 0, 1, 1);
    /* A segunda cor guarda a cobertura sempre que for diferente da primeira: sem ela, o desenho repete a da
       primeira (de 0% para 100% sumia inteiro, porque o 100% não ficava guardado). */
    if (p.alfa2 !== undefined && numero(p.alfa2, 0, 1, 1) !== (limpa.alfa ?? 1)) limpa.alfa2 = numero(p.alfa2, 0, 1, 1);
    const pontos = limpa.tipo === "linear" || limpa.tipo === "radial" ? pontosLimpos(p.pontos) : null;
    if (pontos) limpa.pontos = pontos;
    if (Array.isArray(p.paradas) && (limpa.tipo === "linear" || limpa.tipo === "radial")) {
      const paradas = p.paradas
        .slice(0, 8)
        .map((q) => {
          const x = q as Record<string, unknown>;
          const alfa = numero(x?.alfa, 0, 1, 1);
          return { pos: numero(x?.pos, 0, 1, 0.5), cor: corValida(x?.cor, cor), ...(alfa < 1 ? { alfa } : {}) };
        })
        .sort((a, b) => a.pos - b.pos);
      if (paradas.length) limpa.paradas = paradas;
    }
    const meio = meioLimpo(p.meio);
    if (meio !== undefined && (limpa.tipo === "linear" || limpa.tipo === "radial")) limpa.meio = meio;
    return limpa;
  }
  const cor = corValida(antiga.cor, reserva);
  return antiga.cor2 ? { tipo: "linear", cor, cor2: corValida(antiga.cor2, cor), angulo: numero(antiga.angulo, -360, 360, 135) } : solida(cor);
}

function pontosLimpos(bruto: unknown): PontosDoDegrade | null {
  const x = bruto as Record<string, unknown> | null;
  if (!x || typeof x !== "object") return null;
  const pt = { x1: numero(x.x1, -2, 3, 0.5), y1: numero(x.y1, -2, 3, 0), x2: numero(x.x2, -2, 3, 0.5), y2: numero(x.y2, -2, 3, 1) };
  /* Os dois pontos no mesmo lugar não têm direção. */
  return Math.hypot(pt.x2 - pt.x1, pt.y2 - pt.y1) < 0.005 ? null : pt;
}

/* O meio só fica guardado quando sai do centro. */
function meioLimpo(bruto: unknown): number | undefined {
  if (bruto === undefined || bruto === null) return undefined;
  const meio = numero(bruto, 0.02, 0.98, 0.5);
  return Math.abs(meio - 0.5) < 0.005 ? undefined : meio;
}

function transparenciaLimpa(bruto: unknown): TransparenciaDaPeca | null {
  const pontos = pontosLimpos(bruto);
  if (!pontos) return null;
  const x = bruto as Record<string, unknown>;
  const meio = meioLimpo(x.meio);
  return { ...pontos, alfa: numero(x.alfa, 0, 1, 1), alfa2: numero(x.alfa2, 0, 1, 0), ...(meio !== undefined ? { meio } : {}), ...(x.radial === true ? { radial: true } : {}) };
}

function bordaLimpa(b: unknown): Borda {
  const x = b as Record<string, unknown> | null;
  return x && Number(x.largura) > 0 ? { cor: corValida(x.cor, "#FFFFFF"), largura: numero(x.largura, 0, 200, 0) } : null;
}

/** Aceita o formato antigo ("suave", "dura", true). */
export function sombraLimpa(s: unknown): Sombra {
  if (s === true || s === "suave") return sombraSuave();
  if (s === "dura") return sombraDura();
  const x = s as Record<string, unknown> | null;
  if (!x || typeof x !== "object") return null;
  const opacidade = numero(x.opacidade, 0, 1, 0.3);
  if (opacidade <= 0) return null;
  return { cor: corValida(x.cor, "#000000"), x: numero(x.x, -300, 300, 0), y: numero(x.y, -300, 300, 10), desfoque: numero(x.desfoque, 0, 300, 24), opacidade };
}

function filtrosLimpos(f: unknown): FiltrosDaImagem {
  const x = (f ?? {}) as Record<string, unknown>;
  const limpos: FiltrosDaImagem = { brilho: numero(x.brilho, 0, 300, 100), contraste: numero(x.contraste, 0, 300, 100), saturacao: numero(x.saturacao, 0, 300, 100), desfoque: numero(x.desfoque, 0, 60, 0) };
  /* Os novos só ficam guardados quando saem do neutro. */
  const extra = (chave: "matiz" | "exposicao" | "temperatura" | "pretos" | "brancos" | "gama" | "nitidez" | "vinheta", min: number, max: number, neutro: number) => {
    const v = numero(x[chave], min, max, neutro);
    if (Math.abs(v - neutro) > 0.0001) limpos[chave] = v;
  };
  extra("matiz", -180, 180, 0);
  extra("exposicao", -100, 100, 0);
  extra("temperatura", -100, 100, 0);
  extra("pretos", 0, 200, 0);
  extra("brancos", 55, 255, 255);
  extra("gama", 0.2, 3, 1);
  extra("nitidez", 0, 100, 0);
  extra("vinheta", 0, 100, 0);
  const so = x.sobreposicao as Record<string, unknown> | null | undefined;
  if (so && typeof so === "object" && numero(so.forca, 0, 100, 0) > 0) limpos.sobreposicao = { cor: corValida(so.cor, "#FF7A00"), forca: numero(so.forca, 0, 100, 30), mistura: escolha(so.mistura, MISTURAS, "luz-suave") };
  return limpos;
}

/** Os efeitos a mais da peça conferidos (até 6). */
function efeitosLimpos(bruto: unknown): EfeitoDaPeca[] {
  if (!Array.isArray(bruto)) return [];
  return bruto.slice(0, 6).flatMap((q) => {
    const e = q as Record<string, unknown>;
    if (!e || typeof e !== "object") return [];
    const tipo = escolha(e.tipo, ["sombra", "brilho", "sombra-interna", "contorno"] as const, "sombra");
    return [
      {
        tipo,
        cor: corValida(e.cor, tipo === "brilho" ? "#FFE066" : "#000000"),
        ...(tipo === "contorno" && e.cor2 ? { cor2: corValida(e.cor2, "#FFFFFF") } : {}),
        x: numero(e.x, -300, 300, 0),
        y: numero(e.y, -300, 300, tipo === "sombra" ? 10 : 0),
        desfoque: numero(e.desfoque, 0, 300, 20),
        opacidade: numero(e.opacidade, 0, 1, 0.5),
        ...(tipo === "contorno" ? { largura: numero(e.largura, 0.5, 80, 4) } : {}),
      },
    ];
  });
}

function distorcaoLimpa(bruto: unknown): DistorcaoDaPeca | null {
  const d = bruto as Record<string, unknown> | null;
  if (!d || !Array.isArray(d.cantos) || d.cantos.length !== 4) return null;
  const cantos = d.cantos.map((c, i) => {
    const par = Array.isArray(c) ? c : [];
    const reto = [[0, 0], [1, 0], [1, 1], [0, 1]][i];
    return [numero(par[0], -60, 61, reto[0]), numero(par[1], -60, 61, reto[1])] as [number, number];
  }) as DistorcaoDaPeca["cantos"];
  const giro = Array.isArray(d.giro3d) && d.giro3d.length === 2 ? ([numero(d.giro3d[0], -80, 80, 0), numero(d.giro3d[1], -80, 80, 0)] as [number, number]) : null;
  const limpa: DistorcaoDaPeca = { cantos, ...(giro && (giro[0] || giro[1]) ? { giro3d: giro } : {}) };
  return distorcaoAtiva(limpa) ? limpa : null;
}

function texturaLimpa(f: Record<string, unknown>): TexturaDoFundo {
  const t = f.textura;
  /* Formato antigo: textura era só o nome, com corDaTextura ao lado. */
  if (typeof t === "string" || !t) {
    return { tipo: escolha(t, ["nenhuma", "raios", "pontos", "listras", "grao"] as const, "nenhuma"), cor: corValida(f.corDaTextura, "#FFFFFF"), opacidade: t === "grao" ? 0.16 : 0.14, quantidade: 20, tamanho: 0, centroX: 70, centroY: 50, giro: 0 };
  }
  const x = t as Record<string, unknown>;
  return {
    tipo: escolha(x.tipo, ["nenhuma", "raios", "pontos", "listras", "grao"] as const, "nenhuma"),
    cor: corValida(x.cor, "#FFFFFF"),
    opacidade: numero(x.opacidade, 0, 1, 0.14),
    quantidade: numero(x.quantidade, 4, 72, 20),
    tamanho: numero(x.tamanho, 0, 400, 0),
    centroX: numero(x.centroX, -50, 150, 70),
    centroY: numero(x.centroY, -50, 150, 50),
    giro: numero(x.giro, -360, 360, 0),
  };
}

function fundoDoTextoLimpo(f: unknown): FundoDoTexto | null {
  const x = f as Record<string, unknown> | null;
  if (!x || typeof x !== "object") return null;
  const forma = escolha(x.forma, FORMAS_DE_FUNDO_DO_TEXTO, "pilula");
  return {
    forma,
    pintura: pinturaLimpa(x.pintura, { cor: x.cor, cor2: x.cor2 }, "#FFD400"),
    borda: bordaLimpa(x.borda),
    raio: numero(x.raio, 0, 500, 12),
    pontas: numero(x.pontas, 3, 48, forma === "estrela" ? 5 : 16),
    profundidade: numero(x.profundidade, 0.03, 0.8, forma === "estrela" ? 0.55 : 0.2),
  };
}

function destaqueLimpo(d: unknown): DestaqueDoTexto | null {
  const x = d as Record<string, unknown> | null;
  if (!x || typeof x !== "object") return null;
  return { pintura: pinturaLimpa(x.pintura, {}, "#FFD400"), respiro: numero(x.respiro, 0, 2, 0.25), raio: numero(x.raio, 0, 200, 0) };
}

const LADOS_DO_SUMICO = ["baixo", "cima", "esquerda", "direita"] as const;

/** Duotone, adesivo e sumiço da imagem: só entram quando existem. */
function efeitosDaImagem(p: Record<string, unknown>): Pick<PecaDeImagem, "duotone" | "adesivo" | "desvanecer"> {
  const saida: Pick<PecaDeImagem, "duotone" | "adesivo" | "desvanecer"> = {};
  const duo = p.duotone as Record<string, unknown> | null;
  if (duo && typeof duo === "object") saida.duotone = { escuro: corValida(duo.escuro, "#3B0A2A"), claro: corValida(duo.claro, "#FFB3D9") };
  const ade = p.adesivo as Record<string, unknown> | null;
  if (ade && typeof ade === "object" && Number(ade.largura) > 0) saida.adesivo = { cor: corValida(ade.cor, "#FFFFFF"), largura: numero(ade.largura, 0.5, 120, 10) };
  /* O "sumir aos poucos" antigo (só quatro lados) virou a transparência com alças: quem chama converte (`sumicoComoTransparencia`). */
  return saida;
}

/** O estilo pronto e a lista do texto (só entram quando existem). */
function efeitoELista(p: Record<string, unknown>): Pick<PecaDeTexto, "efeito" | "lista"> {
  const saida: Pick<PecaDeTexto, "efeito" | "lista"> = {};
  const e = p.efeito as Record<string, unknown> | null;
  if (e && typeof e === "object" && (EFEITOS_DO_TEXTO as readonly string[]).includes(String(e.tipo))) {
    saida.efeito = { tipo: e.tipo as EfeitoDoTexto["tipo"], cor: corValida(e.cor, "#141414"), forca: numero(e.forca, 0, 1, 0.5) };
  }
  if (p.lista === "pontos" || p.lista === "numeros" || p.lista === "checks") saida.lista = p.lista;
  return saida;
}

/** A foto na moldura (forma, enquadre, moldura vazia): só entram quando existem. */
function molduraLimpa(p: Record<string, unknown>): Pick<PecaDeImagem, "mascara" | "enquadre" | "moldura"> {
  const saida: Pick<PecaDeImagem, "mascara" | "enquadre" | "moldura"> = {};
  if (typeof p.mascara === "string" && (FORMAS as readonly string[]).includes(p.mascara) && p.mascara !== "retangulo") saida.mascara = p.mascara as Forma;
  const e = p.enquadre as Record<string, unknown> | null;
  if (e && typeof e === "object") saida.enquadre = { zoom: numero(e.zoom, 1, 5, 1), x: numero(e.x, 0, 100, 50), y: numero(e.y, 0, 100, 50) };
  if (p.moldura === true) saida.moldura = true;
  return saida;
}

/**
 * O "sumir aos poucos" da primeira versão (embaixo, em cima, esquerda,
 * direita e quanto some) como transparência em degradê, a que tem alças
 * na peça e vale em qualquer ângulo. O desenho fica o mesmo.
 */
export function sumicoComoTransparencia(bruto: unknown): TransparenciaDaPeca | null {
  const des = bruto as Record<string, unknown> | null;
  if (!des || typeof des !== "object" || !(Number(des.tamanho) > 0)) return null;
  const t = numero(des.tamanho, 0.05, 1, 0.4);
  const lado = escolha(des.lado, LADOS_DO_SUMICO, "baixo");
  const pontos =
    lado === "baixo" ? { x1: 0.5, y1: 1 - t, x2: 0.5, y2: 1 } : lado === "cima" ? { x1: 0.5, y1: t, x2: 0.5, y2: 0 } : lado === "esquerda" ? { x1: t, y1: 0.5, x2: 0, y2: 0.5 } : { x1: 1 - t, y1: 0.5, x2: 1, y2: 0.5 };
  return { ...pontos, alfa: 1, alfa2: 0 };
}

function origemLimpa(bruto: unknown): OrigemDoModelo | null {
  const o = bruto as Record<string, unknown> | null;
  if (!o || typeof o !== "object" || !/^[a-z0-9-]{1,40}$/.test(String(o.chave))) return null;
  const endereco = (v: unknown) => (typeof v === "string" && ENDERECO_DE_IMAGEM.test(v) ? v : null);
  const proporcao = Number(o.proporcaoDoLogo);
  const pagina = Number(o.pagina);
  return {
    chave: String(o.chave),
    cor: corValida(o.cor, "#6D28D9"),
    logo: endereco(o.logo),
    proporcaoDoLogo: Number.isFinite(proporcao) && proporcao > 0 ? Math.min(10, proporcao) : null,
    foto: endereco(o.foto),
    ...(Number.isInteger(pagina) && pagina > 0 && pagina < 20 ? { pagina } : {}),
  };
}

/**
 * O documento que veio do navegador, conferido peça por peça: números dentro
 * dos limites, cor válida, fonte da lista, imagem só do depósito da Ktalogu.
 * O que não serve cai fora (nunca entra texto solto no CSS). Lê também o
 * formato da primeira versão (cor e cor2 soltos, sombra como palavra).
 */
export function documentoLimpo(bruto: unknown, largura: number, altura: number): DocumentoDoBanner {
  const d = (bruto ?? {}) as Record<string, unknown>;
  /* As fontes da marca primeiro: o texto que usa uma delas precisa ser reconhecido. */
  const fontesProprias = (Array.isArray(d.fontesProprias) ? d.fontesProprias.slice(0, 20) : []).map(fonteDaMarcaLimpa).filter((f): f is FonteDaMarca => Boolean(f));
  registrarFontesDaMarca(fontesProprias);
  const f = (d.fundo ?? {}) as Record<string, unknown>;
  const fundo: FundoDoBanner = { pintura: pinturaLimpa(f.pintura, { cor: f.cor, cor2: f.cor2, angulo: f.angulo }), textura: texturaLimpa(f) };
  /* Antes da v3 o fundo era à parte: vira as duas primeiras camadas (cor e textura). */
  const antigo = Number(d.versao) < 3;
  /* A fatia do carrossel mostra um pedaço de uma arte mais larga: as peças valem na arte inteira. */
  const vistaBruta = d.vista as Record<string, unknown> | null | undefined;
  const vista = vistaBruta && typeof vistaBruta === "object" && Number(vistaBruta.larguraTotal) >= largura ? { x: numero(vistaBruta.x, 0, 12000, 0), larguraTotal: numero(vistaBruta.larguraTotal, largura, 12000, largura) } : null;
  const lado = Math.max(vista ? vista.larguraTotal : largura, altura);
  const pecas: Peca[] = [];
  for (const item of (Array.isArray(d.pecas) ? d.pecas : []).slice(0, LIMITE_DE_PECAS)) {
    const p = (item ?? {}) as Record<string, unknown>;
    const base: Base = {
      id: String(p.id ?? "").replace(/[^a-z0-9_-]/gi, "").slice(0, 40) || novoId(),
      nome: typeof p.nome === "string" ? p.nome.slice(0, 40) : undefined,
      x: numero(p.x, -lado, lado * 2, 0),
      y: numero(p.y, -lado, lado * 2, 0),
      w: numero(p.w, 4, lado * 3, 100),
      h: numero(p.h, 4, lado * 3, 100),
      rot: numero(p.rot, -360, 360, 0),
      opacidade: numero(p.opacidade, 0, 1, 1),
      espelharX: p.espelharX === true,
      espelharY: p.espelharY === true,
      oculta: p.oculta === true,
      bloqueada: p.bloqueada === true,
    };
    const transparencia = transparenciaLimpa(p.transparencia) ?? (p.tipo === "imagem" ? sumicoComoTransparencia(p.desvanecer) : null);
    if (transparencia) base.transparencia = transparencia;
    const mistura = escolha(p.mistura, MISTURAS, "normal");
    if (mistura !== "normal") base.mistura = mistura;
    const efeitos = efeitosLimpos(p.efeitos);
    if (efeitos.length) base.efeitos = efeitos;
    const distorcao = distorcaoLimpa(p.distorcao);
    if (distorcao) base.distorcao = distorcao;
    const perspectiva = distorcao ? perspectivaLimpa(p.perspectiva) : null;
    if (perspectiva) base.perspectiva = perspectiva;
    const dentroDe = typeof p.dentroDe === "string" ? p.dentroDe.replace(/[^a-z0-9_-]/gi, "").slice(0, 60) : "";
    if (dentroDe) base.dentroDe = dentroDe;
    if (p.fixa === true) base.fixa = true;
    const grupo = typeof p.grupo === "string" ? p.grupo.replace(/[^a-z0-9_-]/gi, "").slice(0, 40) : "";
    if (grupo) base.grupo = grupo;
    const animacao = animacaoLimpa(p.animacao);
    if (animacao) base.animacao = animacao;
    if (p.tipo === "texto") {
      /* Com trechos formatados, o texto puro sai do próprio HTML limpo (a peneira do servidor lê o que aparece). */
      const html = typeof p.html === "string" && p.html ? htmlDoTextoLimpo(p.html) : "";
      const comTrechos = temTrechos(html);
      pecas.push({
        ...base,
        tipo: "texto",
        texto: (comTrechos ? textoDoHtml(html) : String(p.texto ?? "")).replace(/\r/g, "").slice(0, 2000),
        html: comTrechos ? html : null,
        ...(p.encaixe !== undefined ? { encaixe: numero(p.encaixe, 0.05, 1, 1) } : {}),
        fonte: fonteDaChave(p.fonte).chave,
        tamanho: numero(p.tamanho, 6, 900, 48),
        peso: numero(p.peso, 100, 900, 700),
        pintura: pinturaLimpa(p.pintura, { cor: p.cor }, "#141414"),
        alinhamento: escolha(p.alinhamento, ["esquerda", "centro", "direita"] as const, "esquerda"),
        vertical: escolha(p.vertical, ["topo", "meio", "base"] as const, "meio"),
        italico: p.italico === true,
        caixaAlta: p.caixaAlta === true,
        riscado: p.riscado === true,
        sublinhado: p.sublinhado === true,
        espaco: numero(p.espaco, -0.2, 1, 0),
        linha: numero(p.linha, 0.7, 2.5, 1.1),
        fundo: fundoDoTextoLimpo(p.fundo),
        contorno: bordaLimpa(p.contorno),
        sombra: sombraLimpa(p.sombra),
        ...(Number(p.curva) ? { curva: numero(p.curva, -100, 100, 0) } : {}),
        ...(p.destaque ? { destaque: destaqueLimpo(p.destaque) } : {}),
        ...efeitoELista(p),
        ...(recursosLimpos(p.recursos) ? { recursos: recursosLimpos(p.recursos) } : {}),
      });
    } else if (p.tipo === "forma") {
      const forma = escolha(p.forma, FORMAS, "retangulo");
      pecas.push({
        ...base,
        tipo: "forma",
        forma,
        pintura: pinturaLimpa(p.pintura, { cor: p.cor, cor2: p.cor2, angulo: p.angulo }, "#FFD400"),
        raio: numero(p.raio, 0, 2000, 0),
        borda: bordaLimpa(p.borda),
        sombra: sombraLimpa(p.sombra),
        pontas: numero(p.pontas, 3, 48, forma === "estrela" ? 5 : 16),
        profundidade: numero(p.profundidade, 0.03, 0.8, forma === "estrela" ? 0.55 : 0.2),
      });
    } else if (p.tipo === "textura") {
      pecas.push({
        ...base,
        bloqueada: p.bloqueada === true,
        tipo: "textura",
        estilo: escolha(p.estilo, ESTILOS_DE_TEXTURA, "raios"),
        cor: corValida(p.cor, "#FFFFFF"),
        quantidade: numero(p.quantidade, 4, ehParticula(String(p.estilo)) ? 300 : 72, 20),
        ...(p.velocidade !== undefined && p.velocidade !== null ? { velocidade: numero(p.velocidade, 0.2, 3, 1) } : {}),
        tamanho: numero(p.tamanho, 0, 400, 0),
        centroX: numero(p.centroX, -50, 150, 50),
        centroY: numero(p.centroY, -50, 150, 50),
        giro: numero(p.giro, -360, 360, 0),
      });
    } else if (p.tipo === "icone") {
      const desenho = desenhoDeIconeLimpo(p.desenho);
      if (!ICONES[String(p.icone)] && !desenho) continue;
      pecas.push({
        ...base,
        tipo: "icone",
        icone: String(p.icone).slice(0, 60),
        ...(desenho ? { desenho } : {}),
        pintura: pinturaLimpa(p.pintura, {}, "#141414"),
        espessura: numero(p.espessura, 0.5, 4, 2),
        preenchido: p.preenchido === true,
        sombra: sombraLimpa(p.sombra),
      });
    } else if (p.tipo === "imagem") {
      const endereco = String(p.src ?? "").trim();
      if (!ENDERECO_DE_IMAGEM.test(endereco)) continue;
      pecas.push({
        ...base,
        tipo: "imagem",
        src: endereco,
        papel: p.papel === "logo" ? "logo" : "foto",
        ajuste: p.ajuste === "cobrir" ? "cobrir" : "conter",
        raio: numero(p.raio, 0, 2000, 0),
        circulo: p.circulo === true,
        borda: bordaLimpa(p.borda),
        sombra: sombraLimpa(p.sombra),
        filtros: filtrosLimpos(p.filtros),
        ...efeitosDaImagem(p),
        ...molduraLimpa(p),
        ...(typeof p.semFundoDe === "string" && ENDERECO_DE_IMAGEM.test(p.semFundoDe.trim()) ? { semFundoDe: p.semFundoDe.trim() } : {}),
        ...(videoLimpo(p.video) ? { video: videoLimpo(p.video) } : {}),
        ...(chromaLimpo(p.chroma) ? { chroma: chromaLimpo(p.chroma) } : {}),
        ...(vetorLimpo(p.vetor) ? { vetor: vetorLimpo(p.vetor) } : {}),
        ...(tratamentoLimpo(p.tratamento, (s) => ENDERECO_DE_IMAGEM.test(s)) ? { tratamento: tratamentoLimpo(p.tratamento, (s) => ENDERECO_DE_IMAGEM.test(s)) } : {}),
        ...(p.cilindro && typeof p.cilindro === "object" ? { cilindro: { curva: numero((p.cilindro as Record<string, unknown>).curva, 0.05, 1, 0.55), arco: numero((p.cilindro as Record<string, unknown>).arco, 0, 0.5, 0.08) } } : {}),
        ...(p.corteDoFundo && typeof p.corteDoFundo === "object"
          ? (() => {
              const c = p.corteDoFundo as Record<string, unknown>;
              const corte = { x0: numero(c.x0, 0, 1, 0), y0: numero(c.y0, 0, 1, 0), x1: numero(c.x1, 0, 1, 1), y1: numero(c.y1, 0, 1, 1) };
              return corte.x1 > corte.x0 && corte.y1 > corte.y0 ? { corteDoFundo: corte } : {};
            })()
          : {}),
      });
    } else if (p.tipo === "traco") {
      const alca = (v: unknown) => {
        const a = v as Record<string, unknown> | null | undefined;
        return a && typeof a === "object" ? { x: numero(a.x, -4, 5, 0), y: numero(a.y, -4, 5, 0) } : null;
      };
      const nos = (lista: unknown, ate: number) =>
        (Array.isArray(lista) ? lista.slice(0, ate) : []).map((q) => {
          const x = q as Record<string, unknown>;
          const e = alca(x?.e);
          const sa = alca(x?.s);
          return { x: numero(x?.x, -2, 3, 0), y: numero(x?.y, -2, 3, 0), ...(e ? { e } : {}), ...(sa ? { s: sa } : {}), ...(x?.no === "canto" || x?.no === "suave" ? { no: x.no as "canto" | "suave" } : {}) };
        });
      const pontos = nos(p.pontos, 400);
      if (pontos.length < 2) continue;
      /* Furos e pedaços soltos (Soldar/Aparar): até 60 contornos e 2.000 nós no total. */
      let restam = 2000 - pontos.length;
      const contornos =
        p.fechado === true && Array.isArray(p.contornos)
          ? p.contornos
              .slice(0, 60)
              .map((c) => {
                const lista = nos(c, Math.max(0, Math.min(400, restam)));
                restam -= lista.length;
                return lista;
              })
              .filter((c) => c.length >= 2)
          : [];
      pecas.push({
        ...base,
        tipo: "traco",
        pontos,
        ...(contornos.length ? { contornos } : {}),
        fechado: p.fechado === true,
        suave: p.suave === true,
        cor: corValida(p.cor, "#141414"),
        /* Forma fechada pode ficar sem contorno (0); linha aberta precisa aparecer. */
        espessura: numero(p.espessura, p.fechado === true ? 0 : 0.5, 200, 6),
        tracejado: escolha(p.tracejado, ["solido", "tracejado", "pontilhado"] as const, "solido"),
        inicio: escolha(p.inicio, PONTAS_DO_TRACO, "nada"),
        fim: escolha(p.fim, PONTAS_DO_TRACO, "nada"),
        preenchimento: pinturaLimpa(p.preenchimento, {}, "#FFD400"),
        sombra: sombraLimpa(p.sombra),
      });
    } else if (p.tipo === "qr") {
      pecas.push({ ...base, tipo: "qr", conteudo: String(p.conteudo ?? "").slice(0, 900), cor: corValida(p.cor, "#141414"), fundo: p.fundo === null ? null : corValida(p.fundo, "#FFFFFF"), arredondado: p.arredondado === true });
    } else if (p.tipo === "tabela") {
      const celulas = (Array.isArray(p.celulas) ? p.celulas.slice(0, 30) : [])
        .map((linha) => (Array.isArray(linha) ? linha.slice(0, 12).map((c) => String(c ?? "").slice(0, 300)) : []))
        .filter((linha) => linha.length);
      if (!celulas.length) continue;
      const colunas = Math.max(...celulas.map((l) => l.length));
      pecas.push({
        ...base,
        tipo: "tabela",
        celulas: celulas.map((l) => [...l, ...Array(colunas - l.length).fill("")]),
        fonte: fonteDaChave(p.fonte).chave,
        tamanho: numero(p.tamanho, 6, 300, 28),
        corDoTexto: corValida(p.corDoTexto, "#141414"),
        cabecalho: p.cabecalho !== false,
        corDoCabecalho: corValida(p.corDoCabecalho, "#141414"),
        corDoTextoDoCabecalho: corValida(p.corDoTextoDoCabecalho, "#FFFFFF"),
        corDaLinha: corValida(p.corDaLinha, "#FFFFFF"),
        corDaLinha2: corValida(p.corDaLinha2, "#F4F4F5"),
        corDaBorda: corValida(p.corDaBorda, "#E4E4E7"),
        alinhamento: escolha(p.alinhamento, ["esquerda", "centro", "direita"] as const, "esquerda"),
        raio: numero(p.raio, 0, 400, 12),
      });
    } else if (p.tipo === "grafico") {
      const dados = (Array.isArray(p.dados) ? p.dados.slice(0, 24) : []).map((d) => {
        const x = (d ?? {}) as Record<string, unknown>;
        return { rotulo: String(x.rotulo ?? "").slice(0, 40), valor: numero(x.valor, -1e12, 1e12, 0) };
      });
      if (!dados.length) continue;
      pecas.push({
        ...base,
        tipo: "grafico",
        estilo: escolha(p.estilo, ESTILOS_DE_GRAFICO, "colunas"),
        dados,
        cores: (Array.isArray(p.cores) ? p.cores.slice(0, 12) : []).map((c) => corValida(c, "")).filter(Boolean),
        fonte: fonteDaChave(p.fonte).chave,
        corDoTexto: corValida(p.corDoTexto, "#141414"),
        mostrarValores: p.mostrarValores !== false,
        prefixo: String(p.prefixo ?? "").slice(0, 8),
        sufixo: String(p.sufixo ?? "").slice(0, 8),
      });
    }
  }
  if (antigo) {
    const camadas = camadasDoFundo(largura, altura, fundo.pintura, fundo.textura);
    pecas.unshift(...camadas);
    return { versao: 3, largura, altura, fundo: FUNDO_BASE, pecas: pecas.slice(0, LIMITE_DE_PECAS) };
  }
  const limpo: DocumentoDoBanner = { versao: 3, largura, altura, fundo, pecas, modelo: origemLimpa(d.modelo) };
  if (fontesProprias.length) limpo.fontesProprias = fontesProprias;
  const animacaoDaPagina = animacaoDaPaginaLimpa(d.animacao);
  if (animacaoDaPagina) limpo.animacao = animacaoDaPagina;
  const transicao = transicaoLimpa(d.transicao);
  if (transicao) limpo.transicao = transicao;
  const audios = audiosLimpos(d.audios);
  if (audios.length) limpo.audios = audios;
  const fatias = Math.round(Number(d.fatias));
  if (fatias >= 2 && fatias <= 10) limpo.fatias = fatias;
  if (vista) limpo.vista = vista;
  const numeracao = d.numeracao as Record<string, unknown> | null | undefined;
  if (numeracao && typeof numeracao === "object" && Number(numeracao.total) >= 1) limpo.numeracao = { pagina: numero(numeracao.pagina, 1, 99, 1), total: numero(numeracao.total, 1, 99, 1) };
  const impressao = d.impressao as Record<string, unknown> | null;
  if (impressao && typeof impressao === "object" && Number(impressao.dpi) > 0) limpo.impressao = { dpi: numero(impressao.dpi, 36, 600, 150) };
  const mais = Array.isArray(d.maisPaginas) ? d.maisPaginas.slice(0, LIMITE_DE_PAGINAS - 1) : [];
  /* Cada página no tamanho dela (dentro dos limites da arte); sem tamanho válido, o da primeira. */
  const ladoDaPagina = (v: unknown, reserva: number) => {
    const n = Math.round(Number(v));
    return Number.isFinite(n) && n >= 50 && n <= 6000 ? n : reserva;
  };
  if (mais.length)
    limpo.maisPaginas = mais.map((pagina) => {
      const x = (pagina ?? {}) as Record<string, unknown>;
      return paginasDoDocumento(documentoLimpo({ ...x, maisPaginas: undefined }, ladoDaPagina(x.largura, largura), ladoDaPagina(x.altura, altura)))[0];
    });
  return limpo;
}

export const FUNDO_BASE: FundoDoBanner = {
  pintura: { tipo: "solida", cor: "#FFFFFF", cor2: "#FFFFFF", angulo: 135 },
  textura: { tipo: "nenhuma", cor: "#FFFFFF", opacidade: 0, quantidade: 20, tamanho: 0, centroX: 50, centroY: 50, giro: 0 },
};

/** O fundo como camadas: a cor (um retângulo do tamanho do banner) e, se houver, a textura por cima. Já nascem travadas no lugar. */
export function camadasDoFundo(largura: number, altura: number, pintura: Pintura, textura?: TexturaDoFundo | null): Peca[] {
  const lista: Peca[] = [
    { id: novoId("fundo"), nome: "Fundo", tipo: "forma", forma: "retangulo", x: 0, y: 0, w: largura, h: altura, rot: 0, opacidade: 1, bloqueada: true, pintura, raio: 0, borda: null, sombra: null, pontas: 16, profundidade: 0.2 },
  ];
  if (textura && textura.tipo !== "nenhuma") {
    const estilo: EstiloDeTextura = textura.tipo === "raios" || textura.tipo === "pontos" || textura.tipo === "listras" || textura.tipo === "grao" ? textura.tipo : "raios";
    lista.push({
      id: novoId("tex"),
      nome: "Textura",
      tipo: "textura",
      estilo,
      x: 0,
      y: 0,
      w: largura,
      h: altura,
      rot: 0,
      opacidade: Math.max(0.03, Math.min(1, textura.opacidade || 0.14)),
      bloqueada: true,
      cor: textura.cor,
      quantidade: textura.quantidade || 20,
      tamanho: textura.tamanho || 0,
      centroX: textura.centroX ?? 70,
      centroY: textura.centroY ?? 50,
      giro: textura.giro || 0,
    });
  }
  return lista;
}

/** O texto de todas as peças (para a peneira de contato do servidor). */
/**
 * A arte em outro tamanho (como o "Redimensionar" do Canva): o fundo e o que
 * cobria a arte inteira esticam até a borda nova; o resto encolhe ou cresce na
 * mesma proporção (letra, borda, sombra, cantos) e fica centrado.
 */
/** A peça na escala `f` (letra, borda, sombra, cantos, trechos), sem mexer na posição. */
export function escalarPeca<T extends Peca>(p: T, f: number): T {
  const borda = (b: Borda): Borda => (b ? { ...b, largura: Math.max(1, b.largura * f) } : null);
  const sombra = (s: Sombra): Sombra => (s ? { ...s, x: s.x * f, y: s.y * f, desfoque: s.desfoque * f } : null);
  const caixa = { w: p.w * f, h: p.h * f };
  if (p.tipo === "texto") {
    return {
      ...p,
      ...caixa,
      tamanho: Math.max(6, p.tamanho * f),
      html: p.html ? p.html.replace(/font-size:([\d.]+)px/g, (_, v) => `font-size:${Math.max(4, Math.round(Number(v) * f * 10) / 10)}px`) : p.html,
      contorno: borda(p.contorno),
      sombra: sombra(p.sombra),
      fundo: p.fundo ? { ...p.fundo, raio: p.fundo.raio * f, borda: borda(p.fundo.borda) } : null,
      ...(p.destaque ? { destaque: { ...p.destaque, raio: p.destaque.raio * f } } : {}),
    };
  }
  if (p.tipo === "forma") return { ...p, ...caixa, raio: p.raio * f, borda: borda(p.borda), sombra: sombra(p.sombra) };
  if (p.tipo === "imagem") {
    return { ...p, ...caixa, raio: p.raio * f, borda: borda(p.borda), sombra: sombra(p.sombra), ...(p.adesivo ? { adesivo: { ...p.adesivo, largura: Math.max(0.5, p.adesivo.largura * f) } } : {}) };
  }
  if (p.tipo === "icone") return { ...p, ...caixa, sombra: sombra(p.sombra) };
  /* Forma fechada sem contorno (grossura 0) continua sem: o mínimo de 0,5 é só para a linha aberta aparecer. */
  if (p.tipo === "traco") return { ...p, ...caixa, espessura: p.fechado && p.espessura <= 0 ? 0 : Math.max(0.5, p.espessura * f), sombra: sombra(p.sombra) };
  if (p.tipo === "qr") return { ...p, ...caixa };
  if (p.tipo === "tabela") return { ...p, ...caixa, tamanho: Math.max(6, p.tamanho * f), raio: p.raio * f };
  if (p.tipo === "grafico") return { ...p, ...caixa };
  return { ...p, ...caixa, tamanho: p.tamanho * f };
}

export function redimensionarDocumento(doc: DocumentoDoBanner, largura: number, altura: number): DocumentoDoBanner {
  const f = Math.min(largura / doc.largura, altura / doc.altura);
  const dx = (largura - doc.largura * f) / 2;
  const dy = (altura - doc.altura * f) / 2;
  const pecas = doc.pecas.map((p): Peca => {
    const cobre = p.x <= 1 && p.y <= 1 && p.x + p.w >= doc.largura - 1 && p.y + p.h >= doc.altura - 1 && !p.rot;
    if (cobre && (p.tipo === "forma" || p.tipo === "textura" || (p.tipo === "imagem" && p.ajuste === "cobrir"))) {
      return { ...p, x: 0, y: 0, w: largura, h: altura } as Peca;
    }
    return { ...escalarPeca(p, f), x: p.x * f + dx, y: p.y * f + dy };
  });
  return { ...doc, largura, altura, pecas };
}

/**
 * A página pronta para a gráfica: a sangria (o que encosta na borda estica
 * 3 mm para fora, para o corte não deixar filete branco), a margem branca em
 * volta e as marcas de corte nos cantos, fora da sangria. Vira um documento
 * comum, maior, desenhado pelo mesmo HTML.
 */
export function paginaParaGrafica(doc: DocumentoDoBanner, dpi: number, sangriaMm = 3, margemMm = 9): DocumentoDoBanner {
  const b = (sangriaMm / 25.4) * dpi;
  const m = (margemMm / 25.4) * dpi;
  const { largura: W, altura: H } = doc;
  const W2 = W + 2 * (b + m);
  const H2 = H + 2 * (b + m);
  const dentro = m + b;
  const pecas: Peca[] = doc.pecas.map((p) => {
    const estica = !p.rot && ((p.tipo === "forma" && (p.forma === "retangulo" || p.forma === "fita")) || p.tipo === "textura" || (p.tipo === "imagem" && p.ajuste === "cobrir"));
    let [x, y, w, h] = [p.x + dentro, p.y + dentro, p.w, p.h];
    if (estica) {
      if (p.x <= 1) {
        x -= b;
        w += b;
      }
      if (p.x + p.w >= W - 1) w += b;
      if (p.y <= 1) {
        y -= b;
        h += b;
      }
      if (p.y + p.h >= H - 1) h += b;
    }
    return { ...p, x, y, w, h } as Peca;
  });
  const papel = (id: string, x: number, y: number, w: number, h: number): PecaDeForma => ({ id, tipo: "forma", nome: "Margem", x, y, w, h, rot: 0, opacidade: 1, forma: "retangulo", pintura: solida("#FFFFFF"), raio: 0, borda: null, sombra: null, pontas: 16, profundidade: 0.2 });
  const margens = [papel("mgc", -2, -2, W2 + 4, m + 2), papel("mgb", -2, H2 - m, W2 + 4, m + 2), papel("mge", -2, -2, m + 2, H2 + 4), papel("mgd", W2 - m, -2, m + 2, H2 + 4)];
  /* Marcas de corte: em cada canto, uma linha em cada direção, alinhadas com o corte e sem entrar na sangria. */
  const esp = Math.max(1, dpi / 300);
  const fora = m * 0.12;
  const marca = (id: string, x1: number, y1: number, x2: number, y2: number): PecaDeTraco => {
    const caixa = caixaDoTraco([{ x: x1, y: y1 }, { x: x2, y: y2 }], esp);
    return { id, tipo: "traco", nome: "Marca de corte", x: caixa.x, y: caixa.y, w: caixa.w, h: caixa.h, rot: 0, opacidade: 1, pontos: caixa.pontos, fechado: false, suave: false, cor: "#000000", espessura: esp, tracejado: "solido", inicio: "nada", fim: "nada", preenchimento: solida("#000000"), sombra: null };
  };
  const [x0, x1, y0, y1] = [dentro, dentro + W, dentro, dentro + H];
  const marcas = [
    marca("mc1", fora, y0, m - fora, y0),
    marca("mc2", x0, fora, x0, m - fora),
    marca("mc3", W2 - m + fora, y0, W2 - fora, y0),
    marca("mc4", x1, fora, x1, m - fora),
    marca("mc5", fora, y1, m - fora, y1),
    marca("mc6", x0, H2 - m + fora, x0, H2 - fora),
    marca("mc7", W2 - m + fora, y1, W2 - fora, y1),
    marca("mc8", x1, H2 - m + fora, x1, H2 - fora),
  ];
  return { ...doc, largura: Math.round(W2), altura: Math.round(H2), pecas: [...pecas, ...margens, ...marcas] };
}

export function textosDoDocumento(doc: DocumentoDoBanner) {
  return doc.pecas.filter((p): p is PecaDeTexto => p.tipo === "texto").map((p) => p.texto);
}
