/**
* 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 = `
`;
writeFileSync(destino, svg, 'utf8');
// Comprobación defensiva: el resultado debe contener curvas, no la fuente.
const escrito = readFileSync(destino, 'utf8');
if (!escrito.includes('