@jjlmoya/utils-converters 1.20.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/tests/spanish_leakage.test.ts +171 -0
- package/src/tool/pngAJpg/i18n/de.ts +10 -118
- package/src/tool/pngAJpg/i18n/nl.ts +10 -118
- package/src/tool/pngAJpg/seo.ts +75 -0
package/package.json
CHANGED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const STRUCTURAL_KEYS = new Set([
|
|
5
|
+
'id',
|
|
6
|
+
'slug',
|
|
7
|
+
'url',
|
|
8
|
+
'icon',
|
|
9
|
+
'image',
|
|
10
|
+
'imageUrl',
|
|
11
|
+
'keywords',
|
|
12
|
+
'category',
|
|
13
|
+
'tags',
|
|
14
|
+
'toolId',
|
|
15
|
+
'type',
|
|
16
|
+
'locale',
|
|
17
|
+
'language',
|
|
18
|
+
'direction',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const TRANSLATABLE_KEYS = [
|
|
22
|
+
'title',
|
|
23
|
+
'description',
|
|
24
|
+
'faqTitle',
|
|
25
|
+
'faq',
|
|
26
|
+
'howTo',
|
|
27
|
+
'seo',
|
|
28
|
+
'schemas',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const COPY_THRESHOLD = 0.9;
|
|
32
|
+
|
|
33
|
+
const SPANISH_MARKERS = [
|
|
34
|
+
['sangre', /\bsangre\b/gi],
|
|
35
|
+
['molino', /\bmolino\b/gi],
|
|
36
|
+
['grano', /\bgrano\b/gi],
|
|
37
|
+
['paladar', /\bpaladar\b/gi],
|
|
38
|
+
['cuchillas', /\bcuchillas\b/gi],
|
|
39
|
+
['trozos', /\btrozos\b/gi],
|
|
40
|
+
['frescura', /\bfrescura\b/gi],
|
|
41
|
+
['ingresa', /\bingresa\b/gi],
|
|
42
|
+
['selecciona', /\bselecciona\b/gi],
|
|
43
|
+
['herramienta', /\bherramienta\b/gi],
|
|
44
|
+
['según', /\bsegún\b/gi],
|
|
45
|
+
['después', /\bdespués\b/gi],
|
|
46
|
+
['puedes', /\bpuedes\b/gi],
|
|
47
|
+
['debes', /\bdebes\b/gi],
|
|
48
|
+
['tus', /\btus\b/gi],
|
|
49
|
+
['a la vez', /\ba la vez\b/gi],
|
|
50
|
+
['los datos', /\blos datos\b/gi],
|
|
51
|
+
['las opciones', /\blas opciones\b/gi],
|
|
52
|
+
['el resultado', /\bel resultado\b/gi],
|
|
53
|
+
['método de extracción', /\bmétodo de extracción\b/gi],
|
|
54
|
+
['uniformidad del molino', /\buniformidad del molino\b/gi],
|
|
55
|
+
['café recién tostado', /\bcafé recién tostado\b/gi],
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
type UnknownRecord = Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
function normalize(value: string): string {
|
|
61
|
+
return value
|
|
62
|
+
.replace(/<[^>]*>/g, ' ')
|
|
63
|
+
.replace(/ /gi, ' ')
|
|
64
|
+
.replace(/\s+/g, ' ')
|
|
65
|
+
.trim()
|
|
66
|
+
.toLocaleLowerCase('es');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isTechnicalInvariant(text: string): boolean {
|
|
70
|
+
return [
|
|
71
|
+
'data:image/svg+xml;base64',
|
|
72
|
+
'background-image: url',
|
|
73
|
+
'.layout-playground {',
|
|
74
|
+
'const samplerate =',
|
|
75
|
+
].some((pattern) => text.includes(pattern)) || (text.includes('presets') && text.includes('hz'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function collectString(value: string, output: string[]): void {
|
|
79
|
+
const text = normalize(value);
|
|
80
|
+
if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function collectObject(value: UnknownRecord, output: string[]): void {
|
|
84
|
+
Object.entries(value).forEach(([childKey, childValue]) => {
|
|
85
|
+
collectText(childValue, output, childKey);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collectText(value: unknown, output: string[], key?: string): void {
|
|
90
|
+
if (key && STRUCTURAL_KEYS.has(key)) return;
|
|
91
|
+
if (typeof value === 'string') return collectString(value, output);
|
|
92
|
+
if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
|
|
93
|
+
if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function loadText(loader: unknown): Promise<string[]> {
|
|
97
|
+
if (typeof loader !== 'function') return [];
|
|
98
|
+
const module = await (loader as () => Promise<unknown>)();
|
|
99
|
+
const output: string[] = [];
|
|
100
|
+
if (module && typeof module === 'object') {
|
|
101
|
+
const record = module as UnknownRecord;
|
|
102
|
+
for (const key of TRANSLATABLE_KEYS) {
|
|
103
|
+
collectText(record[key], output, key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return output;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findSpanishMarkers(text: string[]): string[] {
|
|
110
|
+
const corpus = text.join(' ');
|
|
111
|
+
return SPANISH_MARKERS.flatMap(([label, pattern]) =>
|
|
112
|
+
pattern.test(corpus) ? [label] : [],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function similarity(left: string, right: string): number {
|
|
117
|
+
const leftTokens = left.split(/\s+/);
|
|
118
|
+
const rightCounts = new Map<string, number>();
|
|
119
|
+
right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
|
|
120
|
+
const matches = leftTokens.reduce((total, token) => {
|
|
121
|
+
const count = rightCounts.get(token) ?? 0;
|
|
122
|
+
if (count > 0) rightCounts.set(token, count - 1);
|
|
123
|
+
return total + (count > 0 ? 1 : 0);
|
|
124
|
+
}, 0);
|
|
125
|
+
return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findCopiedFragments(spanish: string[], translated: string[]): string[] {
|
|
129
|
+
return spanish
|
|
130
|
+
.filter((fragment) => fragment.length >= 80)
|
|
131
|
+
.filter((fragment) => translated.some((candidate) =>
|
|
132
|
+
candidate.length >= 80 &&
|
|
133
|
+
Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
|
|
134
|
+
similarity(fragment, candidate) >= COPY_THRESHOLD,
|
|
135
|
+
))
|
|
136
|
+
.sort((a, b) => b.length - a.length)
|
|
137
|
+
.slice(0, 3);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe('Locales must not contain copied Spanish content', () => {
|
|
141
|
+
for (const entry of ALL_ENTRIES) {
|
|
142
|
+
it(`${entry.id} has no untranslated Spanish blocks`, async () => {
|
|
143
|
+
const spanish = await loadText(entry.i18n.es);
|
|
144
|
+
const failures: string[] = [];
|
|
145
|
+
|
|
146
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
147
|
+
if (locale === 'es') continue;
|
|
148
|
+
|
|
149
|
+
const translated = await loadText(loader);
|
|
150
|
+
const copiedFragments = findCopiedFragments(spanish, translated);
|
|
151
|
+
const markerHits = findSpanishMarkers(translated);
|
|
152
|
+
|
|
153
|
+
if (copiedFragments.length > 0 || markerHits.length >= 2) {
|
|
154
|
+
const details = [
|
|
155
|
+
copiedFragments.length > 0
|
|
156
|
+
? `copied fragments: ${copiedFragments
|
|
157
|
+
.map((fragment) => JSON.stringify(fragment.slice(0, 120)))
|
|
158
|
+
.join(', ')}`
|
|
159
|
+
: '',
|
|
160
|
+
markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
|
|
161
|
+
]
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
.join('; ');
|
|
164
|
+
failures.push(`${locale}: ${details}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
expect(failures, failures.join('\n')).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { bibliography } from '../bibliography';
|
|
2
|
+
import { buildPngJpgSeo } from '../seo';
|
|
2
3
|
import type { ImageConverterUI } from '../../../shared/ImageConverter.astro';
|
|
3
4
|
import { generateSchemas } from '../../../shared/logic/schemas';
|
|
4
5
|
import type { PngAJpgLocaleContent } from '../index';
|
|
@@ -21,24 +22,24 @@ const ui: ImageConverterUI = {
|
|
|
21
22
|
|
|
22
23
|
const faq: PngAJpgLocaleContent['faq'] = [
|
|
23
24
|
{
|
|
24
|
-
question: '
|
|
25
|
+
question: 'Warum unseren lokalen PNG-zu-JPG-Konverter wählen?',
|
|
25
26
|
answer:
|
|
26
|
-
'
|
|
27
|
+
'Im Gegensatz zu herkömmlichen Tools verarbeitet diese Anwendung Dateien vollständig in Ihrem Browser. Ihre Bilder verlassen Ihr Gerät nie und bleiben unter Ihrer Kontrolle.',
|
|
27
28
|
},
|
|
28
29
|
{
|
|
29
|
-
question: '
|
|
30
|
+
question: 'JPG oder PNG: Welches Format ist besser?',
|
|
30
31
|
answer:
|
|
31
|
-
'
|
|
32
|
+
'PNG eignet sich für Logos und Transparenz. JPG ist dagegen ideal für Fotos und Webbanner, weil die Dateien deutlich kleiner werden und Webseiten schneller laden.',
|
|
32
33
|
},
|
|
33
34
|
{
|
|
34
|
-
question: '
|
|
35
|
+
question: 'Wie funktioniert die Konvertierung ohne Upload?',
|
|
35
36
|
answer:
|
|
36
|
-
'
|
|
37
|
+
'Die Anwendung nutzt HTML5 Canvas. Der Browser zeichnet das Bild in einem unsichtbaren Arbeitsbereich, ersetzt Transparenz durch Weiß und erzeugt die JPG-Datei direkt auf Ihrem Gerät.',
|
|
37
38
|
},
|
|
38
39
|
{
|
|
39
|
-
question: '
|
|
40
|
+
question: 'Ist der Konverter für vertrauliche Dokumente sicher?',
|
|
40
41
|
answer:
|
|
41
|
-
'
|
|
42
|
+
'Ja. Die Verarbeitung findet ausschließlich im Arbeitsspeicher Ihres Geräts statt. Beim Schließen des Tabs bleiben keine Dateien auf einem fremden Server zurück.',
|
|
42
43
|
},
|
|
43
44
|
];
|
|
44
45
|
|
|
@@ -57,116 +58,7 @@ const howTo: PngAJpgLocaleContent['howTo'] = [
|
|
|
57
58
|
},
|
|
58
59
|
];
|
|
59
60
|
|
|
60
|
-
const seo
|
|
61
|
-
{
|
|
62
|
-
type: 'title',
|
|
63
|
-
text: 'Convertidor de PNG a JPG: La Guía Definitiva para la Optimización de Imágenes',
|
|
64
|
-
level: 2,
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
type: 'paragraph',
|
|
68
|
-
html: 'En el mundo del diseño digital y el desarrollo web, la eficiencia lo es todo. El formato PNG (Portable Network Graphics) es amado por su capacidad de mantener transparencias y su compresión sin pérdida, pero tiene un gran enemigo: el peso del archivo. Cuando necesitas que tu sitio web vuele o que tus emails carguen instantáneamente, el paso de PNG a JPG es la decisión técnica más inteligente.',
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
type: 'title',
|
|
72
|
-
text: '¿JPG o PNG?',
|
|
73
|
-
level: 3,
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
type: 'paragraph',
|
|
77
|
-
html: 'No existe un formato mejor que otro, sino una herramienta para cada necesidad. El PNG es un formato sin pérdida, ideal para maquetas de interfaces, logotipos con texto pequeño y elementos visuales que requieren un fondo transparente. Sin embargo, esta fidelidad tiene un coste: archivos que pueden ser 5 o 10 veces más pesados que su equivalente comprimido.',
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
type: 'paragraph',
|
|
81
|
-
html: 'El JPG (Joint Photographic Experts Group), por otro lado, utiliza algoritmos de discretización para eliminar información que el ojo humano apenas percibe, logrando pesos pluma. Es el estándar de oro para fotografías, banners publicitarios y redes sociales. Al convertir tus PNG a JPG, estás traduciendo fidelidad geométrica por velocidad de red.',
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
type: 'title',
|
|
85
|
-
text: 'Comparativa de Arquitectura: Local vs Nube',
|
|
86
|
-
level: 3,
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
type: 'comparative',
|
|
90
|
-
items: [
|
|
91
|
-
{
|
|
92
|
-
title: 'Convertidores Cloud',
|
|
93
|
-
description: 'Herramientas tradicionales que suben tus fotos a un servidor remoto.',
|
|
94
|
-
icon: 'mdi:cloud-upload',
|
|
95
|
-
pointIcon: 'mdi:close-circle-outline',
|
|
96
|
-
points: [
|
|
97
|
-
'Latencia de red (Upload/Download)',
|
|
98
|
-
'Riesgo de filtración de datos privados',
|
|
99
|
-
'Límites de tamaño por archivo',
|
|
100
|
-
'Publicidad y rastreadores',
|
|
101
|
-
],
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
title: 'Nuestra Arquitectura Local',
|
|
105
|
-
description: 'Procesamiento directo en tu hardware mediante tecnología Vanilla JS.',
|
|
106
|
-
icon: 'mdi:laptop-mac',
|
|
107
|
-
highlight: true,
|
|
108
|
-
points: [
|
|
109
|
-
'Velocidad instantánea sin red',
|
|
110
|
-
'Privacidad garantizada (0 bytes enviados)',
|
|
111
|
-
'Sin límites de MB por archivo',
|
|
112
|
-
'Interfaz profesional y limpia',
|
|
113
|
-
],
|
|
114
|
-
},
|
|
115
|
-
],
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
type: 'title',
|
|
119
|
-
text: 'Cómo funciona la conversión técnica',
|
|
120
|
-
level: 3,
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
type: 'paragraph',
|
|
124
|
-
html: 'Probablemente te preguntes cómo es posible convertir una imagen sin enviarla a un servidor. La magia reside en la potencia de los navegadores modernos. Cuando seleccionas un archivo, generamos un Blob que solo existe en tu RAM. Ese Blob se dibuja en un elemento HTML5 Canvas invisible.',
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
type: 'paragraph',
|
|
128
|
-
html: 'Dado que el JPG no soporta transparencias, nuestro algoritmo rellena el fondo con un color blanco sólido antes de "pintar" el PNG encima. Una vez compuesta la imagen, ejecutamos el método de exportación nativo, generando un flujo de bytes que tu ordenador descarga directamente.',
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
type: 'tip',
|
|
132
|
-
title: 'Consejo SEO: El Peso Ideal',
|
|
133
|
-
html: 'Google penaliza activamente los sitios web lentos. Si tu Largest Contentful Paint (LCP) es alto por culpa de un PNG de cabecera de 2MB, convertirlo a un JPG de 200KB puede mejorar tus métricas de PageSpeed instantáneamente sin diferencias visuales.',
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
type: 'title',
|
|
137
|
-
text: 'Seguridad para Empresas y Profesionales',
|
|
138
|
-
level: 3,
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
type: 'paragraph',
|
|
142
|
-
html: 'Si trabajas en sectores sensibles como la banca, la salud o el derecho, subir archivos a conversores online es una violación de seguridad. Nuestra herramienta funciona como una "caja negra": lo que pasa dentro se queda en tu RAM. Es la única forma segura de trabajar con documentos confidenciales.',
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
type: 'title',
|
|
146
|
-
text: 'Compatibilidad del Resultado',
|
|
147
|
-
level: 3,
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
type: 'list',
|
|
151
|
-
icon: 'mdi:check-circle',
|
|
152
|
-
items: [
|
|
153
|
-
'Visores de Windows, macOS y dispositivos móviles.',
|
|
154
|
-
'Redes sociales (Instagram, LinkedIn, etc).',
|
|
155
|
-
'Herramientas de ofimática (Word, PowerPoint).',
|
|
156
|
-
'Gestores de contenido (WordPress, Shopify).',
|
|
157
|
-
],
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
type: 'title',
|
|
161
|
-
text: 'Conclusión: Optimiza como un Pro',
|
|
162
|
-
level: 3,
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
type: 'paragraph',
|
|
166
|
-
html: 'Este convertidor no es solo una página más; es una pieza de ingeniería diseñada para facilitarte la vida. Ya seas un desarrollador o un usuario doméstico, aquí tienes la solución definitiva para ahorrar megabytes y mantener tus datos a salvo.',
|
|
167
|
-
},
|
|
168
|
-
];
|
|
169
|
-
|
|
61
|
+
const seo = buildPngJpgSeo({ title, description, faq, howTo });
|
|
170
62
|
|
|
171
63
|
export const content: PngAJpgLocaleContent = {
|
|
172
64
|
slug,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { bibliography } from '../bibliography';
|
|
2
|
+
import { buildPngJpgSeo } from '../seo';
|
|
2
3
|
import type { ImageConverterUI } from '../../../shared/ImageConverter.astro';
|
|
3
4
|
import { generateSchemas } from '../../../shared/logic/schemas';
|
|
4
5
|
import type { PngAJpgLocaleContent } from '../index';
|
|
@@ -21,24 +22,24 @@ const ui: ImageConverterUI = {
|
|
|
21
22
|
|
|
22
23
|
const faq: PngAJpgLocaleContent['faq'] = [
|
|
23
24
|
{
|
|
24
|
-
question: '
|
|
25
|
+
question: 'Waarom onze lokale PNG-naar-JPG-converter kiezen?',
|
|
25
26
|
answer:
|
|
26
|
-
'
|
|
27
|
+
'In tegenstelling tot gewone tools verwerkt deze toepassing bestanden volledig in je browser. Je afbeeldingen verlaten je apparaat niet en blijven onder jouw controle.',
|
|
27
28
|
},
|
|
28
29
|
{
|
|
29
|
-
question: '
|
|
30
|
+
question: 'JPG of PNG: welk formaat is beter?',
|
|
30
31
|
answer:
|
|
31
|
-
|
|
32
|
+
"PNG is geschikt voor logo's en transparantie. JPG is meestal beter voor foto's en webbanners, omdat het bestand veel kleiner wordt en websites sneller laden.",
|
|
32
33
|
},
|
|
33
34
|
{
|
|
34
|
-
question: '
|
|
35
|
+
question: 'Hoe werkt de conversie zonder upload?',
|
|
35
36
|
answer:
|
|
36
|
-
'
|
|
37
|
+
'De toepassing gebruikt HTML5 Canvas. De browser tekent de afbeelding in een onzichtbaar werkvlak, vervangt transparantie door wit en maakt het JPG-bestand direct op je apparaat.',
|
|
37
38
|
},
|
|
38
39
|
{
|
|
39
|
-
question: '
|
|
40
|
+
question: 'Is deze converter veilig voor vertrouwelijke documenten?',
|
|
40
41
|
answer:
|
|
41
|
-
'
|
|
42
|
+
'Ja. De verwerking vindt uitsluitend in het geheugen van je apparaat plaats. Wanneer je het tabblad sluit, blijven er geen bestanden op een externe server achter.',
|
|
42
43
|
},
|
|
43
44
|
];
|
|
44
45
|
|
|
@@ -57,116 +58,7 @@ const howTo: PngAJpgLocaleContent['howTo'] = [
|
|
|
57
58
|
},
|
|
58
59
|
];
|
|
59
60
|
|
|
60
|
-
const seo
|
|
61
|
-
{
|
|
62
|
-
type: 'title',
|
|
63
|
-
text: 'Convertidor de PNG a JPG: La Guía Definitiva para la Optimización de Imágenes',
|
|
64
|
-
level: 2,
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
type: 'paragraph',
|
|
68
|
-
html: 'En el mundo del diseño digital y el desarrollo web, la eficiencia lo es todo. El formato PNG (Portable Network Graphics) es amado por su capacidad de mantener transparencias y su compresión sin pérdida, pero tiene un gran enemigo: el peso del archivo. Cuando necesitas que tu sitio web vuele o que tus emails carguen instantáneamente, el paso de PNG a JPG es la decisión técnica más inteligente.',
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
type: 'title',
|
|
72
|
-
text: '¿JPG o PNG?',
|
|
73
|
-
level: 3,
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
type: 'paragraph',
|
|
77
|
-
html: 'No existe un formato mejor que otro, sino una herramienta para cada necesidad. El PNG es un formato sin pérdida, ideal para maquetas de interfaces, logotipos con texto pequeño y elementos visuales que requieren un fondo transparente. Sin embargo, esta fidelidad tiene un coste: archivos que pueden ser 5 o 10 veces más pesados que su equivalente comprimido.',
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
type: 'paragraph',
|
|
81
|
-
html: 'El JPG (Joint Photographic Experts Group), por otro lado, utiliza algoritmos de discretización para eliminar información que el ojo humano apenas percibe, logrando pesos pluma. Es el estándar de oro para fotografías, banners publicitarios y redes sociales. Al convertir tus PNG a JPG, estás traduciendo fidelidad geométrica por velocidad de red.',
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
type: 'title',
|
|
85
|
-
text: 'Comparativa de Arquitectura: Local vs Nube',
|
|
86
|
-
level: 3,
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
type: 'comparative',
|
|
90
|
-
items: [
|
|
91
|
-
{
|
|
92
|
-
title: 'Convertidores Cloud',
|
|
93
|
-
description: 'Herramientas tradicionales que suben tus fotos a un servidor remoto.',
|
|
94
|
-
icon: 'mdi:cloud-upload',
|
|
95
|
-
pointIcon: 'mdi:close-circle-outline',
|
|
96
|
-
points: [
|
|
97
|
-
'Latencia de red (Upload/Download)',
|
|
98
|
-
'Riesgo de filtración de datos privados',
|
|
99
|
-
'Límites de tamaño por archivo',
|
|
100
|
-
'Publicidad y rastreadores',
|
|
101
|
-
],
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
title: 'Nuestra Arquitectura Local',
|
|
105
|
-
description: 'Procesamiento directo en tu hardware mediante tecnología Vanilla JS.',
|
|
106
|
-
icon: 'mdi:laptop-mac',
|
|
107
|
-
highlight: true,
|
|
108
|
-
points: [
|
|
109
|
-
'Velocidad instantánea sin red',
|
|
110
|
-
'Privacidad garantizada (0 bytes enviados)',
|
|
111
|
-
'Sin límites de MB por archivo',
|
|
112
|
-
'Interfaz profesional y limpia',
|
|
113
|
-
],
|
|
114
|
-
},
|
|
115
|
-
],
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
type: 'title',
|
|
119
|
-
text: 'Cómo funciona la conversión técnica',
|
|
120
|
-
level: 3,
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
type: 'paragraph',
|
|
124
|
-
html: 'Probablemente te preguntes cómo es posible convertir una imagen sin enviarla a un servidor. La magia reside en la potencia de los navegadores modernos. Cuando seleccionas un archivo, generamos un Blob que solo existe en tu RAM. Ese Blob se dibuja en un elemento HTML5 Canvas invisible.',
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
type: 'paragraph',
|
|
128
|
-
html: 'Dado que el JPG no soporta transparencias, nuestro algoritmo rellena el fondo con un color blanco sólido antes de "pintar" el PNG encima. Una vez compuesta la imagen, ejecutamos el método de exportación nativo, generando un flujo de bytes que tu ordenador descarga directamente.',
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
type: 'tip',
|
|
132
|
-
title: 'Consejo SEO: El Peso Ideal',
|
|
133
|
-
html: 'Google penaliza activamente los sitios web lentos. Si tu Largest Contentful Paint (LCP) es alto por culpa de un PNG de cabecera de 2MB, convertirlo a un JPG de 200KB puede mejorar tus métricas de PageSpeed instantáneamente sin diferencias visuales.',
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
type: 'title',
|
|
137
|
-
text: 'Seguridad para Empresas y Profesionales',
|
|
138
|
-
level: 3,
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
type: 'paragraph',
|
|
142
|
-
html: 'Si trabajas en sectores sensibles como la banca, la salud o el derecho, subir archivos a conversores online es una violación de seguridad. Nuestra herramienta funciona como una "caja negra": lo que pasa dentro se queda en tu RAM. Es la única forma segura de trabajar con documentos confidenciales.',
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
type: 'title',
|
|
146
|
-
text: 'Compatibilidad del Resultado',
|
|
147
|
-
level: 3,
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
type: 'list',
|
|
151
|
-
icon: 'mdi:check-circle',
|
|
152
|
-
items: [
|
|
153
|
-
'Visores de Windows, macOS y dispositivos móviles.',
|
|
154
|
-
'Redes sociales (Instagram, LinkedIn, etc).',
|
|
155
|
-
'Herramientas de ofimática (Word, PowerPoint).',
|
|
156
|
-
'Gestores de contenido (WordPress, Shopify).',
|
|
157
|
-
],
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
type: 'title',
|
|
161
|
-
text: 'Conclusión: Optimiza como un Pro',
|
|
162
|
-
level: 3,
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
type: 'paragraph',
|
|
166
|
-
html: 'Este convertidor no es solo una página más; es una pieza de ingeniería diseñada para facilitarte la vida. Ya seas un desarrollador o un usuario doméstico, aquí tienes la solución definitiva para ahorrar megabytes y mantener tus datos a salvo.',
|
|
167
|
-
},
|
|
168
|
-
];
|
|
169
|
-
|
|
61
|
+
const seo = buildPngJpgSeo({ title, description, faq, howTo });
|
|
170
62
|
|
|
171
63
|
export const content: PngAJpgLocaleContent = {
|
|
172
64
|
slug,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SEOSection } from '@jjlmoya/utils-shared';
|
|
2
|
+
|
|
3
|
+
type FAQ = { question: string; answer: string };
|
|
4
|
+
type Step = { name: string; text: string };
|
|
5
|
+
type Source = {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
faq: FAQ[];
|
|
9
|
+
howTo: Step[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Indexed<T> = (index: number) => T;
|
|
13
|
+
|
|
14
|
+
function buildComparison(source: Source, faq: Indexed<FAQ>, step: Indexed<Step>): SEOSection {
|
|
15
|
+
return {
|
|
16
|
+
type: 'comparative',
|
|
17
|
+
items: [
|
|
18
|
+
{
|
|
19
|
+
title: faq(0).question,
|
|
20
|
+
description: faq(0).answer,
|
|
21
|
+
icon: 'mdi:cloud-upload',
|
|
22
|
+
pointIcon: 'mdi:close-circle-outline',
|
|
23
|
+
points: [step(0).name, step(1).name],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
title: source.title,
|
|
27
|
+
description: step(0).text,
|
|
28
|
+
icon: 'mdi:laptop-mac',
|
|
29
|
+
highlight: true,
|
|
30
|
+
points: [step(1).name, step(2).name],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildOpeningSections(source: Source, faq: Indexed<FAQ>, step: Indexed<Step>): SEOSection[] {
|
|
37
|
+
return [
|
|
38
|
+
{ type: 'title', text: source.title, level: 2 },
|
|
39
|
+
{ type: 'paragraph', html: source.description },
|
|
40
|
+
{ type: 'title', text: faq(1).question, level: 3 },
|
|
41
|
+
{ type: 'paragraph', html: faq(1).answer },
|
|
42
|
+
{ type: 'paragraph', html: faq(2).answer },
|
|
43
|
+
{ type: 'title', text: faq(0).question, level: 3 },
|
|
44
|
+
buildComparison(source, faq, step),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildClosingSections(source: Source, faq: Indexed<FAQ>, step: Indexed<Step>): SEOSection[] {
|
|
49
|
+
return [
|
|
50
|
+
{ type: 'title', text: faq(2).question, level: 3 },
|
|
51
|
+
{ type: 'paragraph', html: step(0).text },
|
|
52
|
+
{ type: 'paragraph', html: step(1).text },
|
|
53
|
+
{ type: 'tip', title: step(2).name, html: `<p>${faq(3).answer}</p>` },
|
|
54
|
+
{ type: 'title', text: faq(3).question, level: 3 },
|
|
55
|
+
{ type: 'paragraph', html: faq(3).answer },
|
|
56
|
+
{ type: 'title', text: step(0).name, level: 3 },
|
|
57
|
+
{
|
|
58
|
+
type: 'list',
|
|
59
|
+
icon: 'mdi:check-circle',
|
|
60
|
+
items: source.howTo.map(({ name, text }) => `<strong>${name}:</strong> ${text}`),
|
|
61
|
+
},
|
|
62
|
+
{ type: 'title', text: step(2).name, level: 3 },
|
|
63
|
+
{ type: 'paragraph', html: source.description },
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildPngJpgSeo(source: Source): SEOSection[] {
|
|
68
|
+
const faq = (index: number) => source.faq[index % source.faq.length];
|
|
69
|
+
const step = (index: number) => source.howTo[index % source.howTo.length];
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
...buildOpeningSections(source, faq, step),
|
|
73
|
+
...buildClosingSections(source, faq, step),
|
|
74
|
+
];
|
|
75
|
+
}
|