@@ -295,36 +295,20 @@
-
-
+
+
-
-
-
-
-
-
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
{{ 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 () => {