You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
251 lines
7.9 KiB
251 lines
7.9 KiB
/**
|
|
* 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${mascara.ancho} ${mascara.alto}" role="img" aria-labelledby="titulo descripcion">
|
|
<title id="titulo">Senza Paura</title>
|
|
<desc id="descripcion">Monograma dorado SP dentro de un disco negro</desc>
|
|
<defs>
|
|
<linearGradient id="oro" x1="0" y1="1" x2="1" y2="0">
|
|
<stop offset="0" stop-color="#b97a13"/>
|
|
<stop offset=".24" stop-color="#e9b53b"/>
|
|
<stop offset=".46" stop-color="#feeca2"/>
|
|
<stop offset=".64" stop-color="#e9b53b"/>
|
|
<stop offset="1" stop-color="#b97a13"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<circle cx="175" cy="175" r="170" fill="#030303"/>
|
|
<path fill="url(#oro)" fill-rule="evenodd" d="${contornos.map(aPath).join('')}"/>
|
|
</svg>
|
|
`;
|
|
|
|
writeFileSync(destino, svg, 'utf8');
|
|
|
|
// Comprobación defensiva: el resultado debe contener curvas, no la fuente.
|
|
const escrito = readFileSync(destino, 'utf8');
|
|
if (!escrito.includes('<path') || escrito.includes('<image')) {
|
|
throw new Error('El SVG generado no contiene los contornos esperados');
|
|
}
|
|
|
|
console.log(`Logo vectorial generado: ${destino}`);
|