@jjlmoya/utils-converters 1.1.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.
Files changed (172) hide show
  1. package/package.json +61 -0
  2. package/src/category/i18n/en.ts +90 -0
  3. package/src/category/i18n/es.ts +90 -0
  4. package/src/category/i18n/fr.ts +90 -0
  5. package/src/category/index.ts +39 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +152 -0
  9. package/src/data.ts +34 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +39 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +148 -0
  14. package/src/pages/[locale].astro +271 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/shared/ImageConverter.astro +237 -0
  17. package/src/shared/logic/converter.ts +167 -0
  18. package/src/shared/style.css +258 -0
  19. package/src/tests/faq_count.test.ts +10 -0
  20. package/src/tests/mocks/astro_mock.js +2 -0
  21. package/src/tests/seo_length.test.ts +22 -0
  22. package/src/tests/tool_validation.test.ts +17 -0
  23. package/src/tool/avifAJpg/bibliography.astro +14 -0
  24. package/src/tool/avifAJpg/component.astro +8 -0
  25. package/src/tool/avifAJpg/i18n/en.ts +123 -0
  26. package/src/tool/avifAJpg/i18n/es.ts +123 -0
  27. package/src/tool/avifAJpg/i18n/fr.ts +118 -0
  28. package/src/tool/avifAJpg/index.ts +29 -0
  29. package/src/tool/avifAJpg/seo.astro +14 -0
  30. package/src/tool/avifAPng/bibliography.astro +14 -0
  31. package/src/tool/avifAPng/component.astro +8 -0
  32. package/src/tool/avifAPng/i18n/en.ts +118 -0
  33. package/src/tool/avifAPng/i18n/es.ts +123 -0
  34. package/src/tool/avifAPng/i18n/fr.ts +118 -0
  35. package/src/tool/avifAPng/index.ts +29 -0
  36. package/src/tool/avifAPng/seo.astro +14 -0
  37. package/src/tool/avifAWebp/bibliography.astro +14 -0
  38. package/src/tool/avifAWebp/component.astro +8 -0
  39. package/src/tool/avifAWebp/i18n/en.ts +118 -0
  40. package/src/tool/avifAWebp/i18n/es.ts +123 -0
  41. package/src/tool/avifAWebp/i18n/fr.ts +118 -0
  42. package/src/tool/avifAWebp/index.ts +29 -0
  43. package/src/tool/avifAWebp/seo.astro +14 -0
  44. package/src/tool/bmpAJpg/bibliography.astro +14 -0
  45. package/src/tool/bmpAJpg/component.astro +8 -0
  46. package/src/tool/bmpAJpg/i18n/en.ts +123 -0
  47. package/src/tool/bmpAJpg/i18n/es.ts +123 -0
  48. package/src/tool/bmpAJpg/i18n/fr.ts +118 -0
  49. package/src/tool/bmpAJpg/index.ts +29 -0
  50. package/src/tool/bmpAJpg/seo.astro +14 -0
  51. package/src/tool/bmpAPng/bibliography.astro +14 -0
  52. package/src/tool/bmpAPng/component.astro +8 -0
  53. package/src/tool/bmpAPng/i18n/en.ts +123 -0
  54. package/src/tool/bmpAPng/i18n/es.ts +123 -0
  55. package/src/tool/bmpAPng/i18n/fr.ts +118 -0
  56. package/src/tool/bmpAPng/index.ts +29 -0
  57. package/src/tool/bmpAPng/seo.astro +14 -0
  58. package/src/tool/bmpAWebp/bibliography.astro +14 -0
  59. package/src/tool/bmpAWebp/component.astro +8 -0
  60. package/src/tool/bmpAWebp/i18n/en.ts +118 -0
  61. package/src/tool/bmpAWebp/i18n/es.ts +123 -0
  62. package/src/tool/bmpAWebp/i18n/fr.ts +118 -0
  63. package/src/tool/bmpAWebp/index.ts +29 -0
  64. package/src/tool/bmpAWebp/seo.astro +14 -0
  65. package/src/tool/gifAJpg/bibliography.astro +14 -0
  66. package/src/tool/gifAJpg/component.astro +8 -0
  67. package/src/tool/gifAJpg/i18n/en.ts +123 -0
  68. package/src/tool/gifAJpg/i18n/es.ts +123 -0
  69. package/src/tool/gifAJpg/i18n/fr.ts +118 -0
  70. package/src/tool/gifAJpg/index.ts +29 -0
  71. package/src/tool/gifAJpg/seo.astro +14 -0
  72. package/src/tool/gifAPng/bibliography.astro +14 -0
  73. package/src/tool/gifAPng/component.astro +8 -0
  74. package/src/tool/gifAPng/i18n/en.ts +123 -0
  75. package/src/tool/gifAPng/i18n/es.ts +123 -0
  76. package/src/tool/gifAPng/i18n/fr.ts +118 -0
  77. package/src/tool/gifAPng/index.ts +29 -0
  78. package/src/tool/gifAPng/seo.astro +14 -0
  79. package/src/tool/gifAWebp/bibliography.astro +14 -0
  80. package/src/tool/gifAWebp/component.astro +8 -0
  81. package/src/tool/gifAWebp/i18n/en.ts +123 -0
  82. package/src/tool/gifAWebp/i18n/es.ts +123 -0
  83. package/src/tool/gifAWebp/i18n/fr.ts +118 -0
  84. package/src/tool/gifAWebp/index.ts +29 -0
  85. package/src/tool/gifAWebp/seo.astro +14 -0
  86. package/src/tool/imagenBase64/bibliography.astro +14 -0
  87. package/src/tool/imagenBase64/component.astro +159 -0
  88. package/src/tool/imagenBase64/i18n/en.ts +137 -0
  89. package/src/tool/imagenBase64/i18n/es.ts +137 -0
  90. package/src/tool/imagenBase64/i18n/fr.ts +132 -0
  91. package/src/tool/imagenBase64/index.ts +43 -0
  92. package/src/tool/imagenBase64/seo.astro +14 -0
  93. package/src/tool/imagenBase64/style.css +299 -0
  94. package/src/tool/jpgAIco/bibliography.astro +14 -0
  95. package/src/tool/jpgAIco/component.astro +8 -0
  96. package/src/tool/jpgAIco/i18n/en.ts +123 -0
  97. package/src/tool/jpgAIco/i18n/es.ts +123 -0
  98. package/src/tool/jpgAIco/i18n/fr.ts +118 -0
  99. package/src/tool/jpgAIco/index.ts +29 -0
  100. package/src/tool/jpgAIco/seo.astro +14 -0
  101. package/src/tool/jpgAPng/bibliography.astro +14 -0
  102. package/src/tool/jpgAPng/component.astro +8 -0
  103. package/src/tool/jpgAPng/i18n/en.ts +128 -0
  104. package/src/tool/jpgAPng/i18n/es.ts +128 -0
  105. package/src/tool/jpgAPng/i18n/fr.ts +123 -0
  106. package/src/tool/jpgAPng/index.ts +29 -0
  107. package/src/tool/jpgAPng/seo.astro +14 -0
  108. package/src/tool/jpgAWebp/bibliography.astro +14 -0
  109. package/src/tool/jpgAWebp/component.astro +8 -0
  110. package/src/tool/jpgAWebp/i18n/en.ts +118 -0
  111. package/src/tool/jpgAWebp/i18n/es.ts +123 -0
  112. package/src/tool/jpgAWebp/i18n/fr.ts +118 -0
  113. package/src/tool/jpgAWebp/index.ts +29 -0
  114. package/src/tool/jpgAWebp/seo.astro +14 -0
  115. package/src/tool/pngAIco/bibliography.astro +14 -0
  116. package/src/tool/pngAIco/component.astro +8 -0
  117. package/src/tool/pngAIco/i18n/en.ts +123 -0
  118. package/src/tool/pngAIco/i18n/es.ts +123 -0
  119. package/src/tool/pngAIco/i18n/fr.ts +118 -0
  120. package/src/tool/pngAIco/index.ts +29 -0
  121. package/src/tool/pngAIco/seo.astro +14 -0
  122. package/src/tool/pngAJpg/bibliography.astro +14 -0
  123. package/src/tool/pngAJpg/component.astro +8 -0
  124. package/src/tool/pngAJpg/i18n/en.ts +133 -0
  125. package/src/tool/pngAJpg/i18n/es.ts +201 -0
  126. package/src/tool/pngAJpg/i18n/fr.ts +128 -0
  127. package/src/tool/pngAJpg/index.ts +29 -0
  128. package/src/tool/pngAJpg/seo.astro +14 -0
  129. package/src/tool/pngAWebp/bibliography.astro +14 -0
  130. package/src/tool/pngAWebp/component.astro +8 -0
  131. package/src/tool/pngAWebp/i18n/en.ts +127 -0
  132. package/src/tool/pngAWebp/i18n/es.ts +132 -0
  133. package/src/tool/pngAWebp/i18n/fr.ts +122 -0
  134. package/src/tool/pngAWebp/index.ts +29 -0
  135. package/src/tool/pngAWebp/seo.astro +14 -0
  136. package/src/tool/svgAJpg/bibliography.astro +14 -0
  137. package/src/tool/svgAJpg/component.astro +8 -0
  138. package/src/tool/svgAJpg/i18n/en.ts +118 -0
  139. package/src/tool/svgAJpg/i18n/es.ts +123 -0
  140. package/src/tool/svgAJpg/i18n/fr.ts +118 -0
  141. package/src/tool/svgAJpg/index.ts +29 -0
  142. package/src/tool/svgAJpg/seo.astro +14 -0
  143. package/src/tool/svgAPng/bibliography.astro +14 -0
  144. package/src/tool/svgAPng/component.astro +8 -0
  145. package/src/tool/svgAPng/i18n/en.ts +123 -0
  146. package/src/tool/svgAPng/i18n/es.ts +128 -0
  147. package/src/tool/svgAPng/i18n/fr.ts +118 -0
  148. package/src/tool/svgAPng/index.ts +29 -0
  149. package/src/tool/svgAPng/seo.astro +14 -0
  150. package/src/tool/webpAIco/bibliography.astro +14 -0
  151. package/src/tool/webpAIco/component.astro +8 -0
  152. package/src/tool/webpAIco/i18n/en.ts +123 -0
  153. package/src/tool/webpAIco/i18n/es.ts +123 -0
  154. package/src/tool/webpAIco/i18n/fr.ts +118 -0
  155. package/src/tool/webpAIco/index.ts +29 -0
  156. package/src/tool/webpAIco/seo.astro +14 -0
  157. package/src/tool/webpAJpg/bibliography.astro +14 -0
  158. package/src/tool/webpAJpg/component.astro +8 -0
  159. package/src/tool/webpAJpg/i18n/en.ts +122 -0
  160. package/src/tool/webpAJpg/i18n/es.ts +127 -0
  161. package/src/tool/webpAJpg/i18n/fr.ts +122 -0
  162. package/src/tool/webpAJpg/index.ts +29 -0
  163. package/src/tool/webpAJpg/seo.astro +14 -0
  164. package/src/tool/webpAPng/bibliography.astro +14 -0
  165. package/src/tool/webpAPng/component.astro +8 -0
  166. package/src/tool/webpAPng/i18n/en.ts +127 -0
  167. package/src/tool/webpAPng/i18n/es.ts +132 -0
  168. package/src/tool/webpAPng/i18n/fr.ts +122 -0
  169. package/src/tool/webpAPng/index.ts +29 -0
  170. package/src/tool/webpAPng/seo.astro +14 -0
  171. package/src/tools.ts +70 -0
  172. package/src/types.ts +69 -0
