Skip to content

Instantly share code, notes, and snippets.

@pfelipm
Last active June 12, 2025 16:57
Show Gist options
  • Save pfelipm/31a0cb1b37b3687f638a9be5b42946be to your computer and use it in GitHub Desktop.
Save pfelipm/31a0cb1b37b3687f638a9be5b42946be to your computer and use it in GitHub Desktop.
Herramienta web para unir y dividir PDF. Funciona totalmente en local.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Herramientas PDF - Unir y Dividir</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/[email protected]/dist/pdf-lib.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<style>
/* Estilos personalizados para la apariencia */
body {
font-family: 'Inter', sans-serif;
}
.drag-over {
border-color: #3b82f6; /* Azul brillante al arrastrar */
background-color: #eff6ff; /* Azul muy claro */
}
.progress-bar-fill {
transition: width 0.3s ease-in-out;
}
/* Clase que SortableJS aplica al elemento fantasma mientras se arrastra */
.dragging {
opacity: 0.5;
background-color: #dbeafe;
}
/* Estilos para las pestañas */
.tab-btn {
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
border-bottom: 2px solid transparent;
}
.tab-btn.active {
border-bottom-color: #3b82f6; /* Azul */
color: #3b82f6;
font-weight: 600;
}
</style>
</head>
<body class="bg-gray-100 flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-2xl bg-white rounded-2xl shadow-xl p-6 md:p-8">
<div class="mb-6 border-b border-gray-200">
<nav class="-mb-px flex gap-6" aria-label="Tabs">
<button id="tab-merge-btn" class="tab-btn active py-4 px-1 text-gray-500 hover:text-blue-600">Unir PDFs</button>
<button id="tab-split-btn" class="tab-btn py-4 px-1 text-gray-500 hover:text-blue-600">Dividir PDF</button>
</nav>
</div>
<div id="merge-content">
<div class="text-center mb-6">
<h1 class="text-3xl font-bold text-gray-800">Unir Archivos PDF</h1>
<p class="text-gray-500 mt-2">Arrastra y suelta archivos, reordénalos y únelos en un solo PDF.</p>
</div>
<div id="merge-drop-zone" class="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-blue-500 transition-colors">
<input type="file" id="merge-file-input" multiple accept=".pdf" class="hidden">
<div class="text-gray-500">
<!-- Icono Actualizado -->
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
<p class="mt-2">Arrastra y suelta tus archivos PDF aquí</p>
<p class="text-xs text-gray-400 mt-1">o</p>
<button type="button" id="merge-browse-btn" class="mt-2 font-semibold text-blue-600 hover:text-blue-800">Selecciona los archivos</button>
</div>
</div>
<div id="merge-file-list-area" class="mt-6">
<div id="merge-file-list-container"></div>
<div class="text-center mt-4"><button id="merge-clear-all-btn" class="text-sm font-semibold text-red-600 hover:text-red-800 transition-colors hidden">Eliminar Todos</button></div>
</div>
<div id="merge-progress-container" class="w-full bg-gray-200 rounded-full h-2.5 my-4 hidden"><div id="merge-progress-bar" class="bg-blue-600 h-2.5 rounded-full progress-bar-fill" style="width: 0%"></div></div>
<p id="merge-progress-text" class="text-center text-sm text-gray-600 mb-4 hidden"></p>
<div id="merge-error-message" class="mt-4 text-center text-red-600 font-medium hidden"></div>
<div class="mt-8 flex flex-col md:flex-row gap-4">
<button id="merge-btn" class="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed">Unir PDFs</button>
<a id="merge-download-btn" href="#" download="unido.pdf" class="w-full text-center bg-green-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-green-700 transition-colors hidden">Descargar PDF Resultante</a>
</div>
</div>
<div id="split-content" class="hidden">
<div class="text-center mb-6">
<h1 class="text-3xl font-bold text-gray-800">Dividir Archivo PDF</h1>
<p class="text-gray-500 mt-2">Extrae páginas o rangos de un PDF en múltiples archivos.</p>
</div>
<div id="split-file-area">
<div id="split-drop-zone" class="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-blue-500 transition-colors">
<input type="file" id="split-file-input" accept=".pdf" class="hidden">
<div class="text-gray-500">
<!-- Icono Actualizado -->
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
<p class="mt-2">Arrastra y suelta tu archivo PDF aquí</p>
<p class="text-xs text-gray-400 mt-1">o</p>
<button type="button" id="split-browse-btn" class="mt-2 font-semibold text-blue-600 hover:text-blue-800">Selecciona un archivo</button>
</div>
</div>
<div id="split-options-area" class="mt-6 hidden">
<div class="bg-gray-50 p-4 rounded-lg border border-gray-200">
<div class="flex justify-between items-center gap-4">
<div class="min-w-0">
<p class="font-medium text-gray-800 truncate" id="split-file-name"></p>
<p class="text-sm text-gray-500" id="split-file-pages"></p>
</div>
<button id="split-clear-btn" class="text-sm font-semibold text-red-600 hover:text-red-800 flex-shrink-0">Cambiar archivo</button>
</div>
</div>
<div class="mt-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Opciones de División</h3>
<div class="space-y-4">
<div class="flex items-start">
<input id="split-mode-range" name="split-mode" type="radio" class="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500 mt-1" checked>
<div class="ml-3 text-sm">
<label for="split-mode-range" class="font-medium text-gray-700">Extraer rangos</label>
<input type="text" id="split-range-input" class="mt-2 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="Ej: 1-5, 8, 10-">
</div>
</div>
<div class="flex items-start">
<input id="split-mode-all" name="split-mode" type="radio" class="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500 mt-1">
<label for="split-mode-all" class="ml-3 block text-sm font-medium text-gray-700">Dividir todas las páginas</label>
</div>
</div>
</div>
</div>
</div>
<div id="split-progress-container" class="w-full bg-gray-200 rounded-full h-2.5 my-4 hidden"><div id="split-progress-bar" class="bg-blue-600 h-2.5 rounded-full progress-bar-fill" style="width: 0%"></div></div>
<p id="split-progress-text" class="text-center text-sm text-gray-600 mb-4 hidden"></p>
<div id="split-error-message" class="mt-4 text-center text-red-600 font-medium hidden"></div>
<div id="split-action-area" class="mt-8">
<button id="split-btn" class="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed">
Dividir PDF
</button>
</div>
<div id="split-results-area" class="mt-6 hidden">
<h3 class="text-lg font-medium text-gray-900 mb-4">Archivos Generados</h3>
<div id="split-downloads-list" class="space-y-2"></div>
</div>
</div>
<div class="text-right text-xs text-gray-400 mt-8 pt-4 border-t border-gray-200">
Versión 1 (junio 2025)
</div>
</div>
<script>
// --- INICIALIZACIÓN Y VARIABLES GLOBALES ---
const { PDFDocument } = PDFLib;
// Elementos de Pestañas
const tabMergeBtn = document.getElementById('tab-merge-btn');
const tabSplitBtn = document.getElementById('tab-split-btn');
const mergeContent = document.getElementById('merge-content');
const splitContent = document.getElementById('split-content');
// --- LÓGICA DE PESTAÑAS ---
tabMergeBtn.addEventListener('click', () => {
tabMergeBtn.classList.add('active');
tabSplitBtn.classList.remove('active');
mergeContent.classList.remove('hidden');
splitContent.classList.add('hidden');
});
tabSplitBtn.addEventListener('click', () => {
tabSplitBtn.classList.add('active');
tabMergeBtn.classList.remove('active');
splitContent.classList.remove('hidden');
mergeContent.classList.add('hidden');
});
// ========================================================================
// --- SECCIÓN DE UNIR PDF ---
// ========================================================================
{
const dropZone = document.getElementById('merge-drop-zone');
const fileInput = document.getElementById('merge-file-input');
const browseBtn = document.getElementById('merge-browse-btn');
const mergeBtn = document.getElementById('merge-btn');
const downloadBtn = document.getElementById('merge-download-btn');
const fileListContainer = document.getElementById('merge-file-list-container');
const clearAllBtn = document.getElementById('merge-clear-all-btn');
const progressContainer = document.getElementById('merge-progress-container');
const progressBar = document.getElementById('merge-progress-bar');
const progressText = document.getElementById('merge-progress-text');
const errorMessage = document.getElementById('merge-error-message');
let selectedFiles = [];
browseBtn.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); });
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); handleFiles(e.dataTransfer.files); });
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
async function handleFiles(files) {
hideError();
const pdfFiles = Array.from(files).filter(file => file.type === 'application/pdf');
if (pdfFiles.length === 0 && files.length > 0) {
showError("Por favor, selecciona solo archivos PDF.");
return;
}
// Deshabilitar la adición de más archivos mientras se procesan los actuales para evitar conflictos
dropZone.style.pointerEvents = 'none';
browseBtn.disabled = true;
const newFilesData = [];
// Procesar archivos secuencialmente para evitar sobrecargar el navegador
for (const file of pdfFiles) {
try {
const fileArrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(fileArrayBuffer, { ignoreEncryption: true });
newFilesData.push({ file: file, id: crypto.randomUUID(), pageCount: pdfDoc.getPageCount() });
} catch (e) {
console.error("Error al leer el PDF:", file.name, e);
showError(`No se pudo leer "${file.name}". Puede que esté dañado o protegido.`);
// La ejecución continúa con el siguiente archivo
}
}
selectedFiles.push(...newFilesData);
updateFileListUI();
// Rehabilitar la adición de archivos
dropZone.style.pointerEvents = 'auto';
browseBtn.disabled = false;
}
function updateFileListUI() {
fileListContainer.innerHTML = '';
if (selectedFiles.length > 0) {
const list = document.createElement('ul');
list.className = 'flex flex-col gap-2';
selectedFiles.forEach((fileWrapper) => {
const listItem = document.createElement('li');
listItem.setAttribute('data-id', fileWrapper.id);
listItem.className = 'file-item-container flex items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-200 cursor-grab active:cursor-grabbing';
listItem.innerHTML = `
<div class="flex items-center overflow-hidden pointer-events-none">
<svg class="w-5 h-5 text-gray-400 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
<span class="font-medium text-gray-700 truncate">${fileWrapper.file.name}</span>
<span class="ml-2 text-sm text-gray-500 flex-shrink-0">(${fileWrapper.pageCount} ${fileWrapper.pageCount === 1 ? 'pág' : 'págs'})</span>
</div>
<button class="remove-btn text-gray-400 hover:text-red-600 transition-colors">
<svg class="w-5 h-5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
`;
list.appendChild(listItem);
});
fileListContainer.appendChild(list);
document.querySelectorAll('.remove-btn').forEach(button => {
button.addEventListener('click', (e) => {
const idToRemove = e.currentTarget.parentElement.getAttribute('data-id');
selectedFiles = selectedFiles.filter(f => f.id !== idToRemove);
updateFileListUI();
});
});
new Sortable(list, {
animation: 150, ghostClass: 'dragging',
onEnd: function (evt) {
const movedItem = selectedFiles.splice(evt.oldIndex, 1)[0];
selectedFiles.splice(evt.newIndex, 0, movedItem);
}
});
}
clearAllBtn.classList.toggle('hidden', selectedFiles.length === 0);
mergeBtn.disabled = selectedFiles.length < 2;
downloadBtn.classList.add('hidden');
}
clearAllBtn.addEventListener('click', () => {
selectedFiles = [];
// Restablecer el valor del input de archivo es crucial para permitir volver a seleccionar los mismos archivos
// y solucionar el problema de bloqueo de la interfaz de carga.
fileInput.value = null;
updateFileListUI();
// Como medida de seguridad, nos aseguramos de que los controles de carga estén habilitados.
dropZone.style.pointerEvents = 'auto';
browseBtn.disabled = false;
});
function showError(message) { errorMessage.textContent = message; errorMessage.classList.remove('hidden'); }
function hideError() { errorMessage.classList.add('hidden'); }
mergeBtn.addEventListener('click', async () => {
if (selectedFiles.length < 2) { showError("Necesitas al menos 2 archivos PDF para unir."); return; }
setProcessingState(true); hideError();
try {
const mergedPdf = await PDFDocument.create();
let filesProcessed = 0;
for (const fileWrapper of selectedFiles) {
const fileArrayBuffer = await fileWrapper.file.arrayBuffer();
const pdfToMerge = await PDFDocument.load(fileArrayBuffer, { ignoreEncryption: true });
const copiedPages = await mergedPdf.copyPages(pdfToMerge, pdfToMerge.getPageIndices());
copiedPages.forEach((page) => mergedPdf.addPage(page));
filesProcessed++;
updateProgress(filesProcessed, selectedFiles.length);
}
const mergedPdfBytes = await mergedPdf.save();
const blob = new Blob([mergedPdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const totalPages = mergedPdf.getPageCount();
downloadBtn.textContent = `Descargar PDF (${totalPages} ${totalPages === 1 ? 'página' : 'páginas'})`;
downloadBtn.href = url;
downloadBtn.classList.remove('hidden');
} catch (error) {
console.error('Error al unir los PDFs:', error);
showError("Ocurrió un error al procesar los archivos.");
} finally {
setProcessingState(false);
progressText.textContent = "¡Proceso completado!";
}
});
function setProcessingState(isProcessing) {
mergeBtn.disabled = isProcessing; browseBtn.disabled = isProcessing;
dropZone.style.pointerEvents = isProcessing ? 'none' : 'auto';
const sortableInstance = fileListContainer.querySelector('ul')?.Sortable;
if (sortableInstance) { sortableInstance.option('disabled', isProcessing); }
if (isProcessing) {
progressContainer.classList.remove('hidden'); progressText.classList.remove('hidden');
downloadBtn.classList.add('hidden');
document.querySelectorAll('.remove-btn').forEach(btn => btn.disabled = true);
clearAllBtn.disabled = true;
} else {
document.querySelectorAll('.remove-btn').forEach(btn => btn.disabled = false);
clearAllBtn.disabled = false;
}
}
function updateProgress(current, total) {
const percentage = Math.round((current / total) * 100);
progressBar.style.width = `${percentage}%`;
progressText.textContent = `Procesando archivo ${current} de ${total}... (${percentage}%)`;
}
updateFileListUI();
}
// ========================================================================
// --- SECCIÓN DE DIVIDIR PDF ---
// ========================================================================
{
const dropZone = document.getElementById('split-drop-zone');
const fileInput = document.getElementById('split-file-input');
const browseBtn = document.getElementById('split-browse-btn');
const splitBtn = document.getElementById('split-btn');
const clearBtn = document.getElementById('split-clear-btn');
const fileArea = document.getElementById('split-file-area');
const optionsArea = document.getElementById('split-options-area');
const actionArea = document.getElementById('split-action-area');
const resultsArea = document.getElementById('split-results-area');
const downloadsList = document.getElementById('split-downloads-list');
const fileNameEl = document.getElementById('split-file-name');
const filePagesEl = document.getElementById('split-file-pages');
const rangeInput = document.getElementById('split-range-input');
const progressContainer = document.getElementById('split-progress-container');
const progressBar = document.getElementById('split-progress-bar');
const progressText = document.getElementById('split-progress-text');
const errorMessage = document.getElementById('split-error-message');
let sourcePdf = { file: null, doc: null, totalPages: 0 };
function resetUI() {
sourcePdf = { file: null, doc: null, totalPages: 0 };
fileInput.value = '';
rangeInput.value = ''; // Limpiar el campo de rangos
dropZone.classList.remove('hidden');
optionsArea.classList.add('hidden');
actionArea.classList.add('hidden');
resultsArea.classList.add('hidden');
downloadsList.innerHTML = '';
splitBtn.disabled = true;
hideError();
}
browseBtn.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); });
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); handleFile(e.dataTransfer.files); });
fileInput.addEventListener('change', (e) => handleFile(e.target.files));
clearBtn.addEventListener('click', resetUI);
document.getElementById('split-mode-range').addEventListener('change', () => rangeInput.disabled = false);
document.getElementById('split-mode-all').addEventListener('change', () => rangeInput.disabled = true);
async function handleFile(files) {
if (files.length === 0) return;
const file = files[0];
if (file.type !== 'application/pdf') {
showError("Por favor, selecciona un archivo PDF.");
return;
}
hideError();
setProcessingState(true, 'Cargando archivo...');
try {
const fileArrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(fileArrayBuffer, { ignoreEncryption: true });
sourcePdf = { file: file, doc: pdfDoc, totalPages: pdfDoc.getPageCount() };
fileNameEl.textContent = file.name;
filePagesEl.textContent = `${sourcePdf.totalPages} ${sourcePdf.totalPages === 1 ? 'página' : 'páginas'} en total`;
dropZone.classList.add('hidden');
optionsArea.classList.remove('hidden');
actionArea.classList.remove('hidden');
resultsArea.classList.add('hidden');
splitBtn.disabled = false;
} catch (e) {
console.error("Error al leer el PDF:", file.name, e);
showError(`No se pudo leer "${file.name}". Puede que esté dañado.`);
resetUI();
} finally {
setProcessingState(false);
}
}
function parsePageRanges(rangeStr, totalPages) {
const ranges = [];
const parts = rangeStr.split(',').filter(p => p.trim() !== '');
if (parts.length === 0) throw new Error("La lista de páginas está vacía.");
for (const part of parts) {
const trimmedPart = part.trim();
if (trimmedPart.includes('-')) {
let [start, end] = trimmedPart.split('-').map(n => n.trim());
start = start === '' ? 1 : parseInt(start, 10);
end = end === '' ? totalPages : parseInt(end, 10);
if (isNaN(start) || isNaN(end) || start < 1 || end > totalPages || start > end) {
throw new Error(`Rango inválido: "${trimmedPart}"`);
}
ranges.push(Array.from({length: end - start + 1}, (_, i) => start + i));
} else {
const page = parseInt(trimmedPart, 10);
if (isNaN(page) || page < 1 || page > totalPages) {
throw new Error(`Página inválida: "${trimmedPart}"`);
}
ranges.push([page]);
}
}
return ranges;
}
splitBtn.addEventListener('click', async () => {
hideError();
let pageGroups;
const splitModeAll = document.getElementById('split-mode-all').checked;
try {
if (splitModeAll) {
pageGroups = Array.from({ length: sourcePdf.totalPages }, (_, i) => [i + 1]);
} else {
pageGroups = parsePageRanges(rangeInput.value, sourcePdf.totalPages);
}
} catch(e) {
showError(e.message);
return;
}
setProcessingState(true);
downloadsList.innerHTML = '';
try {
const originalFileName = sourcePdf.file.name.replace(/\.pdf$/i, '');
let filesProcessed = 0;
for (const pageGroup of pageGroups) {
const newPdfDoc = await PDFDocument.create();
const pageIndices = pageGroup.map(p => p - 1); // a 0-based
const copiedPages = await newPdfDoc.copyPages(sourcePdf.doc, pageIndices);
copiedPages.forEach(page => newPdfDoc.addPage(page));
const pdfBytes = await newPdfDoc.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
let newFileName;
if (pageGroup.length === 1) {
newFileName = `${originalFileName}_pagina_${pageGroup[0]}.pdf`;
} else {
newFileName = `${originalFileName}_paginas_${pageGroup[0]}-${pageGroup[pageGroup.length - 1]}.pdf`;
}
const link = document.createElement('a');
link.href = url;
link.download = newFileName;
link.className = "block bg-gray-50 p-3 rounded-lg border border-gray-200 text-blue-600 hover:bg-blue-50 hover:border-blue-300 transition-colors";
link.textContent = newFileName;
downloadsList.appendChild(link);
filesProcessed++;
updateProgress(filesProcessed, pageGroups.length);
}
resultsArea.classList.remove('hidden');
} catch (e) {
console.error('Error al dividir el PDF:', e);
showError("Ocurrió un error al dividir el archivo.");
} finally {
setProcessingState(false, "¡Proceso completado!");
}
});
function setProcessingState(isProcessing, text = '') {
splitBtn.disabled = isProcessing;
clearBtn.disabled = isProcessing;
browseBtn.disabled = isProcessing;
dropZone.style.pointerEvents = isProcessing ? 'none' : 'auto';
if (isProcessing) {
progressContainer.classList.remove('hidden');
progressText.classList.remove('hidden');
progressText.textContent = text;
progressBar.style.width = '0%';
} else {
// Cuando el procesamiento termina...
if (text) {
// Si hay un mensaje final (ej. "¡Proceso completado!"), lo muestra.
// El contenedor de progreso se mantiene visible para mostrar este texto.
progressText.textContent = text;
} else {
// Si no hay mensaje final (ej. después de cargar un archivo), oculta los elementos de progreso.
progressContainer.classList.add('hidden');
progressText.classList.add('hidden');
}
}
}
function updateProgress(current, total) {
const percentage = Math.round((current / total) * 100);
progressBar.style.width = `${percentage}%`;
progressText.textContent = `Creando archivo ${current} de ${total}... (${percentage}%)`;
}
function showError(message) { errorMessage.textContent = message; errorMessage.classList.remove('hidden'); }
function hideError() { errorMessage.classList.add('hidden'); }
resetUI();
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment