diff --git a/src/App.vue b/src/App.vue index 3fd315a..006cc61 100644 --- a/src/App.vue +++ b/src/App.vue @@ -29,16 +29,14 @@ ]" > - - - + @@ -99,6 +97,9 @@ :is-open="isPrivacyOpen" @close="isPrivacyOpen = false" /> + + + @@ -109,6 +110,7 @@ import Navbar from './components/Navbar.vue'; import Footer from './components/Footer.vue'; import MobileBottomNav from './components/MobileBottomNav.vue'; import MobileToolsDrawer from './components/MobileToolsDrawer.vue'; +import GlobalLoadingMask from './components/GlobalLoadingMask.vue'; const FeedbackModal = defineAsyncComponent(() => import('./components/FeedbackModal.vue')); const PrivacyModal = defineAsyncComponent(() => import('./components/PrivacyModal.vue')); const GlobalSettingsModal = defineAsyncComponent(() => import('./components/GlobalSettingsModal.vue')); diff --git a/src/components/GlobalLoadingMask.vue b/src/components/GlobalLoadingMask.vue new file mode 100644 index 0000000..bb8c2cb --- /dev/null +++ b/src/components/GlobalLoadingMask.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/src/components/PagePreviewModal.vue b/src/components/PagePreviewModal.vue new file mode 100644 index 0000000..58528df --- /dev/null +++ b/src/components/PagePreviewModal.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/src/locales/de.json b/src/locales/de.json index 47b9498..7038ca0 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1488,5 +1488,20 @@ "next_action_cta_image_to_pdf": "Zu PDF konvertieren ➔", "next_action_badge_general": "Nahtlose Weiterleitung", "next_action_desc_general": "Datei im Browserspeicher gehalten. Nahtlose Weiterleitung ohne erneuten Upload.", - "next_action_desc_merge_paginated": "Zusammengefügte Dokumente haben oft lückenhafte Seitenzahlen. Paginieren Sie das Gesamtdokument fortlaufend neu." -} + "next_action_desc_merge_paginated": "Zusammengefügte Dokumente haben oft lückenhafte Seitenzahlen. Paginieren Sie das Gesamtdokument fortlaufend neu.", + "loading_file": "Loading file...", + "loading_default": "Processing...", + "preview_title": "Seiten-HD-Vorschau", + "zoom_in": "Vergrößern", + "zoom_out": "Verkleinern", + "zoom_reset": "Zurücksetzen", + "fit_screen": "An Bildschirm anpassen", + "preview_rendering": "Hochauflösende Vorschau wird gerendert...", + "close": "Schließen (Esc)", + "compress_target_already_fits": "Originalgröße entspricht bereits dem Ziel. Führe verlustfreie Optimierung durch...", + "compress_target_bisection": "Bisektionssuche läuft [Iteration {current}/{total}]...", + "compress_target_resampling": "Seiten werden mit optimaler Auflösung neu gerendert ({current}/{total})...", + "compress_target_finetune": "Feinabstimmung der Skalierungsparameter...", + "compress_target_boost": "Schärfeparameter werden optimiert...", + "compress_target_done": "Zielgrößen-Komprimierung abgeschlossen" +} \ No newline at end of file diff --git a/src/locales/en.json b/src/locales/en.json index 02ec4f2..29d5657 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1488,5 +1488,20 @@ "next_action_cta_image_to_pdf": "Convert to PDF ➔", "next_action_badge_general": "Seamless Relay", "next_action_desc_general": "File held in browser memory. Seamlessly relay to your next task with zero re-uploading.", - "next_action_desc_merge_paginated": "Merged documents often have fragmented page numbering. Re-paginate the combined document sequentially." -} + "next_action_desc_merge_paginated": "Merged documents often have fragmented page numbering. Re-paginate the combined document sequentially.", + "loading_file": "Loading file...", + "loading_default": "Processing...", + "preview_title": "Page HD Preview", + "zoom_in": "Zoom In", + "zoom_out": "Zoom Out", + "zoom_reset": "Reset Zoom", + "fit_screen": "Fit Screen", + "preview_rendering": "Rendering high-resolution preview...", + "close": "Close (Esc)", + "compress_target_already_fits": "Original size already meets target limit. Running lossless optimization...", + "compress_target_bisection": "Bisection search in progress [iteration {current}/{total}]...", + "compress_target_resampling": "Resampling pages at optimal resolution ({current}/{total})...", + "compress_target_finetune": "Fine-tuning scale parameters to strictly meet target limit...", + "compress_target_boost": "Boosting clarity parameters to maximize visual sharpness...", + "compress_target_done": "Target size compression complete" +} \ No newline at end of file diff --git a/src/locales/es.json b/src/locales/es.json index 6f302f1..0a6b112 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1488,5 +1488,20 @@ "next_action_cta_image_to_pdf": "Convertir a PDF ➔", "next_action_badge_general": "Relevo continuo", "next_action_desc_general": "Archivo retenido en memoria. Continúe con la siguiente tarea sin volver a subir.", - "next_action_desc_merge_paginated": "Los documentos combinados a menudo tienen numeración fragmentada. Vuelva a numerar el documento completo de forma secuencial." -} + "next_action_desc_merge_paginated": "Los documentos combinados a menudo tienen numeración fragmentada. Vuelva a numerar el documento completo de forma secuencial.", + "loading_file": "Loading file...", + "loading_default": "Processing...", + "preview_title": "Vista previa HD", + "zoom_in": "Acercar", + "zoom_out": "Alejar", + "zoom_reset": "Restablecer zoom", + "fit_screen": "Ajustar a la pantalla", + "preview_rendering": "Generando vista previa en alta resolución...", + "close": "Cerrar (Esc)", + "compress_target_already_fits": "El tamaño original ya cumple con el objetivo. Ejecutando optimización sin pérdidas...", + "compress_target_bisection": "Búsqueda por bisección en progreso [iteración {current}/{total}]...", + "compress_target_resampling": "Remuestreando páginas con resolución óptima ({current}/{total})...", + "compress_target_finetune": "Ajustando parámetros para cumplir con el límite objetivo...", + "compress_target_boost": "Optimizando parámetros para maximizar la nitidez...", + "compress_target_done": "Compresión a tamaño objetivo completada" +} \ No newline at end of file diff --git a/src/locales/fr.json b/src/locales/fr.json index a39a307..3e5e626 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1488,5 +1488,20 @@ "next_action_cta_image_to_pdf": "Convertir en PDF ➔", "next_action_badge_general": "Relais continu", "next_action_desc_general": "Fichier conservé en mémoire. Poursuivez votre tâche sans aucun nouveau téléversement.", - "next_action_desc_merge_paginated": "Les documents fusionnés ont souvent une numérotation fragmentée. Re-numérotez le document de manière séquentielle." -} + "next_action_desc_merge_paginated": "Les documents fusionnés ont souvent une numérotation fragmentée. Re-numérotez le document de manière séquentielle.", + "loading_file": "Loading file...", + "loading_default": "Processing...", + "preview_title": "Aperçu HD de la page", + "zoom_in": "Zoom avant", + "zoom_out": "Zoom arrière", + "zoom_reset": "Réinitialiser le zoom", + "fit_screen": "Ajuster à l'écran", + "preview_rendering": "Génération de l'aperçu haute résolution...", + "close": "Fermer (Esc)", + "compress_target_already_fits": "La taille d'origine respecte déjà la cible. Optimisation sans perte en cours...", + "compress_target_bisection": "Recherche par dichotomie en cours [itération {current}/{total}]...", + "compress_target_resampling": "Rééchantillonnage des pages à la résolution optimale ({current}/{total})...", + "compress_target_finetune": "Ajustement fin des paramètres de mise à l'échelle...", + "compress_target_boost": "Amélioration de la netteté visuelle...", + "compress_target_done": "Compression à la taille cible terminée" +} \ No newline at end of file diff --git a/src/locales/zh.json b/src/locales/zh.json index b86accd..c2452b5 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1488,5 +1488,20 @@ "next_action_cta_image_to_pdf": "一键拼成 PDF ➔", "next_action_badge_general": "无缝接力", "next_action_desc_general": "文件已保留在浏览器内存中,零等待直接流转至下一步处理环节。", - "next_action_desc_merge_paginated": "多个原文件拼接后各章节页码已割裂,建议为新文档一键重新编排连续页码。" -} + "next_action_desc_merge_paginated": "多个原文件拼接后各章节页码已割裂,建议为新文档一键重新编排连续页码。", + "loading_file": "正在加载文件...", + "loading_default": "处理中...", + "preview_title": "页面高清预览", + "zoom_in": "放大", + "zoom_out": "缩小", + "zoom_reset": "适应屏幕", + "fit_screen": "适应屏幕", + "preview_rendering": "正在生成高清预览...", + "close": "关闭 (Esc)", + "compress_target_already_fits": "原文件已符合目标大小,正在执行无损优化...", + "compress_target_bisection": "正在智能推演最佳压缩率 (轮次 {current}/{total})...", + "compress_target_resampling": "正在以最优分辨率重采样页面 ({current}/{total})...", + "compress_target_finetune": "正在微调尺寸参数以严格符合目标大小...", + "compress_target_boost": "正在智能提升清晰度参数以最大化画质...", + "compress_target_done": "目标大小压缩完成!" +} \ No newline at end of file diff --git a/src/tools/CompressTool.vue b/src/tools/CompressTool.vue index 3cf7a14..3e38018 100644 --- a/src/tools/CompressTool.vue +++ b/src/tools/CompressTool.vue @@ -485,10 +485,12 @@ import VaultFilePickerModal from '../components/VaultFilePickerModal.vue'; import NextActionBanner from '../components/NextActionBanner.vue'; import DiffPreviewModal from '../components/DiffPreviewModal.vue'; import ResultDeliveryView from '../components/ResultDeliveryView.vue'; +import { useGlobalLoading } from '../utils/useGlobalLoading'; const emit = defineEmits(['send-to-tool']); const workspaceState = inject('workspaceActiveState', null); +const { showLoading, hideLoading } = useGlobalLoading(); const fileInputRef = ref(null); const docBytes = ref(null); @@ -567,12 +569,14 @@ function handleVaultFilesSelected(selectedFiles) { } async function loadFile(file, password = '') { - showNextActions.value = false; - lastExportedFile.value = null; - pendingFileName.value = file.name; - pendingFileObj = file; + showLoading(t('loading_file', 'Loading file...')); + try { + showNextActions.value = false; + lastExportedFile.value = null; + pendingFileName.value = file.name; + pendingFileObj = file; - const rawBuffer = await file.arrayBuffer(); + const rawBuffer = await file.arrayBuffer(); // Security check const security = await verifyPdfSecurity(rawBuffer, password); @@ -633,6 +637,9 @@ async function loadFile(file, password = '') { } catch (e) { originalThumbnailUrl.value = ''; } + } finally { + hideLoading(); + } } async function handlePasswordSubmit(pwd) { diff --git a/src/tools/ImageToPdfTool.vue b/src/tools/ImageToPdfTool.vue index aa8d297..3c31f81 100644 --- a/src/tools/ImageToPdfTool.vue +++ b/src/tools/ImageToPdfTool.vue @@ -188,6 +188,16 @@ class="max-h-full max-w-full object-contain transition-transform duration-200 pointer-events-none" > + + + - - - - + + +
+ + + + +
+ {{ p.sourceName || t('org_source_external_badge') }} @@ -506,6 +525,15 @@ @submit="handlePasswordSubmit" @cancel="handlePasswordCancel" /> + + + @@ -530,7 +558,8 @@ import { Check, X, FilePlus, - FileUp + FileUp, + ZoomIn } from 'lucide-vue-next'; import * as pdfjsLib from 'pdfjs-dist'; import { PDFDocument, PageSizes, degrees } from 'pdf-lib'; @@ -547,6 +576,7 @@ import PasswordModal from '../components/PasswordModal.vue'; import VaultFilePickerModal from '../components/VaultFilePickerModal.vue'; import NextActionBanner from '../components/NextActionBanner.vue'; import ResultDeliveryView from '../components/ResultDeliveryView.vue'; +import PagePreviewModal from '../components/PagePreviewModal.vue'; const emit = defineEmits(['send-to-tool']); @@ -558,6 +588,79 @@ const showNextActions = ref(false); const progressPercent = ref(0); const progressMessage = ref(''); +// Page Preview Modal State +const isPreviewOpen = ref(false); +const isPreviewLoading = ref(false); +const previewImgSrc = ref(''); +const previewRotation = ref(0); + +async function openPreview(idx) { + const p = pages.value[idx]; + if (!p) return; + + isPreviewOpen.value = true; + previewRotation.value = p.rotation || 0; + + if (p.type === 'blank') { + previewImgSrc.value = p.dataUrl; + return; + } + + isPreviewLoading.value = true; + try { + let targetPdf = pdfDoc; + let needDestroy = false; + + if (p.type === 'external' && p.sourceBytes) { + const loadingTask = pdfjsLib.getDocument({ + data: new Uint8Array(p.sourceBytes.slice(0)), + cMapUrl: typeof window !== 'undefined' ? (window.location.origin + '/cmaps/') : '/cmaps/', + cMapPacked: true, + standardFontDataUrl: typeof window !== 'undefined' ? (window.location.origin + '/standard_fonts/') : '/standard_fonts/' + }); + targetPdf = await loadingTask.promise; + needDestroy = true; + } + + if (!targetPdf) return; + + const page = await targetPdf.getPage(p.pageIndex + 1); + const rotation = ((page.rotate || 0) + (p.rotation || 0)) % 360; + const viewport = page.getViewport({ scale: 2.5, rotation }); // Ultra high-res scale (2.5x) + const canvas = document.createElement('canvas'); + canvas.width = Math.floor(viewport.width); + canvas.height = Math.floor(viewport.height); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + await page.render({ + canvasContext: ctx, + viewport, + intent: 'display' + }).promise; + + previewImgSrc.value = canvas.toDataURL('image/png'); + + if (needDestroy) { + try { await targetPdf.destroy(); } catch (e) {} + } + } catch (err) { + logger.error('PREVIEW', 'Failed to generate high-res preview: ' + err.message); + previewImgSrc.value = p.dataUrl; // fallback to low-res + } finally { + isPreviewLoading.value = false; + } +} + +function closePreview() { + isPreviewOpen.value = false; + setTimeout(() => { + previewImgSrc.value = ''; + previewRotation.value = 0; + }, 200); +} + function handleReDownload() { if (!lastExportedFile.value?.arrayBuffer) return; triggerDownload(new Blob([lastExportedFile.value.arrayBuffer], { type: 'application/pdf' }), lastExportedFile.value.name); diff --git a/src/tools/PdfToImageTool.vue b/src/tools/PdfToImageTool.vue index 4e149cf..cf41437 100644 --- a/src/tools/PdfToImageTool.vue +++ b/src/tools/PdfToImageTool.vue @@ -234,12 +234,22 @@ - -
+ +
+ + +
@@ -304,6 +314,14 @@ @submit="handlePasswordSubmit" @cancel="handlePasswordCancel" /> + + + @@ -318,7 +336,8 @@ import { Loader2, FolderLock, Unlock, - RefreshCw + RefreshCw, + ZoomIn } from 'lucide-vue-next'; import * as pdfjsLib from 'pdfjs-dist'; import { t } from '../i18n'; @@ -331,6 +350,7 @@ import { logger } from '../utils/logger'; import PasswordModal from '../components/PasswordModal.vue'; import VaultFilePickerModal from '../components/VaultFilePickerModal.vue'; import ResultDeliveryView from '../components/ResultDeliveryView.vue'; +import PagePreviewModal from '../components/PagePreviewModal.vue'; import { saveFile } from '../utils/vaultDb'; const emit = defineEmits(['send-to-tool']); @@ -367,6 +387,56 @@ const zipProgress = ref(0); const isRenderingPage = ref(null); const isVaultPickerOpen = ref(false); +// Page Preview Modal State +const isPreviewOpen = ref(false); +const isPreviewLoading = ref(false); +const previewImgSrc = ref(''); + +async function openPreview(idx) { + const p = pages.value[idx]; + if (!p) return; + + isPreviewOpen.value = true; + isPreviewLoading.value = true; + + try { + if (!pdfDoc) { + previewImgSrc.value = p.dataUrl; + return; + } + + const page = await pdfDoc.getPage(idx + 1); + const rotation = page.rotate || 0; + const viewport = page.getViewport({ scale: 2.5, rotation }); + const canvas = document.createElement('canvas'); + canvas.width = Math.floor(viewport.width); + canvas.height = Math.floor(viewport.height); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + await page.render({ + canvasContext: ctx, + viewport, + intent: 'display' + }).promise; + + previewImgSrc.value = canvas.toDataURL('image/png'); + } catch (err) { + logger.error('PDF_TO_IMAGE', 'Failed to generate high-res preview: ' + err.message); + previewImgSrc.value = p.dataUrl; + } finally { + isPreviewLoading.value = false; + } +} + +function closePreview() { + isPreviewOpen.value = false; + setTimeout(() => { + previewImgSrc.value = ''; + }, 200); +} + // Output Configuration const outputFormat = ref('png'); // 'png' | 'jpg' const outputDpi = ref(150); // 150 (Standard) | 300 (Print) diff --git a/src/tools/SplitTool.vue b/src/tools/SplitTool.vue index 53af918..11607d6 100644 --- a/src/tools/SplitTool.vue +++ b/src/tools/SplitTool.vue @@ -393,7 +393,7 @@ class="flex-1 my-2 overflow-y-auto min-h-[200px] max-h-[calc(100vh-465px)] pr-1 grid content-start items-start grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8 gap-3 select-none" >
+
+ +
@@ -535,7 +544,6 @@ @close="isVaultPickerOpen = false" /> - + + diff --git a/src/utils/pdfCompress.js b/src/utils/pdfCompress.js index aae58c1..baa19e1 100644 --- a/src/utils/pdfCompress.js +++ b/src/utils/pdfCompress.js @@ -334,9 +334,9 @@ export async function compressPdfToTargetSize(arrayBuffer, targetSizeMb = 2.0, o // we do NOT downsample raster images! We perform lossless optimization to preserve 100% vector fidelity. if (origBytes.byteLength <= targetBytes) { logger.info('COMPRESS_TARGET', `Original size (${origBytes.byteLength} B) <= target (${targetBytes} B). Applying lossless optimization without lossy downsampling.`); - if (onProgress) onProgress(50, 'Original size already meets target limit. Running lossless optimization...'); + if (onProgress) onProgress(50, t('compress_target_already_fits', 'Original size already meets target limit. Running lossless optimization...')); const losslessBytes = await compressPdfLossless(arrayBuffer, password); - if (onProgress) onProgress(100, 'Optimization complete'); + if (onProgress) onProgress(100, t('compress_progress_done', 'Optimization complete')); return losslessBytes; } @@ -422,7 +422,7 @@ export async function compressPdfToTargetSize(arrayBuffer, targetSizeMb = 2.0, o } else { for (let iter = 0; iter < maxIterations; iter++) { const pct = Math.round(10 + (iter / maxIterations) * 20); - if (onProgress) onProgress(pct, `Bisection search in progress [iteration ${iter + 1}/${maxIterations}]...`); + if (onProgress) onProgress(pct, t('compress_target_bisection', { current: iter + 1, total: maxIterations }, `Bisection search in progress [iteration ${iter + 1}/${maxIterations}]...`)); const tMid = (tLow + tHigh) / 2; const estBytes = await probeSampleBytes(tMid); @@ -456,7 +456,7 @@ export async function compressPdfToTargetSize(arrayBuffer, targetSizeMb = 2.0, o for (let i = 1; i <= numPages; i++) { if (onProgress) { const stepPct = Math.round(progressOffset + ((i - 1) / numPages) * progressSpan); - onProgress(stepPct, `Resampling pages at optimal resolution (${i}/${numPages})...`); + onProgress(stepPct, t('compress_target_resampling', { current: i, total: numPages }, `Resampling pages at optimal resolution (${i}/${numPages})...`)); } const page = await pdf.getPage(i); @@ -497,7 +497,7 @@ export async function compressPdfToTargetSize(arrayBuffer, targetSizeMb = 2.0, o // Case A: Actual full document exceeded targetBytes due to complex non-sample pages -> Downward tuning if (outBytes.byteLength > targetBytes) { logger.info('COMPRESS_TARGET', `Actual output (${(outBytes.byteLength / 1048576).toFixed(2)} MB) exceeded target (${targetSizeMb} MB). Executing downward fine-tuning.`); - if (onProgress) onProgress(88, 'Fine-tuning scale parameters to strictly meet target limit...'); + if (onProgress) onProgress(88, t('compress_target_finetune', 'Fine-tuning scale parameters to strictly meet target limit...')); const shrinkRatio = Math.min(0.94, Math.sqrt((effectiveTargetBytes * 0.96) / outBytes.byteLength)); const tunedScale = Math.max(0.60, Number((currentParams.scale * shrinkRatio).toFixed(2))); @@ -509,7 +509,7 @@ export async function compressPdfToTargetSize(arrayBuffer, targetSizeMb = 2.0, o // Case B: Space is under-utilized by > 15% (e.g. < 1.70 MB for 2.0 MB target) -> Upward Clarity Booster else if (outBytes.byteLength < targetBytes * 0.85 && currentParams.scale < 2.50) { logger.info('COMPRESS_TARGET', `Actual output (${(outBytes.byteLength / 1048576).toFixed(2)} MB) is below 85% of target (${targetSizeMb} MB). Boosting clarity to maximize sharpness.`); - if (onProgress) onProgress(88, 'Boosting clarity parameters to maximize visual sharpness...'); + if (onProgress) onProgress(88, t('compress_target_boost', 'Boosting clarity parameters to maximize visual sharpness...')); const boostRatio = Math.min(1.25, Math.sqrt((effectiveTargetBytes * 0.97) / outBytes.byteLength)); const boostedScale = Math.min(2.50, Number((currentParams.scale * boostRatio).toFixed(2))); @@ -533,7 +533,7 @@ export async function compressPdfToTargetSize(arrayBuffer, targetSizeMb = 2.0, o } try { await pdf.destroy(); } catch (e) {} - if (onProgress) onProgress(100, 'Target size compression complete'); + if (onProgress) onProgress(100, t('compress_target_done', 'Target size compression complete')); return outBytes; } diff --git a/src/utils/useGlobalLoading.js b/src/utils/useGlobalLoading.js new file mode 100644 index 0000000..65cca1d --- /dev/null +++ b/src/utils/useGlobalLoading.js @@ -0,0 +1,23 @@ +import { ref } from 'vue'; + +const globalLoading = ref(false); +const loadingMessage = ref(''); + +export function useGlobalLoading() { + const showLoading = (message = 'Processing...') => { + globalLoading.value = true; + loadingMessage.value = message; + }; + + const hideLoading = () => { + globalLoading.value = false; + loadingMessage.value = ''; + }; + + return { + globalLoading, + loadingMessage, + showLoading, + hideLoading + }; +} diff --git a/tests/compressTargetSize.test.js b/tests/compressTargetSize.test.js index 74d166d..e89ab44 100644 --- a/tests/compressTargetSize.test.js +++ b/tests/compressTargetSize.test.js @@ -71,7 +71,7 @@ describe('Target Size Compression & Bisection Engine (Sprint 3.2)', () => { expect(result.byteLength).toBeGreaterThan(0); expect(result.byteLength).toBeLessThanOrEqual(targetSizeMb * 1024 * 1024); // Confirmed lossless path executed - expect(messages.some(m => m.toLowerCase().includes('lossless'))).toBe(true); + expect(messages.some(m => m.toLowerCase().includes('lossless') || m.includes('无损'))).toBe(true); }); it('should dispatch level === "target" via universal compressPdf', async () => {