/** * Convierte el PNG maestro del monograma en un SVG de contornos reales. * * No incrusta la imagen dentro del SVG. ImageMagick solo separa las zonas * doradas; este script recorre sus bordes, simplifica los puntos y escribe las * formas como `path`. Así el símbolo puede crecer y girar sin pixelarse. */ import { spawnSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const raiz = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const fuente = join(raiz, 'static', 'images', 'senza_paura_logo.png'); const destino = join(raiz, 'src', 'lib', 'assets', 'senza-paura-logo.svg'); /** Separa el metal del disco negro y cierra poros diminutos de la textura. */ function crearMascara() { const resultado = spawnSync( 'magick', [ fuente, '-alpha', 'on', '-fx', '(a > 0.15 && r > 0.16 && r > g * 1.05 && g > b * 1.12) ? 1 : 0', '-morphology', 'Close', 'Disk:1', '-threshold', '50%', '-depth', '8', 'pgm:-' ], { encoding: null, maxBuffer: 16 * 1024 * 1024 } ); if (resultado.error) { throw new Error(`No se pudo ejecutar ImageMagick: ${resultado.error.message}`); } if (resultado.status !== 0) { throw new Error(resultado.stderr.toString('utf8').trim() || 'ImageMagick no creó la máscara'); } return resultado.stdout; } /** Lee un PGM binario P5 sin depender de una librería de imágenes. */ function leerPgm(buffer) { let posicion = 0; function token() { while (posicion < buffer.length) { const byte = buffer[posicion]; if (byte === 35) { while (posicion < buffer.length && buffer[posicion] !== 10) posicion += 1; } else if (byte <= 32) { posicion += 1; } else { break; } } const inicio = posicion; while (posicion < buffer.length && buffer[posicion] > 32) posicion += 1; return buffer.toString('ascii', inicio, posicion); } if (token() !== 'P5') throw new Error('La máscara no es un PGM binario'); const ancho = Number(token()); const alto = Number(token()); const maximo = Number(token()); if (!Number.isInteger(ancho) || !Number.isInteger(alto) || maximo !== 255) { throw new Error('Cabecera PGM no compatible'); } // Tras `maximo` hay un único separador. No se puede saltar "todo el espacio": // en binario, un píxel negro también vale 0 y se confundiría con él. if (buffer[posicion] === 13 && buffer[posicion + 1] === 10) posicion += 2; else if (buffer[posicion] <= 32) posicion += 1; const pixeles = buffer.subarray(posicion, posicion + ancho * alto); if (pixeles.length !== ancho * alto) throw new Error('La máscara PGM está incompleta'); return { ancho, alto, pixeles }; } const clave = (x, y) => `${x},${y}`; /** * Cada píxel encendido aporta solo los lados que tocan transparencia. Los * lados se orientan dejando el metal a la derecha, de modo que se pueden * encadenar en anillos cerrados, incluidos los huecos de la P y de la S. */ function extraerContornos({ ancho, alto, pixeles }) { const encendido = (x, y) => x >= 0 && y >= 0 && x < ancho && y < alto && pixeles[y * ancho + x] > 127; const aristas = []; const desde = new Map(); function arista(ax, ay, bx, by, direccion) { const id = aristas.length; const valor = { id, ax, ay, bx, by, direccion }; aristas.push(valor); const salida = desde.get(clave(ax, ay)) ?? []; salida.push(valor); desde.set(clave(ax, ay), salida); } for (let y = 0; y < alto; y += 1) { for (let x = 0; x < ancho; x += 1) { if (!encendido(x, y)) continue; if (!encendido(x, y - 1)) arista(x, y, x + 1, y, 0); if (!encendido(x + 1, y)) arista(x + 1, y, x + 1, y + 1, 1); if (!encendido(x, y + 1)) arista(x + 1, y + 1, x, y + 1, 2); if (!encendido(x - 1, y)) arista(x, y + 1, x, y, 3); } } const usadas = new Set(); const contornos = []; const preferencia = new Map([ [1, 0], // giro a la derecha [0, 1], // recto [3, 2], // giro a la izquierda [2, 3] // vuelta atrás ]); for (const primera of aristas) { if (usadas.has(primera.id)) continue; const puntos = [{ x: primera.ax, y: primera.ay }]; let actual = primera; for (let limite = 0; limite <= aristas.length; limite += 1) { usadas.add(actual.id); puntos.push({ x: actual.bx, y: actual.by }); if (actual.bx === primera.ax && actual.by === primera.ay) break; const candidatas = (desde.get(clave(actual.bx, actual.by)) ?? []) .filter((candidata) => !usadas.has(candidata.id)) .sort((a, b) => { const giroA = (a.direccion - actual.direccion + 4) % 4; const giroB = (b.direccion - actual.direccion + 4) % 4; return preferencia.get(giroA) - preferencia.get(giroB); }); if (candidatas.length === 0) break; actual = candidatas[0]; } const ultimo = puntos.at(-1); if (puntos.length >= 5 && ultimo.x === primera.ax && ultimo.y === primera.ay) { puntos.pop(); contornos.push(puntos); } } return contornos; } function distanciaAlSegmento(punto, inicio, fin) { const dx = fin.x - inicio.x; const dy = fin.y - inicio.y; if (dx === 0 && dy === 0) return Math.hypot(punto.x - inicio.x, punto.y - inicio.y); const t = Math.max( 0, Math.min(1, ((punto.x - inicio.x) * dx + (punto.y - inicio.y) * dy) / (dx * dx + dy * dy)) ); return Math.hypot(punto.x - (inicio.x + t * dx), punto.y - (inicio.y + t * dy)); } function simplificarAbierto(puntos, tolerancia) { if (puntos.length <= 2) return puntos; let distanciaMayor = 0; let indice = 0; for (let i = 1; i < puntos.length - 1; i += 1) { const distancia = distanciaAlSegmento(puntos[i], puntos[0], puntos.at(-1)); if (distancia > distanciaMayor) { distanciaMayor = distancia; indice = i; } } if (distanciaMayor <= tolerancia) return [puntos[0], puntos.at(-1)]; return [ ...simplificarAbierto(puntos.slice(0, indice + 1), tolerancia).slice(0, -1), ...simplificarAbierto(puntos.slice(indice), tolerancia) ]; } function simplificarCerrado(puntos, tolerancia = 0.72) { const origen = puntos[0]; let opuesto = 1; let distanciaMayor = 0; for (let i = 1; i < puntos.length; i += 1) { const distancia = Math.hypot(puntos[i].x - origen.x, puntos[i].y - origen.y); if (distancia > distanciaMayor) { distanciaMayor = distancia; opuesto = i; } } const mitadA = simplificarAbierto(puntos.slice(0, opuesto + 1), tolerancia); const mitadB = simplificarAbierto([...puntos.slice(opuesto), origen], tolerancia); return [...mitadA.slice(0, -1), ...mitadB.slice(0, -1)]; } function area(puntos) { let total = 0; for (let i = 0; i < puntos.length; i += 1) { const a = puntos[i]; const b = puntos[(i + 1) % puntos.length]; total += a.x * b.y - b.x * a.y; } return total / 2; } function aPath(puntos) { return `M${puntos.map(({ x, y }) => `${x} ${y}`).join('L')}Z`; } const mascara = leerPgm(crearMascara()); const contornos = extraerContornos(mascara) .filter((puntos) => Math.abs(area(puntos)) >= 4) .map((puntos) => simplificarCerrado(puntos)); const svg = ` Senza Paura Monograma dorado SP dentro de un disco negro `; writeFileSync(destino, svg, 'utf8'); // Comprobación defensiva: el resultado debe contener curvas, no la fuente. const escrito = readFileSync(destino, 'utf8'); if (!escrito.includes('