diff --git a/README.md b/README.md
index 75cdc07..0f9e30a 100644
--- a/README.md
+++ b/README.md
@@ -46,9 +46,9 @@ Why **PDFSeal**? In English, **"Seal"** has a charming double meaning:
- **Smart Compress**: Reduce file size by up to 90% across 4 fine-tuned modes: *Balanced (300 DPI)* for crisp readability; *Extreme (150 DPI)* for tight email caps; *Lossless Object Stream Compression* for structural optimization without rasterization; and **Target Size Bisection** (specify exact thresholds like ≤ 2MB, auto-tuning resolution and quality factors in-browser). All modes are guarded by a Universal Anti-Inflation Size Guard.
- **Watermark & Protect**: High-DPI transparent canvas watermark stamping engine. Supports multilingual typography, custom font sizing, opacity, tilt angles (-90° to 90°), repetitive grid tiling or centered placement, quick color swatches (Official Red, Navy Blue), and custom hex values, with direct chaining into owner-level permission encryption.
- **Dynamic Page Numbering**: Professional document pagination. Supports customizable format macros (`{n}`, `{total}`, `Page {n} of {total}`, etc.), 6 header/footer alignment anchors, odd/even mirror flipping, and cover page skipping (starting page 1 on sheet 2). Features proprietary **Whiteout Masking** (full-ribbon or local bounding box) with canvas color auto-sampling to cleanly conceal misaligned legacy page numbers.
-- **Sign & Stamp**: Engineered for rigorous contract signing. Supports freehand drawing, typography-generated signatures, and uploaded seal images. Proprietary local background flattening automatically strips camera shadows and paper whiteout, with instant ink recoloring (Original, Ink Black, Deep Blue, Stamp Red). One-click batch initial placement across all, even, odd, or non-terminal pages, backed by a persistent local seal library and positioning presets.
+- **Sign & Stamp**: Engineered for rigorous contract signing. Supports freehand drawing, typography-generated signatures, and uploaded seal images. Proprietary local background flattening automatically strips camera shadows and paper whiteout, with instant ink recoloring (Original, Ink Black, Deep Blue, Stamp Red). Features **Smart Auto-Trim Bounding Boxes** to crop away transparent whitespace for pixel-perfect placement, and automatic aspect-ratio preservation for official circular and square seals. One-click batch initial placement across all, even, odd, or non-terminal pages, backed by a persistent local seal library and positioning presets.
- **PDF to Image**: High-fidelity rasterization of PDF pages into crisp PNG or JPG images. Choose between *Standard 150 DPI* for web distribution and *Print-Grade 300 DPI* for archival clarity. Download individual pages directly from thumbnail cards or export the complete set as a zipped archive in one click.
-- **Image to PDF**: Convert batches of PNG, JPG, WebP, and BMP images into a unified multi-page PDF. Offers standardized A4 dimensions (with auto-orientation and proportional centered margins) or adaptive native-image sizing, ideal for expense receipt collation and identity document archiving.
+- **Image to PDF (with Scanner Enhancement)**: Convert batches of PNG, JPG, WebP, and BMP images into a unified multi-page PDF. Includes a proprietary **In-Browser Document Scanner Enhancement Engine (CamScanner-Style)**: $O(W \times H)$ illuminance normalization flattens phone/wrist shadows and whitens yellowish paper backgrounds, with 3 optimized filter modes (*Auto Color* preserving red seals, *Crisp B&W* high-contrast binary text, and *Clean Grayscale*), live real-time thumbnail updates, and interactive **Hold to Compare** original view. Offers standardized A4 dimensions (with auto-orientation and proportional centered margins) or adaptive native-image sizing.
- **Protect & Encrypt**: Modern high-grade AES-256 client-side encryption. Protect documents with an **Open Password (User Password)** to prevent unauthorized reading, and set an **Owner Password** to enforce granular operational restrictions (prohibiting unauthorized printing, text/image extraction, form alterations, and annotating), securing distributed contracts without sending keys to the cloud.
- **Unlock & Strip Restrictions**: Permanently remove encryption locks and permission restrictions. After validating the document password, the client engine restructures the internal object streams to permanently purge encryption dictionaries, yielding a clean, unrestricted, plaintext PDF.
- **Scrub Metadata**: Essential privacy purification for government, corporate audits, and sensitive disclosures. Deeply audits and permanently purges hidden forensic metadata and underlying attributes: document titles, author names, company names, software fingerprints (Word, WPS, InDesign), timestamps, camera GPS coordinates, private application dictionaries (PieceInfo), and embedded thumbnail caches.
@@ -66,7 +66,7 @@ Why **PDFSeal**? In English, **"Seal"** has a charming double meaning:
- **Battle-Tested Presets**:
- 📁 **Tender & Sensitive Document Sanitizer**: Metadata scrubbing $\rightarrow$ Balanced compression $\rightarrow$ Anti-leak watermark stamping.
- 🛡️ **Privacy & Content Redaction**: Metadata scrubbing $\rightarrow$ Keyword/regex PII redaction $\rightarrow$ AES permission encryption.
- - 🧾 **Expense Receipts Auto-Packer**: Multi-image to A4 PDF conversion $\rightarrow$ Compression sized for reimbursement portals.
+ - 🧾 **Expense Receipts Auto-Packer**: Multi-image to A4 PDF conversion (with automatic shadow removal & paper whitening) $\rightarrow$ Audit page numbering $\rightarrow$ Smart compression $\rightarrow$ EXPENSE REPORT watermark.
- 📑 **Contract Batch Stamping & Archive**: Permission unlock $\rightarrow$ Last page signature stamping $\rightarrow$ Security watermark.
- **Visual Drag & Drop Pipeline Builder**:
- Reorder, configure, add, or remove steps with live node compatibility port validation.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2cded17..67052f5 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -46,9 +46,9 @@
- **智能多模压缩 (Smart Compress)**:最高压缩 90% 体积。内置 4 档精细模式:*均衡模式 (300 DPI)* 兼顾画质与体积;*极限模式 (150 DPI)* 极限瘦身适合邮件附件;*无损对象流压缩* 纯净精简内部结构;以及**目标体积智能逼近**(输入硬性指标如 ≤ 2MB,纯本地二分搜索算法自动微调清晰度逼近上限)。全模式均受通用防膨胀体积护栏保护。
- **水印与安全防伪 (Watermark & Protect)**:高 DPI 透明 Canvas 水印压印引擎。支持多语言排版、自定义字号、不透明度、倾斜角度(-90° 至 90°)、平铺或单点居中印压、快捷印章色(印章红、商务蓝等)与 Hex 色值自定义,支持在压印完成后直接连带配置所有者权限加密锁。
- **动态页码编排 (Dynamic Page Numbering)**:专业级文档统一页码规整。支持灵活宏表达式(`{n}`、`{total}`、`Page {n} of {total}`、`第 {n} 页 共 {total} 页`)、6 大页眉/页脚对齐锚点、奇偶页镜像翻转、跳过首页封面(第 2 页起码为 1)。独创**旧页码白底遮罩覆盖**(整行色带或局部小框),支持画布智能吸色匹配背景底色,彻底遮蔽原有错乱页码。
-- **电子签名与盖章 (Sign & Stamp)**:专为严肃公文与合同签署打造。支持手绘手写签名、输入文字生成签名、本地印章与签名照片上传。自研盒式低通平整算法自动去除近景拍摄阴影与纸张白底,支持原色/墨黑/深蓝/公章红重着色。支持一键将简签(Initials)批量盖印至全部页面、偶数页、奇数页或末页,配备 LocalStorage 离线印章库与预设位置。
+- **电子签名与盖章 (Sign & Stamp)**:专为严肃公文与合同签署打造。支持手绘手写签名、输入文字生成签名、本地印章与签名照片上传。自研盒式低通平整算法自动去除近景拍摄阴影与纸张白底,支持原色/墨黑/深蓝/公章红重着色。新增**智能透明留白裁切 (Auto-Trim)** 紧致字迹定界框杜绝边缘误触,自适应按真实自然比例放置圆形/方形公章防压扁变形。支持一键将简签批量盖印至全部页面、偶数页、奇数页或末页,配备 LocalStorage 离线印章库与预设位置。
- **PDF 转图片 (PDF to Image)**:逐页将 PDF 渲染为清晰 PNG 或 JPG 图像。支持*标准 150 DPI* 与*印刷级 300 DPI* 超清画质。可在页面缩略图上直接单页下载预览,亦可一键将全册所有页面完整打包为 ZIP 归档。
-- **图片转 PDF (Image to PDF)**:将 PNG、JPG、WebP、BMP 等图片快速批量拼版合成为多页 PDF。提供标准 A4 统一画幅(支持纵横自适应与等比居中排版)以及原图比例自适应贴合模式,满足票据报销拼版与证件扫描归档。
+- **图片转 PDF / 扫描件增强 (Image to PDF)**:将 PNG、JPG、WebP、BMP 等图片快速批量拼版合成为多页 PDF。内置独创纯前端 **CamScanner 级文档扫描件增强引擎**:基于高效率 $O(W \times H)$ 照度归一化算法,一键抹平手机与手腕投影、消除纸面发黄发灰暗斑并提亮为纯白底色,提供 3 档专业滤镜(*智能彩色*保留公章红印与彩字、*高清黑白*高反差自适应锐化字迹、*清晰灰度*),支持卡片缩略图实时增白与**按住查看原图对比**。提供标准 A4 统一画幅与原图比例自适应贴合模式。
- **加密与权限保护 (Protect & Encrypt)**:现代高强度 AES-256 原生算法加密。支持设置**打开密码 (User Password)** 限制未授权查阅,同时支持设置**所有者管理密码 (Owner Password)** 细粒度锁定操作权限(严禁未授权打印、禁止复制文本与图像、禁止表单修改与批注),确保外发合同机密安全。
- **解密与解除限制 (Unlock & Strip Restrictions)**:永久剥离密码保护与操作权限限制。输入正确密码后,底层引擎重构文档对象流,永久清除加密字典及打印、复制、编辑权限锁,输出一份完全自由干净的明文 PDF。
- **元数据清理 (Scrub Metadata)**:深入扫描并物理擦除 PDF 内部隐藏的所有敏感属性与底层指纹:文档标题、作者名称、制作机构、编辑软件(Word / WPS / Acrobat 等指纹)、创建与修改时间戳、相机 GPS 地理坐标、私有应用字典(PieceInfo)以及内嵌缩略图缓存,100% 杜绝痕迹泄漏。
@@ -66,7 +66,7 @@
- **久经考验的预设流程**:
- 📁 **招投标与涉密文档预处理**:元数据清理 → 均衡压缩 → 防泄漏水印。
- 🛡️ **隐私脱敏与安全归档**:元数据清理 → 敏感信息/PII 规则脱敏 → AES 权限加密。
- - 🧾 **报销票据自动打包**:多图转 A4 PDF → 按报销门户要求压缩体积。
+ - 🧾 **报销票据自动打包**:多图转 A4 PDF(自动去阴影与背景增白)→ 编排审计页码 → 按报销门户要求压缩体积 → 加印 EXPENSE REPORT 凭据水印。
- 📑 **合同批量盖章与归档**:权限解锁 → 末页签名盖章 → 安全水印。
- **可视化拖拽流水线编辑器**:
- 重新排序、配置参数、增删步骤,实时节点兼容性端口校验。
diff --git a/src/locales/de.json b/src/locales/de.json
index d13e77b..67d7096 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -530,6 +530,20 @@
"img2pdf_orient_auto": "Automatisch",
"img2pdf_orient_portrait": "Hochformat",
"img2pdf_orient_landscape": "Querformat",
+ "img2pdf_enhance_title": "Scanner-Verbesserung",
+ "img2pdf_enhance_desc": "Schatten glätten & Papier aufhellen",
+ "img2pdf_filter_label": "Filtermodus",
+ "img2pdf_filter_color": "Auto-Farbe",
+ "img2pdf_filter_bw": "Klares S/W",
+ "img2pdf_filter_grayscale": "Graustufen",
+ "img2pdf_shadow_label": "Schattenentfernung",
+ "img2pdf_shadow_low": "Leicht",
+ "img2pdf_shadow_medium": "Ausgewogen",
+ "img2pdf_shadow_high": "Stark",
+ "img2pdf_badge_enhanced": "Verbessert",
+ "img2pdf_hold_compare": "Gedrückt halten für Original",
+ "img2pdf_original": "Original",
+ "img2pdf_compare": "Vergleich",
"pipeline_title": "Pipeline & Stapelverarbeitung",
"pipeline_subtitle": "Verketten Sie Bereinigung, Komprimierung, Wasserzeichen, Stempeln und Zusammenführen. 100% lokal im Browserspeicher ohne Uploads.",
"pro_desktop_btn": "Pro / Desktop-App",
diff --git a/src/locales/en.json b/src/locales/en.json
index 41809e1..11f558a 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -530,6 +530,20 @@
"img2pdf_orient_auto": "Auto",
"img2pdf_orient_portrait": "Portrait",
"img2pdf_orient_landscape": "Landscape",
+ "img2pdf_enhance_title": "Scanner Enhancement",
+ "img2pdf_enhance_desc": "Flatten phone shadows & whiten paper",
+ "img2pdf_filter_label": "Enhance Filter",
+ "img2pdf_filter_color": "Auto Color",
+ "img2pdf_filter_bw": "Crisp B&W",
+ "img2pdf_filter_grayscale": "Grayscale",
+ "img2pdf_shadow_label": "Shadow Removal",
+ "img2pdf_shadow_low": "Subtle",
+ "img2pdf_shadow_medium": "Balanced",
+ "img2pdf_shadow_high": "Strong",
+ "img2pdf_badge_enhanced": "Enhanced",
+ "img2pdf_hold_compare": "Hold to see original",
+ "img2pdf_original": "Original",
+ "img2pdf_compare": "Hold Compare",
"pipeline_title": "Automated Pipeline & Batch",
"pipeline_subtitle": "Chain sanitization, compression, watermarking, stamping, and merging. Batch process multiple documents 100% locally in browser memory with zero uploads.",
"pro_desktop_btn": "Upgrade Pro / Desktop",
diff --git a/src/locales/es.json b/src/locales/es.json
index 6b0b57d..8dd84b4 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -530,6 +530,20 @@
"img2pdf_orient_auto": "Automático",
"img2pdf_orient_portrait": "Vertical",
"img2pdf_orient_landscape": "Horizontal",
+ "img2pdf_enhance_title": "Mejora de Escáner",
+ "img2pdf_enhance_desc": "Eliminar sombras y blanquear papel",
+ "img2pdf_filter_label": "Filtro de Mejora",
+ "img2pdf_filter_color": "Color Automático",
+ "img2pdf_filter_bw": "B/N Nítido",
+ "img2pdf_filter_grayscale": "Escala de Grises",
+ "img2pdf_shadow_label": "Eliminación de sombras",
+ "img2pdf_shadow_low": "Suave",
+ "img2pdf_shadow_medium": "Equilibrado",
+ "img2pdf_shadow_high": "Fuerte",
+ "img2pdf_badge_enhanced": "Mejorado",
+ "img2pdf_hold_compare": "Mantén presionado para original",
+ "img2pdf_original": "Original",
+ "img2pdf_compare": "Comparar",
"pipeline_title": "Pipeline y Lotes",
"pipeline_subtitle": "Encadene saneamiento, compresión, marcas de agua, sellado y combinación. Procesamiento por lotes 100% local sin subidas.",
"pro_desktop_btn": "Pro / App de escritorio",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index c2fe587..a6806f5 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -530,6 +530,20 @@
"img2pdf_orient_auto": "Automatique",
"img2pdf_orient_portrait": "Portrait",
"img2pdf_orient_landscape": "Paysage",
+ "img2pdf_enhance_title": "Amélioration Scanner",
+ "img2pdf_enhance_desc": "Supprimer les ombres et blanchir le papier",
+ "img2pdf_filter_label": "Filtre d'amélioration",
+ "img2pdf_filter_color": "Couleur Auto",
+ "img2pdf_filter_bw": "N&B Net",
+ "img2pdf_filter_grayscale": "Niveaux de gris",
+ "img2pdf_shadow_label": "Suppression des ombres",
+ "img2pdf_shadow_low": "Léger",
+ "img2pdf_shadow_medium": "Équilibré",
+ "img2pdf_shadow_high": "Fort",
+ "img2pdf_badge_enhanced": "Amélioré",
+ "img2pdf_hold_compare": "Maintenir pour original",
+ "img2pdf_original": "Original",
+ "img2pdf_compare": "Comparer",
"pipeline_title": "Pipeline & Traitement par lots",
"pipeline_subtitle": "Enchaînez anonymisation, compression, filigrane, tampon et fusion. Traitement par lots 100% local en mémoire sans téléversement.",
"pro_desktop_btn": "Pro / Application bureau",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 3c87529..56c906b 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -530,6 +530,20 @@
"img2pdf_orient_auto": "自适应",
"img2pdf_orient_portrait": "纵向",
"img2pdf_orient_landscape": "横向",
+ "img2pdf_enhance_title": "文档扫描增强 (去阴影 / 增白)",
+ "img2pdf_enhance_desc": "抹平手机暗影,纸面纯白提亮",
+ "img2pdf_filter_label": "增强滤镜",
+ "img2pdf_filter_color": "智能彩色 (保留印章)",
+ "img2pdf_filter_bw": "高清黑白",
+ "img2pdf_filter_grayscale": "清晰灰度",
+ "img2pdf_shadow_label": "去阴影强度",
+ "img2pdf_shadow_low": "弱",
+ "img2pdf_shadow_medium": "均衡",
+ "img2pdf_shadow_high": "强力",
+ "img2pdf_badge_enhanced": "已增强",
+ "img2pdf_hold_compare": "按住查看原图",
+ "img2pdf_original": "原图",
+ "img2pdf_compare": "按住对比",
"pipeline_title": "自动化流水线 & 批处理",
"pipeline_subtitle": "一键串联脱敏、压缩、加水印、盖章与合并,对多份文档进行流水线批量处理。100% 浏览器本地运算,零上传,极速安全。",
"pro_desktop_btn": "升级 Pro / 独立桌面版",
diff --git a/src/tools/ImageToPdfTool.vue b/src/tools/ImageToPdfTool.vue
index e5c021b..aa8d297 100644
--- a/src/tools/ImageToPdfTool.vue
+++ b/src/tools/ImageToPdfTool.vue
@@ -160,9 +160,15 @@
@drop="onDropReorder(idx)"
class="bg-white rounded-xl sm:rounded-2xl p-1.5 sm:p-2.5 border border-slate-200/80 shadow-2xs hover:border-violet-400 hover:shadow-md transition flex flex-col justify-between group relative select-none"
>
-
-
- P{{ idx + 1 }}
+
+
+
+ P{{ idx + 1 }}
+
+
+
+ {{ t('img2pdf_badge_enhanced', 'Enhanced') }}
+
@@ -174,13 +180,29 @@
-
-
+
+
![]()
+
+
+
@@ -229,8 +251,64 @@
-
+
+
+
+
+
+
+
+
+
+
+ {{ t('img2pdf_enhance_desc', 'Flatten phone shadows & whiten paper') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('img2pdf_shadow_label', 'Shadow Removal') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('img2pdf_enhance_desc', 'Flatten phone shadows & whiten paper') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('img2pdf_shadow_label', 'Shadow Removal') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('img2pdf_scanner_title') }}
+
+
+ ✨ {{ t('img2pdf_badge_enhanced') }}
+
+
+
+ {{ t('img2pdf_scanner_desc') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('img2pdf_shadow_label') }}
+
+
+
+
+
+
@@ -3026,6 +3092,17 @@ function openStepConfigModal(idx) {
}
}
+ if (step.nodeId === 'node_img2pdf') {
+ if (!editingStepDraft.value.scannerMode) {
+ editingStepDraft.value.scannerMode = editingStepDraft.value.enhanceScanner
+ ? (editingStepDraft.value.enhanceFilter || 'color')
+ : 'none';
+ }
+ if (!editingStepDraft.value.shadowSuppression) {
+ editingStepDraft.value.shadowSuppression = 'medium';
+ }
+ }
+
if (step.nodeId === 'node_protect') {
showProtectUserPwd.value = false;
showProtectOwnerPwd.value = false;
@@ -3182,7 +3259,11 @@ function getStepSummary(step) {
case 'node_img2pdf': {
const merge = step.params.mergeIntoOne ? t('pipe_merge_one', 'Merged') : t('pipe_merge_split', '1 Page/Img');
const sz = step.params.pageSize === 'a4' ? 'A4' : t('pipe_sz_fit', 'Original');
- return `${merge} · ${sz}`;
+ const scannerMode = step.params.scannerMode || (step.params.enhanceScanner ? (step.params.enhanceFilter || 'color') : 'none');
+ const enhance = scannerMode !== 'none'
+ ? ` · ✨ ${t('img2pdf_badge_enhanced') || 'Enhanced'}`
+ : '';
+ return `${merge} · ${sz}${enhance}`;
}
case 'node_pdf2img': {
const fmt = (step.params.format || 'png').toUpperCase();
diff --git a/src/tools/SignTool.vue b/src/tools/SignTool.vue
index b22a297..650dba5 100644
--- a/src/tools/SignTool.vue
+++ b/src/tools/SignTool.vue
@@ -797,6 +797,7 @@ import { userSettings } from '../utils/userSettings';
import { logger } from '../utils/logger';
import {
processImageToTransparentDataUrl,
+ trimTransparentCanvas,
loadSavedStamps,
saveStampToLibrary,
deleteSavedStamp,
@@ -1220,14 +1221,19 @@ function clearDrawCanvas() {
function addDrawnSignature() {
const canvas = drawCanvasRef.value;
if (!canvas || !hasDrawn.value) return;
- const dataUrl = canvas.toDataURL('image/png');
- placeNewSignature(dataUrl, 140, 60);
+ const trimmed = trimTransparentCanvas(canvas, 6);
+ const dataUrl = trimmed.toDataURL('image/png');
+ const aspect = trimmed.width / (trimmed.height || 1);
+ const targetH = 50;
+ const targetW = Math.round(Math.min(220, Math.max(70, targetH * aspect)));
+ placeNewSignature(dataUrl, targetW, targetH);
}
function saveDrawnToLibrary() {
const canvas = drawCanvasRef.value;
if (!canvas || !hasDrawn.value) return;
- const dataUrl = canvas.toDataURL('image/png');
+ const trimmed = trimTransparentCanvas(canvas, 6);
+ const dataUrl = trimmed.toDataURL('image/png');
saveCurrentStamp(dataUrl, 'Handwritten Signature', 'draw');
}
@@ -1255,8 +1261,12 @@ function addTypedSignature() {
ctx.fillText(typedName.value.trim(), 250, 90);
- const dataUrl = canvas.toDataURL('image/png');
- placeNewSignature(dataUrl, 160, 60);
+ const trimmed = trimTransparentCanvas(canvas, 6);
+ const dataUrl = trimmed.toDataURL('image/png');
+ const aspect = trimmed.width / (trimmed.height || 1);
+ const targetH = 45;
+ const targetW = Math.round(Math.min(240, Math.max(80, targetH * aspect)));
+ placeNewSignature(dataUrl, targetW, targetH);
}
function saveTypedToLibrary() {
@@ -1280,7 +1290,8 @@ function saveTypedToLibrary() {
ctx.fillText(typedName.value.trim(), 250, 90);
- const dataUrl = canvas.toDataURL('image/png');
+ const trimmed = trimTransparentCanvas(canvas, 6);
+ const dataUrl = trimmed.toDataURL('image/png');
saveCurrentStamp(dataUrl, typedName.value.trim(), 'type');
}
@@ -1326,12 +1337,48 @@ function onStampFileSelected(e) {
function addUploadedSignature() {
if (!uploadedStampDataUrl.value) return;
- placeNewSignature(uploadedStampDataUrl.value, 140, 65);
+ const img = new Image();
+ img.onload = () => {
+ const aspect = img.naturalWidth / (img.naturalHeight || 1);
+ let targetW = 130;
+ let targetH = Math.round(targetW / aspect);
+ if (targetH > 110) {
+ targetH = 100;
+ targetW = Math.round(targetH * aspect);
+ }
+ placeNewSignature(uploadedStampDataUrl.value, Math.max(40, targetW), Math.max(30, targetH));
+ };
+ img.onerror = () => {
+ placeNewSignature(uploadedStampDataUrl.value, 140, 65);
+ };
+ img.src = uploadedStampDataUrl.value;
}
function saveUploadedToLibrary() {
if (!uploadedStampDataUrl.value) return;
- saveCurrentStamp(uploadedStampDataUrl.value, 'Uploaded Stamp', 'upload');
+ const img = new Image();
+ img.onload = () => {
+ const aspect = img.naturalWidth / (img.naturalHeight || 1);
+ let targetW = 130;
+ let targetH = Math.round(targetW / aspect);
+ if (targetH > 110) {
+ targetH = 100;
+ targetW = Math.round(targetH * aspect);
+ }
+ saveStampToLibrary({
+ title: 'Uploaded Stamp',
+ type: 'upload',
+ dataUrl: uploadedStampDataUrl.value,
+ defaultWidth: Math.max(40, targetW),
+ defaultHeight: Math.max(30, targetH)
+ });
+ refreshSavedStamps();
+ showToast(t('sign_library_saved_toast'));
+ };
+ img.onerror = () => {
+ saveCurrentStamp(uploadedStampDataUrl.value, 'Uploaded Stamp', 'upload');
+ };
+ img.src = uploadedStampDataUrl.value;
}
// ----------------- STAMP LIBRARY ENGINE -----------------
diff --git a/src/utils/imageProcess.js b/src/utils/imageProcess.js
index 33a0f5c..e84f99e 100644
--- a/src/utils/imageProcess.js
+++ b/src/utils/imageProcess.js
@@ -114,9 +114,13 @@ export const INK_COLORS = {
*/
export function applyBackgroundFlattening(imageData, options = {}) {
const { width, height, data } = imageData;
- const threshold = options.threshold ?? 220;
+ let threshold = options.threshold ?? 220;
+ // If threshold is passed as a percentage (e.g. 50..100 from UI slider)
+ if (threshold <= 100) {
+ threshold = Math.round((threshold / 100) * 255);
+ }
const softness = options.softness ?? 25;
- const shadowMode = options.shadowSuppression ?? 'medium';
+ const shadowMode = options.shadowSuppression ?? options.shadowRemoval ?? 'medium';
const inkColorKey = options.inkColor ?? 'original';
const enhanceContrast = options.enhanceContrast ?? true;
@@ -199,19 +203,37 @@ export function applyBackgroundFlattening(imageData, options = {}) {
}
/**
- * Processes an Image (HTMLImageElement, HTMLCanvasElement, or dataURL) in browser
+ * Processes an Image (HTMLImageElement, HTMLCanvasElement, or dataURL string) in browser
* and produces a clean transparent PNG dataURL.
*
- * @param {HTMLImageElement|HTMLCanvasElement} imgElement
- * @param {Object} options
- * @returns {string} Processed transparent PNG dataURL
+ * @param {HTMLImageElement|HTMLCanvasElement|string} imgElementOrUrl
+ * @param {Object} [options={}]
+ * @returns {Promise
} Processed transparent PNG dataURL
*/
-export function processImageToTransparentDataUrl(imgElement, options = {}) {
+export async function processImageToTransparentDataUrl(imgElementOrUrl, options = {}) {
if (typeof document === 'undefined') return '';
- const canvas = document.createElement('canvas');
+ let imgElement = imgElementOrUrl;
+ if (typeof imgElementOrUrl === 'string') {
+ try {
+ const img = new Image();
+ await new Promise((resolve, reject) => {
+ img.onload = resolve;
+ img.onerror = reject;
+ img.src = imgElementOrUrl;
+ });
+ imgElement = img;
+ } catch (e) {
+ return '';
+ }
+ }
+
+ if (!imgElement) return '';
const w = imgElement.naturalWidth || imgElement.width;
const h = imgElement.naturalHeight || imgElement.height;
+ if (!w || !h) return '';
+
+ const canvas = document.createElement('canvas');
// Scale down huge mobile camera photos (e.g. 4000x3000 -> max 1000px) for speed and PDF compactness
const maxDim = 1000;
@@ -236,7 +258,397 @@ export function processImageToTransparentDataUrl(imgElement, options = {}) {
applyBackgroundFlattening(imageData, options);
ctx.putImageData(imageData, 0, 0);
- return canvas.toDataURL('image/png');
+ const shouldTrim = options.trim !== false;
+ const outputCanvas = shouldTrim ? trimTransparentCanvas(canvas) : canvas;
+
+ return outputCanvas.toDataURL('image/png');
+}
+
+/**
+ * Trims transparent border pixels from a canvas, returning a tight-fitting canvas.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * @param {number} [padding=6]
+ * @returns {HTMLCanvasElement}
+ */
+export function trimTransparentCanvas(canvas, padding = 6) {
+ if (typeof document === 'undefined' || !canvas || !canvas.width || !canvas.height) return canvas;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return canvas;
+
+ const w = canvas.width;
+ const h = canvas.height;
+ let imgData;
+ try {
+ imgData = ctx.getImageData(0, 0, w, h);
+ } catch (e) {
+ return canvas;
+ }
+ const data = imgData.data;
+
+ let minX = w;
+ let minY = h;
+ let maxX = -1;
+ let maxY = -1;
+
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const alpha = data[(y * w + x) * 4 + 3];
+ if (alpha > 15) {
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ }
+ }
+
+ // If completely transparent or nothing detected, return original canvas
+ if (maxX < minX || maxY < minY) {
+ return canvas;
+ }
+
+ const cropX = Math.max(0, minX - padding);
+ const cropY = Math.max(0, minY - padding);
+ const cropW = Math.min(w - cropX, (maxX - minX + 1) + padding * 2);
+ const cropH = Math.min(h - cropY, (maxY - minY + 1) + padding * 2);
+
+ const trimmedCanvas = document.createElement('canvas');
+ trimmedCanvas.width = cropW;
+ trimmedCanvas.height = cropH;
+ const tCtx = trimmedCanvas.getContext('2d');
+ if (!tCtx) return canvas;
+
+ tCtx.drawImage(canvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
+ return trimmedCanvas;
+}
+
+/**
+ * Linear-Time O(W*H) Guided Filter for Edge-Preserving Illumination Estimation
+ *
+ * @param {Float32Array} guideAndSrc
+ * @param {number} width
+ * @param {number} height
+ * @param {number} radius
+ * @param {number} [eps=150]
+ * @returns {Float32Array}
+ */
+export function computeGuidedIlluminationMap(guideAndSrc, width, height, radius, eps = 150) {
+ const total = width * height;
+ const mean_p = computeBoxBlurGrayscale(guideAndSrc, width, height, radius);
+
+ const guideSq = new Float32Array(total);
+ for (let i = 0; i < total; i++) {
+ guideSq[i] = guideAndSrc[i] * guideAndSrc[i];
+ }
+ const mean_II = computeBoxBlurGrayscale(guideSq, width, height, radius);
+
+ const a = new Float32Array(total);
+ const b = new Float32Array(total);
+
+ for (let i = 0; i < total; i++) {
+ const mean_i = mean_p[i];
+ const variance = Math.max(0, mean_II[i] - mean_i * mean_i);
+ const aVal = variance / (variance + eps);
+ a[i] = aVal;
+ b[i] = (1 - aVal) * mean_i;
+ }
+
+ const mean_a = computeBoxBlurGrayscale(a, width, height, radius);
+ const mean_b = computeBoxBlurGrayscale(b, width, height, radius);
+
+ const out = new Float32Array(total);
+ for (let i = 0; i < total; i++) {
+ out[i] = Math.max(1, mean_a[i] * guideAndSrc[i] + mean_b[i]);
+ }
+
+ return out;
+}
+
+/**
+ * Fast Anisotropic Horizontal & Vertical Line Detector (O(W*H))
+ * Detects continuous thin lines (e.g. 1px invoice grid lines) to protect from whitening erosion.
+ */
+export function detectThinGridLines(gray, width, height, strokeDelta = 18) {
+ const total = width * height;
+ const isLine = new Uint8Array(total);
+ if (width < 6 || height < 6) return isLine;
+ const minRunLength = 6;
+
+ // Horizontal runs
+ for (let y = 1; y < height - 1; y++) {
+ const rowOffset = y * width;
+ let runStart = -1;
+
+ for (let x = 0; x < width; x++) {
+ const idx = rowOffset + x;
+ const val = gray[idx];
+ const topVal = gray[(y - 1) * width + x];
+ const botVal = gray[(y + 1) * width + x];
+ const isDarker = (topVal - val > strokeDelta) && (botVal - val > strokeDelta);
+
+ if (isDarker) {
+ if (runStart === -1) runStart = x;
+ } else {
+ if (runStart !== -1 && (x - runStart) >= minRunLength) {
+ for (let rx = runStart; rx < x; rx++) isLine[rowOffset + rx] = 1;
+ }
+ runStart = -1;
+ }
+ }
+ if (runStart !== -1 && (width - runStart) >= minRunLength) {
+ for (let rx = runStart; rx < width; rx++) isLine[rowOffset + rx] = 1;
+ }
+ }
+
+ // Vertical runs
+ for (let x = 1; x < width - 1; x++) {
+ let runStart = -1;
+
+ for (let y = 0; y < height; y++) {
+ const idx = y * width + x;
+ const val = gray[idx];
+ const leftVal = gray[y * width + (x - 1)];
+ const rightVal = gray[y * width + (x + 1)];
+ const isDarker = (leftVal - val > strokeDelta) && (rightVal - val > strokeDelta);
+
+ if (isDarker) {
+ if (runStart === -1) runStart = y;
+ } else {
+ if (runStart !== -1 && (y - runStart) >= minRunLength) {
+ for (let ry = runStart; ry < y; ry++) isLine[ry * width + x] = 1;
+ }
+ runStart = -1;
+ }
+ }
+ if (runStart !== -1 && (height - runStart) >= minRunLength) {
+ for (let ry = runStart; ry < height; ry++) isLine[ry * width + x] = 1;
+ }
+ }
+
+ return isLine;
+}
+
+/**
+ * Pure algorithm function for document photo enhancement:
+ * Flattens non-uniform shadows, whitens paper background, and sharpens text clarity.
+ * (CamScanner-style 100% in-browser illumination normalization)
+ *
+ * @param {Object} imageData - { width, height, data: Uint8ClampedArray }
+ * @param {Object} [options={}]
+ * @param {'color'|'bw'|'grayscale'|'original'} [options.mode='color']
+ * @param {'none'|'low'|'medium'|'high'} [options.shadowSuppression='medium']
+ * @param {number} [options.whitenStrength=0.92] - Paper whitening sensitivity (0.7 ~ 1.0)
+ * @returns {Object} Processed imageData
+ */
+export function applyDocumentEnhancement(imageData, options = {}) {
+ if (!imageData || !imageData.data) return imageData;
+ const { width, height, data } = imageData;
+ const mode = options.mode ?? 'color';
+ if (mode === 'original') return imageData;
+
+ const shadowMode = options.shadowSuppression ?? 'medium';
+ const whitenStrength = options.whitenStrength ?? 0.92;
+ const totalPixels = width * height;
+ const gray = new Float32Array(totalPixels);
+
+ // 1. Compute perceptual luminance
+ for (let i = 0; i < totalPixels; i++) {
+ const idx = i * 4;
+ gray[i] = 0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2];
+ }
+
+ // 2. Estimate background illumination map (guided edge-preserving filter)
+ let bgMap = null;
+ let gridLineMap = null;
+
+ if (shadowMode !== 'none') {
+ let radiusRatio = 0.08; // medium default
+ if (shadowMode === 'low') radiusRatio = 0.04;
+ if (shadowMode === 'high') radiusRatio = 0.14;
+
+ const minDim = Math.min(width, height);
+ const maxAllowedRadius = Math.max(1, Math.floor(minDim / 3));
+ const blurRadius = Math.max(1, Math.min(maxAllowedRadius, Math.round(minDim * radiusRatio)));
+
+ // Tier 1: Text-Stroke Decoupling to avoid halo artifacts around text
+ const coarseBg = computeBoxBlurGrayscale(gray, width, height, blurRadius);
+ const guideBuffer = new Float32Array(totalPixels);
+ const inkDelta = 18;
+ for (let i = 0; i < totalPixels; i++) {
+ if (gray[i] < coarseBg[i] - inkDelta) {
+ guideBuffer[i] = coarseBg[i];
+ } else {
+ guideBuffer[i] = gray[i];
+ }
+ }
+
+ // Tier 2: Guided Illumination Filter for sharp boundary tracking
+ bgMap = computeGuidedIlluminationMap(guideBuffer, width, height, blurRadius, 150);
+
+ // Tier 3: Thin 1px grid line detection
+ gridLineMap = detectThinGridLines(gray, width, height, 16);
+ }
+
+ const basePaperThreshold = Math.round(255 - (whitenStrength * 55)); // ~204 by default
+ const bwThresholdRatio = 0.77 + ((1.0 - whitenStrength) * 0.1); // ~0.78 by default
+ const tSafe = 75;
+
+ // 3. Pixel-wise illumination normalization & tone mapping
+ for (let i = 0; i < totalPixels; i++) {
+ const idx = i * 4;
+ const gVal = gray[i];
+ const r = data[idx];
+ const g = data[idx + 1];
+ const b = data[idx + 2];
+
+ const localBg = bgMap ? Math.max(15, bgMap[i]) : 240;
+
+ // Noise-Regulated Soft Division in deep shadow to prevent shot noise amplification
+ const lambdaL = localBg < tSafe ? Math.pow((tSafe - localBg) / tSafe, 2) : 0;
+ const effectiveBg = localBg + lambdaL * 18.0 + 1.0;
+ const normLuma = Math.min(255, Math.round(((gVal + 1.0) / effectiveBg) * 255));
+
+ const isThinGrid = gridLineMap && gridLineMap[i] === 1;
+ const activePaperThreshold = isThinGrid ? Math.min(250, basePaperThreshold + 32) : basePaperThreshold;
+
+ if (mode === 'bw') {
+ // High-contrast clean black & white binary photocopy style
+ const isInk = isThinGrid || ((gVal < localBg * bwThresholdRatio) && (normLuma < 195));
+ const val = isInk ? 0 : 255;
+ data[idx] = val;
+ data[idx + 1] = val;
+ data[idx + 2] = val;
+ } else if (mode === 'grayscale') {
+ // Clean normalized grayscale with whitened paper
+ let val = normLuma;
+ if (normLuma >= activePaperThreshold && !isThinGrid) {
+ val = 255;
+ } else if (normLuma >= 175 && !isThinGrid) {
+ const factor = (normLuma - 175) / (activePaperThreshold - 175);
+ val = Math.min(255, Math.round(175 + factor * (255 - 175)));
+ } else if (normLuma < 130 || isThinGrid) {
+ // Deepen dark text strokes and preserve thin grid lines
+ val = isThinGrid ? Math.round(normLuma * 0.75) : Math.max(0, Math.round(normLuma * 0.85 - 10));
+ }
+ data[idx] = val;
+ data[idx + 1] = val;
+ data[idx + 2] = val;
+ } else {
+ // mode === 'color': Preserve colored seals, stamps and colored ink while whitening paper
+ const maxC = Math.max(r, g, b);
+ const minC = Math.min(r, g, b);
+ const chromaDelta = maxC - minC;
+ const isWarmPaper = (r >= g && g >= b) && (chromaDelta < 22) && (normLuma >= 175);
+ const isChromatic = (chromaDelta > 24) && !isWarmPaper;
+
+ if (!isChromatic && (normLuma >= activePaperThreshold || (normLuma >= 238 && !isThinGrid && chromaDelta < 18 && gVal >= 150))) {
+ // Paper background: Force to clean 255 pure white
+ data[idx] = 255;
+ data[idx + 1] = 255;
+ data[idx + 2] = 255;
+ } else if (!isChromatic && normLuma >= 180 && !isThinGrid) {
+ // Smooth transition zone to pure white paper
+ const factor = (normLuma - 180) / (activePaperThreshold - 180);
+ const gain = 1.0 + factor * ((255 / Math.max(1, gVal)) - 1.0);
+ data[idx] = Math.min(255, Math.round(r * gain));
+ data[idx + 1] = Math.min(255, Math.round(g * gain));
+ data[idx + 2] = Math.min(255, Math.round(b * gain));
+ } else if (isChromatic) {
+ // Uniform Hue-Locked Gamut-Preserving Scaling
+ const gain = Math.min(2.2, Math.max(1.0, Math.pow(255 / effectiveBg, 0.85)));
+ let rCand = r * gain;
+ let gCand = g * gain;
+ let bCand = b * gain;
+
+ const maxCand = Math.max(rCand, gCand, bCand);
+ if (maxCand > 255) {
+ const compress = 255 / maxCand;
+ rCand *= compress;
+ gCand *= compress;
+ bCand *= compress;
+ }
+
+ data[idx] = Math.round(rCand);
+ data[idx + 1] = Math.round(gCand);
+ data[idx + 2] = Math.round(bCand);
+ } else {
+ // Dark text strokes, ink strokes, or thin grid lines
+ if (normLuma < 130 || isThinGrid) {
+ const darken = isThinGrid ? 0.75 : Math.max(0.65, normLuma / 140);
+ data[idx] = Math.round(r * darken);
+ data[idx + 1] = Math.round(g * darken);
+ data[idx + 2] = Math.round(b * darken);
+ } else {
+ const gain = Math.min(1.4, 255 / effectiveBg);
+ data[idx] = Math.min(255, Math.round(r * gain));
+ data[idx + 1] = Math.min(255, Math.round(g * gain));
+ data[idx + 2] = Math.min(255, Math.round(b * gain));
+ }
+ }
+ }
+
+ // Ensure fully opaque document sheet
+ data[idx + 3] = 255;
+ }
+
+ return imageData;
+}
+
+/**
+ * Creates an enhanced document Canvas from an Image or Canvas element
+ *
+ * @param {HTMLImageElement|HTMLCanvasElement} imgElement
+ * @param {Object} [options={}]
+ * @param {number} [options.maxDimension=2400]
+ * @returns {HTMLCanvasElement|null}
+ */
+export function enhanceDocumentCanvas(imgElement, options = {}) {
+ if (typeof document === 'undefined') return null;
+
+ const canvas = document.createElement('canvas');
+ const w = imgElement.naturalWidth || imgElement.width;
+ const h = imgElement.naturalHeight || imgElement.height;
+ if (!w || !h) return null;
+
+ const maxDim = options.maxDimension || 2400;
+ let targetW = w;
+ let targetH = h;
+ if (w > maxDim || h > maxDim) {
+ if (w > h) {
+ targetW = maxDim;
+ targetH = Math.round((h * maxDim) / w);
+ } else {
+ targetH = maxDim;
+ targetW = Math.round((w * maxDim) / h);
+ }
+ }
+
+ canvas.width = targetW;
+ canvas.height = targetH;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(imgElement, 0, 0, targetW, targetH);
+
+ const imageData = ctx.getImageData(0, 0, targetW, targetH);
+ applyDocumentEnhancement(imageData, options);
+ ctx.putImageData(imageData, 0, 0);
+
+ return canvas;
+}
+
+/**
+ * Generates an enhanced thumbnail DataURL for instant card preview
+ *
+ * @param {HTMLImageElement|HTMLCanvasElement} imgElement
+ * @param {Object} [options={}]
+ * @returns {string} JPEG DataURL
+ */
+export function generateEnhancedThumbnail(imgElement, options = {}) {
+ const canvas = enhanceDocumentCanvas(imgElement, {
+ ...options,
+ maxDimension: 360
+ });
+ return canvas ? canvas.toDataURL('image/jpeg', 0.85) : '';
}
/**
diff --git a/src/utils/pipeline/nodes/img2pdfNode.js b/src/utils/pipeline/nodes/img2pdfNode.js
index a790a91..8fffbe2 100644
--- a/src/utils/pipeline/nodes/img2pdfNode.js
+++ b/src/utils/pipeline/nodes/img2pdfNode.js
@@ -1,18 +1,67 @@
import { PDFDocument } from 'pdf-lib';
import { logger } from '../../logger';
+import { enhanceDocumentCanvas } from '../../imageProcess';
+
+function isJpegBuffer(data, mimeType, name) {
+ if (data && data.length >= 4) {
+ if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) return true;
+ if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47) return false;
+ }
+ return mimeType === 'image/jpeg' || mimeType === 'image/jpg' || /\.jpe?g$/i.test(name || '');
+}
+
+async function prepareImageBytes(item, scannerMode, shadowSuppression) {
+ const isJpg = isJpegBuffer(item.data, item.mimeType, item.name);
+ if (!scannerMode || scannerMode === 'none' || typeof document === 'undefined') {
+ return { data: item.data, isJpg };
+ }
+
+ try {
+ const blob = new Blob([item.data], { type: item.mimeType || (isJpg ? 'image/jpeg' : 'image/png') });
+ const blobUrl = URL.createObjectURL(blob);
+ const img = new Image();
+ await new Promise((resolve, reject) => {
+ img.onload = resolve;
+ img.onerror = reject;
+ img.src = blobUrl;
+ });
+ URL.revokeObjectURL(blobUrl);
+
+ const canvas = enhanceDocumentCanvas(img, {
+ mode: scannerMode,
+ shadowSuppression: shadowSuppression || 'medium'
+ });
+ if (!canvas) {
+ return { data: item.data, isJpg };
+ }
+
+ const enhancedDataUrl = canvas.toDataURL('image/jpeg', 0.92);
+ const binary = atob(enhancedDataUrl.split(',')[1]);
+ const enhancedBytes = new Uint8Array(binary.length);
+ for (let k = 0; k < binary.length; k++) {
+ enhancedBytes[k] = binary.charCodeAt(k);
+ }
+ return { data: enhancedBytes, isJpg: true };
+ } catch (err) {
+ logger.warn('PIPELINE_IMG2PDF', `Document enhancement failed for ${item.name}: ${err.message}`);
+ return { data: item.data, isJpg };
+ }
+}
/**
* Headless Image to PDF Node (Reduce or Map)
* Converts image items into PDF documents.
*
* @param {Array