@@ -0,0 +1,237 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+
4
+ export interface ImageConverterUI {
5
+ [key: string]: string;
6
+ dragText: string;
7
+ convertText: string;
8
+ selectFiles: string;
9
+ processedFiles: string;
10
+ downloadAll: string;
11
+ pending: string;
12
+ bibliographyTitle: string;
13
+ faqTitle: string;
14
+ }
15
+
16
+ interface Props {
17
+ from: string;
18
+ to: string;
19
+ ui: ImageConverterUI;
20
+ }
21
+
22
+ const { from, to, ui } = Astro.props;
23
+
24
+ const formatMap: Record<string, string> = {
25
+ png: 'image/png',
26
+ jpg: 'image/jpeg',
27
+ jpeg: 'image/jpeg',
28
+ webp: 'image/webp',
29
+ svg: 'image/svg+xml',
30
+ avif: 'image/avif',
31
+ gif: 'image/gif',
32
+ ico: 'image/x-icon',
33
+ };
34
+
35
+ const targetFormat = formatMap[(to || 'png').toLowerCase()] || 'image/png';
36
+ const acceptInput = (from || 'jpg') === 'svg' ? '.svg' : 'image/*';
37
+ ---
38
+
39
+ <style is:global>
40
+ @import url('./style.css');
41
+ </style>
42
+
43
+ <div class="image-converter-dashboard animate-in">
44
+ <div
45
+ id="converter-drop-zone"
46
+ class="drop-zone"
47
+ data-from={from}
48
+ data-to={to}
49
+ data-target={targetFormat}
50
+ >
51
+ <div class="upload-icon">
52
+ <Icon name="mdi:image-move" size={96} />
53
+ </div>
54
+ <div class="upload-text">
55
+ <span>{ui.dragText}</span>
56
+ </div>
57
+ <p class="upload-subtext">{ui.convertText}</p>
58
+ <button class="upload-btn">{ui.selectFiles}</button>
59
+ <input type="file" id="converter-input" accept={acceptInput} multiple class="hidden" />
60
+ </div>
61
+
62
+ <div id="converter-list-container" class="converter-file-list hidden">
63
+ <div class="list-summary">
64
+ <h3>{ui.processedFiles}</h3>
65
+ <button id="download-zip" class="upload-btn hidden">
66
+ <Icon name="mdi:zip-box" /> {ui.downloadAll}
67
+ </button>
68
+ </div>
69
+ <div id="converter-items"></div>
70
+ </div>
71
+ </div>
72
+
73
+ <template id="converter-item-template">
74
+ <div class="converter-item">
75
+ <div class="item-preview">
76
+ <div class="thumb-box">
77
+ <img src="" alt="Thumbnail" />
78
+ </div>
79
+ <div class="item-info">
80
+ <span class="item-name"></span>
81
+ <span class="item-size"></span>
82
+ </div>
83
+ </div>
84
+ <div class="item-status">
85
+ <Icon name="mdi:arrow-right-thick" />
86
+ </div>
87
+ <div class="item-actions">
88
+ <span class="status-badge" data-pending={ui.pending}>{ui.pending}</span>
89
+ <a href="#" download class="dl-btn hidden" title="Descargar">
90
+ <Icon name="mdi:download" size={20} />
91
+ </a>
92
+ <span class="final-size"></span>
93
+ </div>
94
+ </div>
95
+ </template>
96
+
97
+ <script>
98
+ import {
99
+ generateId,
100
+ formatBytes,
101
+ processConversion,
102
+ processSvgToRaster,
103
+ } from './logic/converter';
104
+ import type { ConversionItem, ImageFormat } from './logic/converter';
105
+ import JSZip from 'jszip';
106
+
107
+ const dropZone = document.getElementById('converter-drop-zone');
108
+ const fileInput = document.getElementById('converter-input') as HTMLInputElement;
109
+ const container = document.getElementById('converter-list-container');
110
+ const itemsList = document.getElementById('converter-items');
111
+ const template = document.getElementById('converter-item-template') as HTMLTemplateElement;
112
+ const zipBtn = document.getElementById('download-zip') as HTMLButtonElement;
113
+
114
+ const fromExt = dropZone?.dataset.from || '';
115
+ const toExt = dropZone?.dataset.to || '';
116
+ const targetMime = (dropZone?.dataset.target || 'image/png') as ImageFormat;
117
+
118
+ const queue = new Map();
119
+
120
+ async function handleFiles(files: FileList | File[]) {
121
+ if (!files.length) return;
122
+ container?.classList.remove('hidden');
123
+
124
+ const newItems = [];
125
+ for (const file of files) {
126
+ const id = generateId();
127
+ const item = {
128
+ id,
129
+ file,
130
+ originalSize: file.size,
131
+ newSize: 0,
132
+ convertedDataUrl: '',
133
+ targetFormat: targetMime,
134
+ status: 'processing' as const,
135
+ originalWidth: 0,
136
+ originalHeight: 0,
137
+ };
138
+ queue.set(id, item);
139
+ newItems.push(item);
140
+ }
141
+
142
+ renderItems(newItems);
143
+
144
+ for (const item of newItems) {
145
+ if (fromExt === 'svg') {
146
+ await processSvgToRaster(item as ConversionItem, updateUI);
147
+ } else {
148
+ await processConversion(item as ConversionItem, updateUI);
149
+ }
150
+ }
151
+
152
+ checkZipAvailability();
153
+ }
154
+
155
+ function renderItems(items: ConversionItem[]) {
156
+ items.forEach((item) => {
157
+ const clone = template.content.cloneNode(true) as DocumentFragment;
158
+ const div = clone.querySelector('.converter-item') as HTMLElement;
159
+ div.dataset.id = item.id;
160
+
161
+ div.querySelector('.item-name')!.textContent = item.file.name;
162
+ div.querySelector('.item-size')!.textContent = formatBytes(item.originalSize);
163
+
164
+ itemsList?.prepend(div);
165
+ });
166
+ }
167
+
168
+ function updateUI(item: ConversionItem, src: string) {
169
+ const div = itemsList?.querySelector(`[data-id="${item.id}"]`);
170
+ if (!div) return;
171
+
172
+ const img = div.querySelector('.thumb-box img') as HTMLImageElement;
173
+ img.src = src;
174
+
175
+ const badge = div.querySelector('.status-badge') as HTMLElement;
176
+ const dl = div.querySelector('.dl-btn') as HTMLAnchorElement;
177
+ const finalSize = div.querySelector('.final-size') as HTMLElement;
178
+
179
+ if (item.status === 'completed') {
180
+ badge.textContent = (toExt || 'png').toUpperCase();
181
+ badge.className = 'status-badge completed';
182
+ dl.classList.remove('hidden');
183
+ dl.href = item.convertedDataUrl;
184
+
185
+ const base = item.file.name.substring(0, item.file.name.lastIndexOf('.')) || item.file.name;
186
+ dl.download = `${base}.${toExt || 'png'}`;
187
+
188
+ finalSize.textContent = formatBytes(item.newSize);
189
+ } else if (item.status === 'error') {
190
+ badge.textContent = 'Error';
191
+ badge.className = 'status-badge error';
192
+ }
193
+ }
194
+
195
+ function checkZipAvailability() {
196
+ const completed = Array.from(queue.values()).filter((i) => i.status === 'completed');
197
+ if (completed.length >= 2) {
198
+ zipBtn?.classList.remove('hidden');
199
+ }
200
+ }
201
+
202
+ dropZone?.addEventListener('click', () => fileInput?.click());
203
+ dropZone?.addEventListener('dragover', (e) => {
204
+ e.preventDefault();
205
+ dropZone.classList.add('dragover');
206
+ });
207
+ dropZone?.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
208
+ dropZone?.addEventListener('drop', (e) => {
209
+ e.preventDefault();
210
+ dropZone.classList.remove('dragover');
211
+ if (e.dataTransfer?.files) handleFiles(e.dataTransfer.files);
212
+ });
213
+ fileInput?.addEventListener('change', (e) => {
214
+ const target = e.target as HTMLInputElement;
215
+ if (target.files) handleFiles(target.files);
216
+ });
217
+
218
+ zipBtn?.addEventListener('click', async () => {
219
+ const zip = new JSZip();
220
+ queue.forEach((item) => {
221
+ if (item.status === 'completed') {
222
+ const base = item.file.name.substring(0, item.file.name.lastIndexOf('.')) || item.file.name;
223
+ const binary = atob(item.convertedDataUrl.split(',')[1]);
224
+ const array = new Uint8Array(binary.length);
225
+ for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
226
+ zip.file(`${base}.${toExt || 'png'}`, array);
227
+ }
228
+ });
229
+
230
+ const content = await zip.generateAsync({ type: 'blob' });
231
+ const url = URL.createObjectURL(content);
232
+ const a = document.createElement('a');
233
+ a.href = url;
234
+ a.download = `imagenes_${fromExt || 'jpg'}_a_${toExt || 'png'}.zip`;
235
+ a.click();
236
+ });
237
+ </script>
@@ -0,0 +1,167 @@
1
+ export type ImageFormat = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/x-icon' | 'image/svg+xml' | 'image/avif' | 'image/gif';
2
+
3
+ export interface ConversionItem {
4
+ id: string;
5
+ file: File;
6
+ originalSize: number;
7
+ newSize: number;
8
+ convertedDataUrl: string;
9
+ originalWidth: number;
10
+ originalHeight: number;
11
+ targetFormat: ImageFormat;
12
+ status: 'pending' | 'processing' | 'completed' | 'error';
13
+ error?: string;
14
+ }
15
+
16
+ export function formatBytes(bytes: number): string {
17
+ if (bytes === 0) return '0 B';
18
+ const k = 1024;
19
+ const sizes = ['B', 'KB', 'MB', 'GB'];
20
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
21
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
22
+ }
23
+
24
+ export function generateId(): string {
25
+ return Math.random().toString(36).substring(2, 9);
26
+ }
27
+
28
+ function writeIcoHeader(buffer: Uint8Array, pngSize: number, width: number, height: number): void {
29
+ buffer[0] = 0; buffer[1] = 0; buffer[2] = 1; buffer[3] = 0;
30
+ buffer[4] = 1; buffer[5] = 0;
31
+ buffer[6] = width >= 256 ? 0 : width;
32
+ buffer[7] = height >= 256 ? 0 : height;
33
+ buffer[8] = 0; buffer[9] = 0; buffer[10] = 1; buffer[11] = 0;
34
+ buffer[12] = 32; buffer[13] = 0;
35
+ buffer[14] = pngSize & 0xFF; buffer[15] = (pngSize >> 8) & 0xFF;
36
+ buffer[16] = (pngSize >> 16) & 0xFF; buffer[17] = (pngSize >> 24) & 0xFF;
37
+ buffer[18] = 22; buffer[19] = 0; buffer[20] = 0; buffer[21] = 0;
38
+ }
39
+
40
+ async function pngToIcoBase64(pngBase64: string, width: number, height: number): Promise<string> {
41
+ const response = await fetch(pngBase64);
42
+ const pngArray = new Uint8Array(await response.arrayBuffer());
43
+ const buffer = new Uint8Array(22 + pngArray.length);
44
+ writeIcoHeader(buffer, pngArray.length, width, height);
45
+ buffer.set(pngArray, 22);
46
+ const blob = new Blob([buffer], { type: 'image/x-icon' });
47
+ return new Promise((resolve) => {
48
+ const reader = new FileReader();
49
+ reader.onloadend = () => resolve(reader.result as string);
50
+ reader.readAsDataURL(blob);
51
+ });
52
+ }
53
+
54
+ async function convertToIco(img: HTMLImageElement): Promise<string> {
55
+ let size = Math.min(img.width, img.height);
56
+ if (size > 256) size = 256;
57
+ const canvas = document.createElement('canvas');
58
+ canvas.width = size;
59
+ canvas.height = size;
60
+ const ctx = canvas.getContext('2d');
61
+ const sx = (img.width - size) / 2;
62
+ const sy = (img.height - size) / 2;
63
+ ctx?.drawImage(img, sx, sy, size, size, 0, 0, size, size);
64
+ return pngToIcoBase64(canvas.toDataURL('image/png'), size, size);
65
+ }
66
+
67
+ function convertToRaster(img: HTMLImageElement, targetFormat: ImageFormat): string {
68
+ const canvas = document.createElement('canvas');
69
+ canvas.width = img.width;
70
+ canvas.height = img.height;
71
+ const ctx = canvas.getContext('2d');
72
+ if (ctx) {
73
+ if (targetFormat === 'image/jpeg') {
74
+ ctx.fillStyle = '#FFFFFF';
75
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
76
+ }
77
+ ctx.drawImage(img, 0, 0);
78
+ }
79
+ return canvas.toDataURL(targetFormat, 0.92);
80
+ }
81
+
82
+ async function applyConversion(item: ConversionItem, img: HTMLImageElement): Promise<void> {
83
+ try {
84
+ const dataUrl = item.targetFormat === 'image/x-icon'
85
+ ? await convertToIco(img)
86
+ : convertToRaster(img, item.targetFormat);
87
+ item.convertedDataUrl = dataUrl;
88
+ const head = 'data:' + item.targetFormat + ';base64,';
89
+ item.newSize = Math.round(((dataUrl.length - head.length) * 3) / 4);
90
+ item.status = 'completed';
91
+ } catch {
92
+ item.status = 'error';
93
+ item.error = 'Fallo en la conversión';
94
+ }
95
+ }
96
+
97
+ export async function processConversion(
98
+ item: ConversionItem,
99
+ onComplete: (item: ConversionItem, src: string) => void
100
+ ): Promise<void> {
101
+ item.status = 'processing';
102
+ return new Promise((resolve) => {
103
+ const reader = new FileReader();
104
+ reader.onload = (e) => {
105
+ const img = new Image();
106
+ img.onload = async () => {
107
+ item.originalWidth = img.width;
108
+ item.originalHeight = img.height;
109
+ await applyConversion(item, img);
110
+ onComplete(item, img.src);
111
+ resolve();
112
+ };
113
+ img.onerror = () => {
114
+ item.status = 'error';
115
+ item.error = 'Error al cargar imagen';
116
+ onComplete(item, '');
117
+ resolve();
118
+ };
119
+ img.src = e.target?.result as string;
120
+ };
121
+ reader.readAsDataURL(item.file);
122
+ });
123
+ }
124
+
125
+ function renderSvgOnCanvas(img: HTMLImageElement, targetFormat: ImageFormat): string {
126
+ const scale = 2;
127
+ const canvas = document.createElement('canvas');
128
+ canvas.width = (img.width || 800) * scale;
129
+ canvas.height = (img.height || 800) * scale;
130
+ const ctx = canvas.getContext('2d');
131
+ if (ctx) {
132
+ if (targetFormat === 'image/jpeg') {
133
+ ctx.fillStyle = '#FFFFFF';
134
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
135
+ }
136
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
137
+ }
138
+ return canvas.toDataURL(targetFormat, 0.95);
139
+ }
140
+
141
+ export async function processSvgToRaster(
142
+ item: ConversionItem,
143
+ onComplete: (item: ConversionItem, src: string) => void
144
+ ): Promise<void> {
145
+ item.status = 'processing';
146
+ return new Promise((resolve) => {
147
+ const reader = new FileReader();
148
+ reader.onload = (e) => {
149
+ const svgText = e.target?.result as string;
150
+ const blob = new Blob([svgText], { type: 'image/svg+xml' });
151
+ const url = URL.createObjectURL(blob);
152
+ const img = new Image();
153
+ img.onload = () => {
154
+ const dataUrl = renderSvgOnCanvas(img, item.targetFormat);
155
+ item.convertedDataUrl = dataUrl;
156
+ const head = 'data:' + item.targetFormat + ';base64,';
157
+ item.newSize = Math.round(((dataUrl.length - head.length) * 3) / 4);
158
+ item.status = 'completed';
159
+ URL.revokeObjectURL(url);
160
+ onComplete(item, url);
161
+ resolve();
162
+ };
163
+ img.src = url;
164
+ };
165
+ reader.readAsText(item.file);
166
+ });
167
+ }
@@ -0,0 +1,258 @@
1
+ .image-converter-dashboard {
2
+ max-width: 860px;
3
+ margin: 0 auto;
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: 1.5rem;
7
+ }
8
+
9
+ /* ── Drop zone ─────────────────────────────────────────── */
10
+ .drop-zone {
11
+ background: var(--bg-surface);
12
+ border: 2px dashed var(--border-base);
13
+ border-radius: 20px;
14
+ padding: 3.5rem 2rem;
15
+ cursor: pointer;
16
+ transition: border-color 0.2s, background 0.2s;
17
+ display: flex;
18
+ flex-direction: column;
19
+ align-items: center;
20
+ justify-content: center;
21
+ gap: 0.75rem;
22
+ }
23
+
24
+ .drop-zone:hover {
25
+ border-color: var(--accent);
26
+ background: var(--bg-muted);
27
+ }
28
+
29
+ .drop-zone.dragover {
30
+ border-color: var(--accent);
31
+ border-style: solid;
32
+ background: var(--bg-muted);
33
+ }
34
+
35
+ .upload-icon {
36
+ width: 96px;
37
+ height: 96px;
38
+ border-radius: 50%;
39
+ background: var(--accent-bg);
40
+ color: var(--accent);
41
+ display: flex;
42
+ align-items: center;
43
+ justify-content: center;
44
+ margin-bottom: 0.5rem;
45
+ transition: transform 0.3s ease;
46
+ flex-shrink: 0;
47
+ }
48
+
49
+ .drop-zone:hover .upload-icon {
50
+ transform: scale(1.08) rotate(-4deg);
51
+ }
52
+
53
+ .upload-text {
54
+ font-size: 1.3rem;
55
+ font-weight: 700;
56
+ color: var(--text-base);
57
+ margin: 0;
58
+ text-align: center;
59
+ }
60
+
61
+ .upload-subtext {
62
+ color: var(--text-muted);
63
+ font-size: 0.9rem;
64
+ margin: 0;
65
+ text-align: center;
66
+ }
67
+
68
+ .upload-btn {
69
+ background: var(--accent);
70
+ color: var(--text-on-accent);
71
+ padding: 0.7rem 1.75rem;
72
+ border-radius: 50px;
73
+ font-weight: 600;
74
+ font-size: 0.9rem;
75
+ border: none;
76
+ cursor: pointer;
77
+ transition: opacity 0.2s;
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 0.4rem;
81
+ margin-top: 0.5rem;
82
+ }
83
+
84
+ .upload-btn:hover {
85
+ opacity: 0.75;
86
+ }
87
+
88
+ /* ── File list ─────────────────────────────────────────── */
89
+ .converter-file-list {
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: 0.5rem;
93
+ background: var(--bg-surface);
94
+ border: 1px solid var(--border-base);
95
+ border-radius: 16px;
96
+ overflow: hidden;
97
+ padding: 1rem;
98
+ }
99
+
100
+ .list-summary {
101
+ display: flex;
102
+ justify-content: space-between;
103
+ align-items: center;
104
+ padding: 0 0.25rem 0.5rem;
105
+ border-bottom: 1px solid var(--border-base);
106
+ margin-bottom: 0.25rem;
107
+ }
108
+
109
+ .list-summary h3 {
110
+ font-size: 0.8rem;
111
+ font-weight: 700;
112
+ color: var(--text-muted);
113
+ text-transform: uppercase;
114
+ letter-spacing: 0.06em;
115
+ margin: 0;
116
+ }
117
+
118
+ /* ── Converter item ────────────────────────────────────── */
119
+ .converter-item {
120
+ background: var(--bg-surface);
121
+ border-bottom: 1px solid var(--border-base);
122
+ padding: 0.875rem 1rem;
123
+ display: flex;
124
+ align-items: center;
125
+ gap: 1rem;
126
+ }
127
+
128
+ .converter-item:last-child {
129
+ border-bottom: none;
130
+ }
131
+
132
+ .item-preview {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 0.75rem;
136
+ flex: 1;
137
+ min-width: 0;
138
+ }
139
+
140
+ .thumb-box {
141
+ width: 44px;
142
+ height: 44px;
143
+ border-radius: 8px;
144
+ overflow: hidden;
145
+ flex-shrink: 0;
146
+ background: var(--bg-muted);
147
+ }
148
+
149
+ .thumb-box img {
150
+ width: 100%;
151
+ height: 100%;
152
+ object-fit: cover;
153
+ }
154
+
155
+ .item-info {
156
+ display: flex;
157
+ flex-direction: column;
158
+ min-width: 0;
159
+ }
160
+
161
+ .item-name {
162
+ font-weight: 600;
163
+ font-size: 0.875rem;
164
+ color: var(--text-base);
165
+ white-space: nowrap;
166
+ overflow: hidden;
167
+ text-overflow: ellipsis;
168
+ }
169
+
170
+ .item-size {
171
+ font-size: 0.75rem;
172
+ color: var(--text-muted);
173
+ }
174
+
175
+ .item-status {
176
+ display: flex;
177
+ align-items: center;
178
+ color: var(--text-muted);
179
+ flex-shrink: 0;
180
+ }
181
+
182
+ .item-actions {
183
+ display: flex;
184
+ align-items: center;
185
+ gap: 0.75rem;
186
+ flex-shrink: 0;
187
+ }
188
+
189
+ .final-size {
190
+ font-size: 0.8rem;
191
+ font-weight: 600;
192
+ color: var(--text-muted);
193
+ }
194
+
195
+ /* ── Badges ────────────────────────────────────────────── */
196
+ .status-badge {
197
+ padding: 0.2rem 0.6rem;
198
+ border-radius: 20px;
199
+ font-size: 0.7rem;
200
+ font-weight: 700;
201
+ text-transform: uppercase;
202
+ letter-spacing: 0.04em;
203
+ background: var(--bg-muted);
204
+ color: var(--text-muted);
205
+ }
206
+
207
+ .status-badge.completed {
208
+ background: rgba(16, 185, 129, 0.15);
209
+ color: var(--color-success);
210
+ }
211
+
212
+ .status-badge.error {
213
+ background: rgba(244, 63, 94, 0.15);
214
+ color: var(--color-error);
215
+ }
216
+
217
+ /* ── Download button ───────────────────────────────────── */
218
+ .dl-btn {
219
+ background: var(--accent);
220
+ color: var(--text-on-accent);
221
+ width: 34px;
222
+ height: 34px;
223
+ border-radius: 50%;
224
+ display: flex;
225
+ align-items: center;
226
+ justify-content: center;
227
+ transition: opacity 0.2s;
228
+ flex-shrink: 0;
229
+ }
230
+
231
+ .dl-btn:hover {
232
+ opacity: 0.8;
233
+ }
234
+
235
+ /* ── Utility ───────────────────────────────────────────── */
236
+ .hidden {
237
+ display: none;
238
+ }
239
+
240
+ /* ── Mobile ────────────────────────────────────────────── */
241
+ @media (max-width: 600px) {
242
+ .converter-item {
243
+ flex-wrap: wrap;
244
+ }
245
+
246
+ .item-preview {
247
+ flex: 1 1 100%;
248
+ }
249
+
250
+ .item-status {
251
+ display: none;
252
+ }
253
+
254
+ .item-actions {
255
+ flex: 1 1 100%;
256
+ justify-content: flex-end;
257
+ }
258
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ describe('FAQ Content Validation', () => {
5
+ it('all tools have i18n defined', () => {
6
+ ALL_TOOLS.forEach((tool) => {
7
+ expect(tool.entry.i18n).toBeDefined();
8
+ });
9
+ });
10
+ });
@@ -0,0 +1,2 @@
1
+ export default function () { return null; }
2
+
@@ -0,0 +1,22 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as DATA from '../data';
3
+
4
+ const ENTRIES = [
5
+ { id: 'convertersCategory', i18n: DATA.convertersCategory.i18n },
6
+ ];
7
+
8
+ describe('SEO Content Length Validation', () => {
9
+ ENTRIES.forEach((entry) => {
10
+ describe(`Tool: ${entry.id}`, () => {
11
+ Object.keys(entry.i18n).forEach((locale) => {
12
+ it(`${locale}: SEO section should exist`, async () => {
13
+ const loader = (entry.i18n as Record<string, () => Promise<{ seo?: unknown[] }>>)[locale];
14
+ const content = await loader();
15
+ if (!content.seo) return;
16
+ expect(Array.isArray(content.seo)).toBe(true);
17
+ });
18
+ });
19
+ });
20
+ });
21
+ });
22
+