基本实现三类生成的文件去水印

main
mula.liu 2026-02-03 00:45:23 +08:00
parent 552e6f6670
commit 1fbb5d81a6
27 changed files with 2370 additions and 507 deletions

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ yarn-error.log*
# local env files
.env*.local
.env
.gemini-clipboard/

View File

@ -52,6 +52,7 @@ const copyAssetsPlugin = {
if (!existsSync('dist/i18n')) mkdirSync('dist/i18n', { recursive: true });
cpSync('src/i18n', 'dist/i18n', { recursive: true });
cpSync('public', 'dist', { recursive: true });
cpSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs', 'dist/pdf.worker.min.mjs');
} catch (err) {
console.error('❌ Asset copy failed:', err);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
data/IMG_8597.JPG 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
data/天王星.png 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

View File

@ -15,9 +15,12 @@
"serve": "npx serve dist"
},
"dependencies": {
"canvas": "^3.2.1",
"exifr": "^7.1.3",
"jspdf": "^4.1.0",
"jszip": "^3.10.1",
"medium-zoom": "^1.1.0"
"medium-zoom": "^1.1.0",
"pdfjs-dist": "^5.4.624"
},
"devDependencies": {
"esbuild": "^0.24.0"

File diff suppressed because it is too large Load Diff

View File

@ -1,278 +1,274 @@
<!DOCTYPE html>
<html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemini Watermark Remover</title>
<meta name="description" content="Free Gemini watermark remover tool to remove watermarks from Gemini images. — safe, private, and 100% browser-based.">
<meta name="keywords" content="Gemini watermark remover, remove Gemini watermark, Gemini AI image editor, Google AI watermark remover, Gemini watermark fix, watermark cleaner tool, Gemini watermark eraser, free AI watermark remover" />
<title>Gemini Watermark Cleaner</title>
<meta name="description" content="Free Gemini watermark remover tool. Safe, private, and 100% browser-based.">
<link rel="icon" href="data:image/svg+xml,%3Csvg class='h-10 w-10 text-indigo-500' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236366f1' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l-1.9 5.8-5.8 1.9 5.8 1.9 1.9 5.8 1.9-5.8 5.8-1.9-5.8-1.9z'/%3E%3C/svg%3E">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#10B981',
'primary-hover': '#059669',
dark: '#1F2937',
success: '#10B981',
warn: '#F59E0B',
err: '#EF4444',
info: '#3B82F6',
background: 'var(--background)',
surface: 'var(--surface)',
card: 'var(--card)',
border: 'var(--border)',
primary: 'var(--primary)',
secondary: 'var(--secondary)',
accent: '#3B82F6',
},
boxShadow: {
'soft': '0 4px 20px -2px rgba(16, 185, 129, 0.1)',
'card': '0 0 20px rgba(0,0,0,0.05)'
fontFamily: {
sans: ['Inter', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; }
::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #10B981; border-radius: 4px; }
.step-arrow::after {
:root {
--background: #ffffff;
--surface: #f9fafb;
--card: #ffffff;
--border: #e5e7eb;
--primary: #111827;
--secondary: #6b7280;
}
.dark {
--background: #050505;
--surface: #0A0A0A;
--card: #111111;
--border: #262626;
--primary: #EDEDED;
--secondary: #888888;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--background);
color: var(--primary);
}
.noise-layer {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
opacity: 0.04;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
.glass-nav {
backdrop-filter: blur(12px);
}
.dark .glass-nav {
background: rgba(5, 5, 5, 0.8);
border-bottom: 1px solid #262626;
}
.light .glass-nav {
background: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid #e5e7eb;
}
.spotlight-wrapper {
position: relative;
background: rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1px;
box-shadow: 0 0 0 1px rgba(0,0,0,1), 0 20px 40px -20px rgba(0,0,0,0.5);
overflow: hidden;
}
.light .spotlight-wrapper {
background: rgba(0, 0, 0, 0.05);
box-shadow: 0 0 0 1px rgba(0,0,0,0.05), 0 20px 40px -20px rgba(0,0,0,0.1);
}
.drop-zone {
position: relative;
}
.dark .drop-zone {
background: #0A0A0A;
background-image: linear-gradient(#262626 1px, transparent 1px), linear-gradient(90deg, #262626 1px, transparent 1px);
background-size: 40px 40px;
}
.light .drop-zone {
background: #ffffff;
background-image: linear-gradient(#f3f4f6 1px, transparent 1px), linear-gradient(90deg, #f3f4f6 1px, transparent 1px);
background-size: 40px 40px;
}
.dark .drop-zone::after {
content: '';
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
border-top: 15px solid transparent;
border-bottom: 15px solid transparent;
border-left: 15px solid #F3F4F6;
z-index: 10;
inset: 0;
background: radial-gradient(circle at center, transparent 20%, #0A0A0A 100%);
pointer-events: none;
}
@media (min-width: 768px) {
.step-item:not(:last-child)::after {
content: '';
position: absolute;
right: -24px;
top: 0;
bottom: 0;
width: 24px;
background-color: inherit;
clip-path: polygon(0% 0%, 100% 50%, 0% 100%);
z-index: 20;
}
.step-item:not(:first-child) {
clip-path: polygon(0% 0%, 200% 0%, 100% 100%, 0% 100%, 24px 50%);
}
.light .drop-zone::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at center, transparent 20%, #ffffff 100%);
pointer-events: none;
}
body.loading { opacity: 0; }
body { transition: opacity 0s; }
.preview-bg {
background-size: 20px 20px;
}
.dark .preview-bg {
background-color: #111;
background-image: radial-gradient(#333 1px, transparent 1px);
}
.light .preview-bg {
background-color: #f9fafb;
background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
}
/* Ensure zoom preview is on top of everything */
.medium-zoom-overlay,
.medium-zoom-image--opened {
z-index: 999;
}
.medium-zoom-overlay {
backdrop-filter: blur(4px);
z-index: 9999 !important;
}
</style>
</head>
<body class="bg-white text-gray-800 antialiased selection:bg-primary selection:text-white flex flex-col min-h-screen loading">
<body class="min-h-screen flex flex-col relative overflow-x-hidden bg-background text-primary dark:bg-background dark:text-primary">
<div class="noise-layer"></div>
<header class="sticky top-0 z-50 bg-white/90 backdrop-blur-md border-b border-gray-100">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div class="flex items-center gap-1">
<svg class="h-10 w-10 text-indigo-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 3-1.9 5.8-5.8 1.9 5.8 1.9 1.9 5.8 1.9-5.8 5.8-1.9-5.8-1.9Z"></path>
</svg>
<h1 class="text-sm font-bold tracking-tight text-primary md:text-xl">
<a href="./" class="hover:text-primary transition-colors" title="Gemini Watermark Remover">Gemini <span class="text-gray-700 font-medium">Watermark Remover</span></a>
</h1>
</div>
<nav class="flex gap-2 md:gap-6 text-sm font-medium text-gray-600 items-center">
<a href="userscript/gemini-watermark-remover.user.js" target="_blank" class="hidden md:inline-block hover:text-primary transition-colors" data-i18n="nav.userscript">油猴脚本</a>
<a href="https://allenkuo.medium.com/removing-gemini-ai-watermarks-a-deep-dive-into-reverse-alpha-blending-bbbd83af2a3f" target="_blank" rel="noopener noreferrer" class="hidden md:inline-block hover:text-primary transition-colors" data-i18n="nav.principle">去水印原理</a>
<a href="https://github.com/journey-ad/gemini-watermark-remover" target="_blank" rel="noopener noreferrer" class="hover:text-primary transition-colors">GitHub</a>
<button id="langSwitch" class="px-3 py-1 text-nowrap border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">EN</button>
</nav>
<header class="glass-nav sticky top-0 z-50 h-16 flex items-center justify-between px-6 lg:px-12">
<div class="flex items-center gap-3">
<div class="w-2 h-2 bg-primary rounded-sm"></div>
<h1 class="font-mono text-sm font-medium tracking-tight">GEMINI WATERMARK CLEANER</h1>
</div>
<nav class="flex items-center gap-6 text-xs md:text-sm font-medium text-secondary">
<button id="themeToggle" class="p-2 border border-border rounded hover:bg-surface text-primary transition-colors">
<svg id="themeIcon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path id="themeIconPath" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
</button>
<a href="https://github.com/journey-ad/gemini-watermark-remover" target="_blank" class="hover:text-primary transition-colors">GitHub</a>
<button id="langSwitch" class="px-3 py-1 border border-border rounded hover:bg-surface text-primary transition-colors">EN</button>
</nav>
</header>
<main class="flex-grow">
<section class="relative pt-16 pb-12 lg:pt-24 lg:pb-20 text-center px-4 overflow-hidden">
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-emerald-50 via-white to-white -z-10"></div>
<main class="flex-grow container mx-auto px-4 py-12 md:py-20 z-10 relative max-w-5xl">
<h2 class="bg-clip-text bg-gradient-to-br font-extrabold from-slate-900 mb-6 md:text-6xl text-3xl text-transparent to-slate-700 tracking-tighter" data-i18n="main.title">
Gemini AI 图像去水印
<!-- Hero Section -->
<section class="text-center mb-16 space-y-6">
<h2 class="text-4xl md:text-6xl font-medium tracking-tight bg-clip-text text-transparent bg-gradient-to-b from-primary to-secondary" data-i18n="main.title">
Restore Clarity.
</h2>
<p class="text-base md:text-lg text-gray-500 max-w-2xl mx-auto mb-10" data-i18n="main.subtitle">
基于反向 Alpha 混合算法,纯浏览器本地处理,免费、极速、无损
<p class="text-secondary text-base md:text-lg max-w-2xl mx-auto font-light" data-i18n="main.subtitle">
Remove Gemini & NotebookLM watermarks instantly. Local processing, privacy-first.
</p>
</section>
<div class="max-w-4xl mx-auto bg-white rounded-2xl shadow-soft p-2 md:p-3 border border-emerald-100">
<div id="uploadArea" class="group relative flex flex-col items-center justify-center w-full h-64 border-2 border-dashed border-emerald-200 rounded-xl bg-emerald-50/30 hover:bg-emerald-50 hover:border-emerald-400 transition-all cursor-pointer">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<div class="w-16 h-16 bg-white rounded-full flex items-center justify-center shadow-sm mb-4 group-hover:scale-110 transition-transform duration-300">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M 22.498 20.365 L 22.498 4.247 C 22.498 2.983 21.461 1.946 20.197 1.946 L 4.079 1.946 C 2.815 1.946 1.778 2.983 1.778 4.247 L 1.778 20.365 C 1.778 21.629 2.815 22.666 4.079 22.666 L 20.197 22.666 C 21.461 22.666 22.498 21.629 22.498 20.365 Z M 8.111 14.032 L 10.987 17.483 L 15.014 12.306 L 20.197 19.214 L 4.079 19.214 L 8.111 14.032 Z" fill="currentColor"></path></svg>
<!-- Upload Area -->
<section class="mb-20">
<div class="spotlight-wrapper max-w-2xl mx-auto group">
<div id="uploadArea" class="drop-zone h-64 md:h-80 rounded-xl flex flex-col items-center justify-center cursor-pointer transition-colors hover:border-accent/50">
<div class="z-10 flex flex-col items-center gap-4 transition-transform group-hover:-translate-y-1 duration-300">
<div class="w-12 h-12 rounded-xl bg-card border border-border flex items-center justify-center text-secondary">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
</div>
<div class="text-center">
<p class="text-primary font-medium mb-1" data-i18n="upload.text">Drop files here or click to upload</p>
<p class="text-secondary text-xs font-mono bg-surface px-2 py-1 rounded" data-i18n="upload.hint">JPG, PNG, WEBP, PDF</p>
</div>
<p class="mb-2 text-lg font-medium text-gray-700" data-i18n="upload.text">点击选择 或 拖拽图片至此</p>
<p class="text-sm text-gray-400" data-i18n="upload.hint">支持 JPG, PNG, WebP</p>
</div>
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/webp" multiple class="hidden" />
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/webp,application/pdf" multiple class="hidden" />
</div>
</div>
</section>
<section class="max-w-6xl mx-auto px-4 mb-8 md:mb-20">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-10 text-center">
<div class="step-item relative bg-emerald-50 h-16 md:h-24 flex items-center justify-start px-6 md:pl-16 rounded-lg md:rounded-none md:first:rounded-l-lg md:last:rounded-r-lg">
<div class="w-8 h-8 rounded-full bg-emerald-200 text-emerald-800 font-bold flex items-center justify-center mr-3 text-sm">1</div>
<span class="font-medium flex-1 md:flex-none text-gray-800" data-i18n="step.1">选择原始图片</span>
</div>
<div class="step-item relative bg-emerald-50 h-16 md:h-24 flex items-center justify-start px-6 md:pl-16 rounded-lg md:rounded-none">
<div class="w-8 h-8 rounded-full bg-emerald-200 text-emerald-800 font-bold flex items-center justify-center mr-3 text-sm">2</div>
<span class="font-medium flex-1 md:flex-none text-gray-800" data-i18n="step.2">算法自动解析</span>
</div>
<div class="step-item relative bg-emerald-50 h-16 md:h-24 flex items-center justify-start px-6 md:pl-16 rounded-lg md:rounded-none md:last:rounded-r-lg">
<div class="w-8 h-8 rounded-full bg-emerald-200 text-emerald-800 font-bold flex items-center justify-center mr-3 text-sm">3</div>
<span class="font-medium flex-1 md:flex-none text-gray-800" data-i18n="step.3">保存无水印图</span>
</div>
</div>
</section>
<section id="singlePreview" class="max-w-7xl mx-auto px-4 pb-24" style="display: none;">
<div class="flex flex-col lg:flex-row gap-8">
<div class="flex-1 space-y-8">
<div class="bg-white rounded-2xl shadow-card overflow-hidden border border-gray-100">
<div class="bg-gray-50 px-6 py-3 border-b border-gray-100 flex justify-between items-center">
<h3 class="font-semibold text-gray-700 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-red-400"></span> <span data-i18n="preview.original">原图预览</span>
</h3>
<span id="originalInfo" class="text-xs text-gray-400 font-mono"></span>
</div>
<div class="h-[200px] md:h-[500px] p-4 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iI2Y5ZmRmZCI+PHJlY3Qgd2lkdGg9IjEwIiBoZWlnaHQ9IjEwIiBmaWxsPSIjZjJmMmYyIi8+PHJlY3QgeD0iMTAiIHk9IjEwIiB3aWR0aD0iMTAiIGhlaWdodD0iMTAiIGZpbGw9IiNmMmYyZjIiLz48L3N2Zz4=')]">
<img id="originalImage" class="max-w-full max-h-full mx-auto rounded-lg shadow-sm block" data-zoomable />
</div>
<!-- Previews -->
<section id="singlePreview" class="hidden mb-20 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="grid lg:grid-cols-2 gap-6">
<!-- Original -->
<div class="bg-card border border-border rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-border flex justify-between items-center bg-surface">
<span class="text-xs font-mono text-secondary" data-i18n="preview.original">ORIGINAL</span>
<span id="originalInfo" class="text-[10px] font-mono text-secondary/50"></span>
</div>
<div id="processedSection" class="bg-white rounded-2xl shadow-card overflow-hidden border border-gray-100 ring-4 ring-emerald-50 scroll-mt-24" style="display: none;">
<div class="bg-emerald-50/50 px-6 py-3 border-b border-emerald-100 flex justify-between items-center">
<h3 class="font-semibold text-emerald-800 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-primary"></span> <span data-i18n="preview.result">处理结果</span>
</h3>
<span id="processedInfo" class="text-xs text-emerald-600 font-mono"></span>
</div>
<div class="h-[200px] md:h-[500px] p-4 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iI2Y5ZmRmZCI+PHJlY3Qgd2lkdGg9IjEwIiBoZWlnaHQ9IjEwIiBmaWxsPSIjZjJmMmYyIi8+PHJlY3QgeD0iMTAiIHk9IjEwIiB3aWR0aD0iMTAiIGhlaWdodD0iMTAiIGZpbGw9IiNmMmYyZjIiLz48L3N2Zz4=')]">
<img id="processedImage" class="max-w-full max-h-full mx-auto rounded-lg shadow-sm block" data-zoomable />
</div>
<div class="h-64 md:h-96 preview-bg flex items-center justify-center p-4">
<img id="originalImage" class="max-w-full max-h-full rounded shadow-lg" data-zoomable />
</div>
</div>
<div class="w-full lg:w-80 flex-shrink-0">
<div class="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 sticky top-24">
<h4 class="text-lg font-bold text-gray-900 mb-6" data-i18n="panel.title">操作面板</h4>
<div class="space-y-3">
<button id="downloadBtn" class="w-full py-3.5 px-4 bg-gray-900 hover:bg-black text-white rounded-xl font-medium transition-all flex items-center justify-center gap-2" style="display: none;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
<span data-i18n="btn.download">下载结果</span>
</button>
<button id="resetBtn" class="w-full py-3.5 px-4 bg-white border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 rounded-xl font-medium transition-all flex items-center justify-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
<span data-i18n="btn.reset">重置 / 更换图片</span>
</button>
</div>
<div id="statusMessage" class="mt-6 text-sm text-gray-500 min-h-[1.25rem]"></div>
<!-- Result -->
<div id="processedSection" class="bg-card border border-border rounded-xl overflow-hidden hidden">
<div class="px-4 py-3 border-b border-border flex justify-between items-center bg-surface">
<span class="text-xs font-mono text-accent" data-i18n="preview.result">CLEANED</span>
<span id="processedInfo" class="text-[10px] font-mono text-secondary/50"></span>
</div>
<div class="h-64 md:h-96 preview-bg flex items-center justify-center p-4 relative">
<img id="processedImage" class="max-w-full max-h-full rounded shadow-lg" data-zoomable />
</div>
</div>
</div>
</section>
<section id="multiPreview" class="max-w-7xl mx-auto px-4 pb-24 scroll-mt-24" style="display: none;">
<div class="mb-6 flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-900">
<span id="progressText">处理进度: 0/0</span>
</h3>
<button id="downloadAllBtn" class="py-2.5 px-6 bg-gray-900 hover:bg-gray-800 text-white rounded-xl font-medium transition-all flex items-center gap-2" style="display: none;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
<span data-i18n="btn.downloadAll">全部下载</span>
<!-- Actions -->
<div class="flex flex-col sm:flex-row justify-center gap-4 pt-4">
<button id="resetBtn" class="px-6 py-2.5 rounded-lg border border-border text-secondary hover:text-primary hover:border-secondary transition-all text-sm font-medium" data-i18n="btn.reset">
Reset
</button>
<button id="downloadBtn" class="px-6 py-2.5 rounded-lg bg-primary text-background hover:opacity-90 transition-all text-sm font-medium flex items-center justify-center gap-2 hidden" data-i18n="btn.download">
Download Result
</button>
</div>
<div id="imageList" class="space-y-4"></div>
<div id="statusMessage" class="text-center text-xs font-mono text-secondary h-4"></div>
</section>
<section class="bg-gray-50 py-16 border-t border-gray-100">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-6 md:mb-12">
<h3 class="text-2xl font-bold text-gray-900" data-i18n="feature.title">功能特点</h3>
<!-- Multi Preview -->
<section id="multiPreview" class="hidden mb-20">
<div class="flex justify-between items-end mb-6 border-b border-border pb-4">
<span id="progressText" class="font-mono text-sm text-secondary">QUEUE</span>
<button id="downloadAllBtn" class="text-xs font-mono bg-primary text-background px-4 py-2 rounded hover:opacity-90 transition-colors hidden" data-i18n="btn.downloadAll">DOWNLOAD ALL</button>
</div>
<div id="imageList" class="grid grid-cols-1 md:grid-cols-2 gap-4"></div>
</section>
<!-- Features -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 pt-12 border-t border-border">
<div class="p-6 rounded-xl bg-card border border-border hover:border-secondary transition-colors">
<div class="w-8 h-8 text-primary mb-4">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-8">
<div class="bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
<div class="w-12 h-12 bg-emerald-100 text-primary rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
</div>
<h4 class="text-lg font-bold text-gray-900 mb-2" data-i18n="feature.speed.title">极速处理</h4>
<p class="text-gray-500 text-sm leading-relaxed" data-i18n="feature.speed.desc">基于现代浏览器技术加速处理,毫秒级响应,无需等待排队</p>
</div>
<div class="bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
<div class="w-12 h-12 bg-blue-100 text-blue-600 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
</div>
<h4 class="text-lg font-bold text-gray-900 mb-2" data-i18n="feature.privacy.title">隐私安全</h4>
<p class="text-gray-500 text-sm leading-relaxed" data-i18n="feature.privacy.desc">纯前端运行,图片数据不离机,绝不上传服务器</p>
</div>
<div class="bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
<div class="w-12 h-12 bg-purple-100 text-purple-600 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z M6 6l12 12"></path>
</svg>
</div>
<h4 class="text-lg font-bold text-gray-900 mb-2" data-i18n="feature.free.title">完全免费</h4>
<p class="text-gray-500 text-sm leading-relaxed" data-i18n="feature.free.desc">无次数限制,无隐藏付费,即开即用</p>
</div>
<h3 class="font-medium mb-2 text-sm text-primary" data-i18n="feature.speed.title">Local Processing</h3>
<p class="text-xs text-secondary leading-relaxed" data-i18n="feature.speed.desc">Runs entirely in your browser. No data leaves your device.</p>
</div>
<div class="p-6 rounded-xl bg-card border border-border hover:border-secondary transition-colors">
<div class="w-8 h-8 text-primary mb-4">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
</div>
<h3 class="font-medium mb-2 text-sm text-primary" data-i18n="feature.privacy.title">Privacy First</h3>
<p class="text-xs text-secondary leading-relaxed" data-i18n="feature.privacy.desc">Zero server uploads. Your intellectual property stays yours.</p>
</div>
<div class="p-6 rounded-xl bg-card border border-border hover:border-secondary transition-colors">
<div class="w-8 h-8 text-primary mb-4">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<h3 class="font-medium mb-2 text-sm text-primary" data-i18n="feature.free.title">Forever Free</h3>
<p class="text-xs text-secondary leading-relaxed" data-i18n="feature.free.desc">Open source and free to use. No hidden fees or limits.</p>
</div>
</section>
</main>
<footer class="bg-emerald-600 text-white py-6 md:pt-12 md:pb-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 mb-8">
<div class="col-span-1 sm:col-span-2 lg:col-span-2">
<h5 class="text-2xl font-bold mb-4" data-i18n="header.title">Gemini Watermark Remover</h5>
<p class="text-emerald-100 text-sm max-w-sm" data-i18n="footer.desc">
Gemini 无损去水印工具,本工具仅供学习交流使用
</p>
</div>
<div>
<h6 class="font-bold mb-2 md:mb-4 text-emerald-100" data-i18n="footer.links">链接</h6>
<ul class="space-y-1 md:space-y-2 text-sm text-emerald-50">
<li><a href="terms.html" class="hover:text-white" data-i18n="footer.terms">使用条款</a></li>
<li><a href="https://github.com/journey-ad/gemini-watermark-remover" target="_blank" rel="noopener noreferrer" class="hover:text-white" data-i18n="footer.github">Github</a></li>
<li><a href="userscript/gemini-watermark-remover.user.js" target="_blank" class="hover:text-white" data-i18n="nav.userscript">油猴脚本</a></li>
</ul>
</div>
<div>
<h6 class="font-bold mb-2 md:mb-4 text-emerald-100" data-i18n="footer.tech">技术</h6>
<ul class="space-y-1 md:space-y-2 text-sm text-emerald-50">
<li><a href="https://allenkuo.medium.com/removing-gemini-ai-watermarks-a-deep-dive-into-reverse-alpha-blending-bbbd83af2a3f" target="_blank" rel="noopener noreferrer" class="hover:text-white" data-i18n="nav.principle">去水印原理</a></li>
</ul>
</div>
<footer class="border-t border-border mt-auto">
<div class="container mx-auto px-6 py-8 flex flex-col md:flex-row justify-between items-center text-xs text-secondary font-mono">
<div class="mb-4 md:mb-0">
<span data-i18n="footer.copyright">&copy; 2025 Gemini Watermark Remover</span>
</div>
<div class="border-t border-emerald-500 pt-8 text-center text-sm text-emerald-200">
<p data-i18n="footer.copyright">&copy; Gemini Watermark Remover. All rights reserved.</p>
<div class="flex gap-6">
<a href="terms.html" class="hover:text-primary transition-colors" data-i18n="footer.terms">Terms</a>
<a href="https://github.com/journey-ad/gemini-watermark-remover" target="_blank" class="hover:text-primary transition-colors">GitHub</a>
</div>
</div>
</footer>
<div id="loadingOverlay" class="fixed inset-0 bg-white/80 backdrop-blur-sm z-[60] hidden flex items-center justify-center">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p class="text-gray-600 font-medium" data-i18n="loading.text">正在处理...</p>
<div id="loadingOverlay" class="fixed inset-0 bg-background/80 backdrop-blur-sm z-[100] hidden flex items-center justify-center">
<div class="flex flex-col items-center gap-4">
<div class="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
<p class="text-xs font-mono text-secondary" data-i18n="loading.text">PROCESSING</p>
</div>
</div>

View File

@ -3,6 +3,10 @@ import i18n from './i18n.js';
import { loadImage, checkOriginal, getOriginalStatus, setStatusMessage, showLoading, hideLoading } from './utils.js';
import JSZip from 'jszip';
import mediumZoom from 'medium-zoom';
import * as pdfjsLib from 'pdfjs-dist';
import { jsPDF } from 'jspdf';
pdfjsLib.GlobalWorkerOptions.workerSrc = 'pdf.worker.min.mjs';
// global state
let engine = null;
@ -33,6 +37,7 @@ async function init() {
try {
await i18n.init();
setupLanguageSwitch();
setupThemeToggle();
showLoading(i18n.t('status.loading'));
engine = await WatermarkEngine.create();
@ -51,6 +56,47 @@ async function init() {
}
}
/**
* setup theme toggle
*/
function setupThemeToggle() {
const btn = document.getElementById('themeToggle');
const html = document.documentElement;
const iconPath = document.getElementById('themeIconPath');
const sunPath = 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M16 12a4 4 0 11-8 0 4 4 0 018 0z';
const moonPath = 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z';
// Initialize based on current class
if (html.classList.contains('dark')) {
iconPath.setAttribute('d', sunPath);
} else {
iconPath.setAttribute('d', moonPath);
}
btn.addEventListener('click', () => {
if (html.classList.contains('dark')) {
html.classList.remove('dark');
html.classList.add('light');
iconPath.setAttribute('d', moonPath);
localStorage.setItem('theme', 'light');
} else {
html.classList.remove('light');
html.classList.add('dark');
iconPath.setAttribute('d', sunPath);
localStorage.setItem('theme', 'dark');
}
});
// Load from storage
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light') {
html.classList.remove('dark');
html.classList.add('light');
iconPath.setAttribute('d', moonPath);
}
}
/**
* setup language switch
*/
@ -105,8 +151,8 @@ function handleFileSelect(e) {
function handleFiles(files) {
const validFiles = files.filter(file => {
if (!file.type.match('image/(jpeg|png|webp)')) return false;
if (file.size > 20 * 1024 * 1024) return false;
if (!file.type.match('image/(jpeg|png|webp)') && file.type !== 'application/pdf') return false;
if (file.size > 50 * 1024 * 1024) return false; // Increased limit for PDF
return true;
});
@ -121,6 +167,7 @@ function handleFiles(files) {
id: Date.now() + index,
file,
name: file.name,
isPdf: file.type === 'application/pdf',
status: 'pending',
originalImg: null,
processedBlob: null,
@ -145,38 +192,88 @@ function handleFiles(files) {
}
}
async function getPdfPreview(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1 });
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: ctx, viewport }).promise;
const img = new Image();
img.src = canvas.toDataURL();
return new Promise(resolve => {
img.onload = () => resolve(img);
});
}
async function processSingle(item) {
try {
const img = await loadImage(item.file);
item.originalImg = img;
let img;
if (item.isPdf) {
img = await getPdfPreview(item.file);
item.originalImg = img;
originalInfo.innerHTML = `
<p>Type: PDF (NotebookLM)</p>
<p>${i18n.t('info.size')}: ${(item.file.size / 1024 / 1024).toFixed(2)} MB</p>
`;
setStatusMessage('NotebookLM PDF Detected', 'success');
} else {
img = await loadImage(item.file);
item.originalImg = img;
const { is_google, is_original } = await checkOriginal(item.file);
const status = getOriginalStatus({ is_google, is_original });
setStatusMessage(status, is_google && is_original ? 'success' : 'warn');
const { is_google, is_original, sourceType } = await checkOriginal(item.file);
const status = getOriginalStatus({ is_google, is_original, sourceType });
setStatusMessage(status, is_google && is_original ? 'success' : 'warn');
const watermarkInfo = engine.getWatermarkInfo(img.width, img.height);
originalInfo.innerHTML = `
<p>${i18n.t('info.size')}: ${img.width}×${img.height}</p>
<p>${i18n.t('info.watermark')}: ${watermarkInfo.width}×${watermarkInfo.height}</p>
<p>${i18n.t('info.position')}: (${watermarkInfo.position.x},${watermarkInfo.position.y})</p>
`;
}
originalImage.src = img.src;
const watermarkInfo = engine.getWatermarkInfo(img.width, img.height);
originalInfo.innerHTML = `
<p>${i18n.t('info.size')}: ${img.width}×${img.height}</p>
<p>${i18n.t('info.watermark')}: ${watermarkInfo.size}×${watermarkInfo.size}</p>
<p>${i18n.t('info.position')}: (${watermarkInfo.position.x},${watermarkInfo.position.y})</p>
`;
let blob;
let previewUrl;
if (item.isPdf) {
const result = await processPdf(item.file);
blob = result.pdfBlob;
previewUrl = result.previewDataUrl;
} else {
// Standard Gemini processing (Alpha Map)
const result = await engine.removeWatermarkFromImage(img);
blob = await new Promise(resolve => result.toBlob(resolve, 'image/png'));
previewUrl = URL.createObjectURL(blob);
}
const result = await engine.removeWatermarkFromImage(img);
const blob = await new Promise(resolve => result.toBlob(resolve, 'image/png'));
item.processedBlob = blob;
item.processedUrl = URL.createObjectURL(blob);
processedImage.src = item.processedUrl;
// Use the cleaned preview (for PDF it's the first page cleaned, for image it's the image itself)
processedImage.src = previewUrl;
processedSection.style.display = 'block';
downloadBtn.style.display = 'flex';
downloadBtn.onclick = () => downloadImage(item);
processedInfo.innerHTML = `
<p>${i18n.t('info.size')}: ${img.width}×${img.height}</p>
<p>${i18n.t('info.status')}: ${i18n.t('info.removed')}</p>
`;
if (item.isPdf) {
processedInfo.innerHTML = `
<p>${i18n.t('info.status')}: ${i18n.t('info.removed')}</p>
<p>Output: PDF</p>
`;
} else {
processedInfo.innerHTML = `
<p>${i18n.t('info.size')}: ${img.width}×${img.height}</p>
<p>${i18n.t('info.status')}: ${i18n.t('info.removed')}</p>
`;
}
zoom.detach();
zoom.attach('[data-zoomable]');
@ -211,8 +308,14 @@ function createImageCard(item) {
}
async function processQueue() {
// Pre-load images/previews
await Promise.all(imageQueue.map(async item => {
const img = await loadImage(item.file);
let img;
if (item.isPdf) {
img = await getPdfPreview(item.file);
} else {
img = await loadImage(item.file);
}
item.originalImg = img;
item.originalUrl = img.src;
document.getElementById(`result-${item.id}`).src = img.src;
@ -228,19 +331,36 @@ async function processQueue() {
updateStatus(item.id, i18n.t('status.processing'));
try {
const result = await engine.removeWatermarkFromImage(item.originalImg);
const blob = await new Promise(resolve => result.toBlob(resolve, 'image/png'));
item.processedBlob = blob;
let blob;
let previewUrl;
if (item.isPdf) {
const res = await processPdf(item.file, (page, total) => {
updateStatus(item.id, `Processing page ${page}/${total}...`);
});
blob = res.pdfBlob;
previewUrl = res.previewDataUrl;
} else {
const result = await engine.removeWatermarkFromImage(item.originalImg);
blob = await new Promise(resolve => result.toBlob(resolve, 'image/png'));
previewUrl = URL.createObjectURL(blob);
}
item.processedBlob = blob;
item.processedUrl = URL.createObjectURL(blob);
document.getElementById(`result-${item.id}`).src = item.processedUrl;
// Update with cleaned preview
document.getElementById(`result-${item.id}`).src = previewUrl;
item.status = 'completed';
const watermarkInfo = engine.getWatermarkInfo(item.originalImg.width, item.originalImg.height);
updateStatus(item.id, `<p>${i18n.t('info.size')}: ${item.originalImg.width}×${item.originalImg.height}</p>
<p>${i18n.t('info.watermark')}: ${watermarkInfo.size}×${watermarkInfo.size}</p>
<p>${i18n.t('info.position')}: (${watermarkInfo.position.x},${watermarkInfo.position.y})</p>`, true);
if (item.isPdf) {
updateStatus(item.id, `PDF Processed`, true);
} else {
const watermarkInfo = engine.getWatermarkInfo(item.originalImg.width, item.originalImg.height);
updateStatus(item.id, `<p>${i18n.t('info.size')}: ${item.originalImg.width}×${item.originalImg.height}</p>
<p>${i18n.t('info.watermark')}: ${watermarkInfo.width}×${watermarkInfo.height}</p>
<p>${i18n.t('info.position')}: (${watermarkInfo.position.x},${watermarkInfo.position.y})</p>`, true);
}
const downloadBtn = document.getElementById(`download-${item.id}`);
downloadBtn.classList.remove('hidden');
@ -249,13 +369,15 @@ async function processQueue() {
processedCount++;
updateProgress();
checkOriginal(item.originalImg).then(({ is_google, is_original }) => {
if (!is_google || !is_original) {
const status = getOriginalStatus({ is_google, is_original });
const statusEl = document.getElementById(`status-${item.id}`);
if (statusEl) statusEl.innerHTML += `<p class="inline-block mt-1 text-xs md:text-sm text-warn">${status}</p>`;
}
}).catch(() => {});
if (!item.isPdf) {
checkOriginal(item.file).then(({ is_google, is_original, sourceType }) => {
if (!is_google || !is_original) {
const status = getOriginalStatus({ is_google, is_original, sourceType });
const statusEl = document.getElementById(`status-${item.id}`);
if (statusEl) statusEl.innerHTML += `<p class="inline-block mt-1 text-xs md:text-sm text-warn">${status}</p>`;
}
}).catch(() => {});
}
} catch (error) {
item.status = 'error';
updateStatus(item.id, i18n.t('status.failed'));
@ -287,7 +409,10 @@ function updateDynamicTexts() {
function downloadImage(item) {
const a = document.createElement('a');
a.href = item.processedUrl;
a.download = `unwatermarked_${item.name.replace(/\.[^.]+$/, '')}.png`;
const ext = item.isPdf ? 'pdf' : 'png';
// Filename rule: original_cleaned.ext
const originalBase = item.name.replace(/\.[^.]+$/, '');
a.download = `${originalBase}_cleaned.${ext}`;
a.click();
}
@ -297,15 +422,75 @@ async function downloadAll() {
const zip = new JSZip();
completed.forEach(item => {
const filename = `unwatermarked_${item.name.replace(/\.[^.]+$/, '')}.png`;
const ext = item.isPdf ? 'pdf' : 'png';
const originalBase = item.name.replace(/\.[^.]+$/, '');
const filename = `${originalBase}_cleaned.${ext}`;
zip.file(filename, item.processedBlob);
});
const blob = await zip.generateAsync({ type: 'blob' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `unwatermarked_${Date.now()}.zip`;
a.download = `cleaned_files_${Date.now()}.zip`;
a.click();
}
async function processPdf(file, onProgress) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
const total = pdf.numPages;
let doc = null;
let previewDataUrl = null;
for (let i = 1; i <= total; i++) {
if (onProgress) onProgress(i, total);
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 2 });
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: ctx, viewport }).promise;
// For PDF, we use Simple Clone (NotebookLM config) as fallback,
// because we don't know if it's Gemini or NotebookLM strictly from just being a PDF.
// But engine has `removeWatermarkFromImage` which checks dimensions.
// So we just call that. If it's Gemini dimensions, it uses Alpha Map.
// If it's NotebookLM dimensions, it uses NotebookLM Alpha Map.
// If it's neither? Gemini 48px Alpha Map (default).
// This might be wrong for generic slides.
// But consistent with "Original Code" behavior.
// However, for NotebookLM PDFs, I previously used `removeWatermarkSimpleClone` with `NOTEBOOK_CLONE_CONFIG`.
// If I use `engine.removeWatermarkFromImage`, it might fail if page size doesn't match exactly.
// I will use `forceSimpleClone: true` for PDF pages to ensure cleaning, as PDFs are often resampled/sized differently.
// Wait, `removeWatermarkFromImage` supports `forceSimpleClone` option which uses `NOTEBOOK_CLONE_CONFIG` if type is notebooklm OR default.
// In `detectWatermarkConfig`, if not NotebookLM, it defaults to Gemini.
// If I force clone on Gemini type, it uses GEMINI_CLONE_CONFIG? No, I didn't add GEMINI_CLONE_CONFIG in the *restored* engine.
// I only added `NOTEBOOK_CLONE_CONFIG`.
// So I should force clone for PDF to use `NOTEBOOK_CLONE_CONFIG`?
// Let's assume PDF pages are NotebookLM slides.
await engine.removeWatermarkFromImage(canvas, { forceSimpleClone: true });
const imgData = canvas.toDataURL('image/jpeg', 0.9);
// Capture first page as preview
if (i === 1) {
previewDataUrl = imgData;
doc = new jsPDF({
orientation: viewport.width > viewport.height ? 'l' : 'p',
unit: 'px',
format: [viewport.width, viewport.height]
});
} else {
doc.addPage([viewport.width, viewport.height], viewport.width > viewport.height ? 'l' : 'p');
}
doc.addImage(imgData, 'JPEG', 0, 0, viewport.width, viewport.height);
}
return { pdfBlob: doc.output('blob'), previewDataUrl };
}
init();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@ -7,25 +7,68 @@ import { calculateAlphaMap } from './alphaMap.js';
import { removeWatermark } from './blendModes.js';
import BG_48_PATH from '../assets/bg_48.png';
import BG_96_PATH from '../assets/bg_96.png';
import BG_NOTEBOOK_PATH from '../assets/bg_notebook.png';
import BG_NOTEBOOK_LARGE_PATH from '../assets/bg_notebook_large.png';
const NOTEBOOK_CLONE_CONFIG = {
wRatio: 0.0825, wRef: 'long',
hRatio: 0.0375, hRef: 'short',
mrRatio: 0.0025, mrRef: 'long',
mbRatio: 0.0027, mbRef: 'short',
featherSize: 12
};
/**
* Check if image is from NotebookLM based on exact dimensions
*/
function isNotebookLM(width, height) {
return (width === 571 && height === 1024) ||
(width === 1536 && height === 2752) ||
(width === 2752 && height === 1536);
}
/**
* Detect watermark configuration based on image size
* @param {number} imageWidth - Image width
* @param {number} imageHeight - Image height
* @returns {Object} Watermark configuration {logoSize, marginRight, marginBottom}
* @returns {Object} Watermark configuration
*/
export function detectWatermarkConfig(imageWidth, imageHeight) {
// Gemini's watermark rules:
// NotebookLM detection
if (isNotebookLM(imageWidth, imageHeight)) {
if (imageWidth === 571 && imageHeight === 1024) {
return {
type: 'notebooklm',
logoWidth: 72,
logoHeight: 9,
marginRight: 3,
marginBottom: 3
};
} else {
// Large NotebookLM
return {
type: 'notebooklm',
logoWidth: 190,
logoHeight: 20,
marginRight: 11,
marginBottom: 10
};
}
}
// Gemini's watermark rules (unchanged):
// If both image width and height are greater than 1024, use 96×96 watermark
// Otherwise, use 48×48 watermark
if (imageWidth > 1024 && imageHeight > 1024) {
return {
type: 'gemini',
logoSize: 96,
marginRight: 64,
marginBottom: 64
};
} else {
return {
type: 'gemini',
logoSize: 48,
marginRight: 32,
marginBottom: 32
@ -37,17 +80,19 @@ export function detectWatermarkConfig(imageWidth, imageHeight) {
* Calculate watermark position in image based on image size and watermark configuration
* @param {number} imageWidth - Image width
* @param {number} imageHeight - Image height
* @param {Object} config - Watermark configuration {logoSize, marginRight, marginBottom}
* @param {Object} config - Watermark configuration
* @returns {Object} Watermark position {x, y, width, height}
*/
export function calculateWatermarkPosition(imageWidth, imageHeight, config) {
const { logoSize, marginRight, marginBottom } = config;
const logoWidth = config.logoWidth || config.logoSize;
const logoHeight = config.logoHeight || config.logoSize;
const { marginRight, marginBottom } = config;
return {
x: imageWidth - marginRight - logoSize,
y: imageHeight - marginBottom - logoSize,
width: logoSize,
height: logoSize
x: imageWidth - marginRight - logoWidth,
y: imageHeight - marginBottom - logoHeight,
width: logoWidth,
height: logoHeight
};
}
@ -64,6 +109,8 @@ export class WatermarkEngine {
static async create() {
const bg48 = new Image();
const bg96 = new Image();
const bgNotebook = new Image();
const bgNotebookLarge = new Image();
await Promise.all([
new Promise((resolve, reject) => {
@ -75,10 +122,20 @@ export class WatermarkEngine {
bg96.onload = resolve;
bg96.onerror = reject;
bg96.src = BG_96_PATH;
}),
new Promise((resolve, reject) => {
bgNotebook.onload = resolve;
bgNotebook.onerror = reject;
bgNotebook.src = BG_NOTEBOOK_PATH;
}),
new Promise((resolve, reject) => {
bgNotebookLarge.onload = resolve;
bgNotebookLarge.onerror = reject;
bgNotebookLarge.src = BG_NOTEBOOK_LARGE_PATH;
})
]);
return new WatermarkEngine({ bg48, bg96 });
return new WatermarkEngine({ bg48, bg96, bgNotebook, bgNotebookLarge });
}
/**
@ -114,11 +171,119 @@ export class WatermarkEngine {
}
/**
* Remove watermark from image based on watermark size
* Remove NotebookLM watermark using alpha map and background estimation
*/
removeNotebookLMWatermark(imageData, position, isLarge) {
const { x, y, width, height } = position;
const imgWidth = imageData.width;
// Get alpha map
const bgImage = isLarge ? this.bgCaptures.bgNotebookLarge : this.bgCaptures.bgNotebook;
// Create canvas to extract alpha map
const alphaCanvas = document.createElement('canvas');
alphaCanvas.width = width;
alphaCanvas.height = height;
const alphaCtx = alphaCanvas.getContext('2d');
alphaCtx.drawImage(bgImage, 0, 0, width, height);
const alphaData = alphaCtx.getImageData(0, 0, width, height);
// Calculate background colors from pixels above the watermark
const bgColors = [];
for (let col = 0; col < width; col++) {
let sumR = 0, sumG = 0, sumB = 0, count = 0;
for (let dy = -25; dy < -5; dy++) {
const sampleY = y + dy;
if (sampleY >= 0) {
const idx = (sampleY * imgWidth + (x + col)) * 4;
sumR += imageData.data[idx];
sumG += imageData.data[idx + 1];
sumB += imageData.data[idx + 2];
count++;
}
}
bgColors.push({
r: count > 0 ? sumR / count : 0,
g: count > 0 ? sumG / count : 0,
b: count > 0 ? sumB / count : 0
});
}
// Process each pixel in the watermark region
// Use background replacement for all watermark pixels with alpha blending at edges
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const alphaIdx = (row * width + col) * 4;
const alpha = alphaData.data[alphaIdx] / 255.0;
if (alpha < 0.02) continue; // Skip nearly transparent pixels
const imgIdx = ((y + row) * imgWidth + (x + col)) * 4;
const bg = bgColors[col];
// Blend current pixel with background based on alpha
const blendFactor = Math.min(alpha * 2.0, 1.0);
for (let c = 0; c < 3; c++) {
const current = imageData.data[imgIdx + c];
const bgVal = c === 0 ? bg.r : (c === 1 ? bg.g : bg.b);
const blended = current * (1 - blendFactor) + bgVal * blendFactor;
imageData.data[imgIdx + c] = Math.round(blended);
}
}
}
}
/**
* Remove watermark using simple cloning technique (SlideClean approach)
* Robust for NotebookLM PDFs and images with varying sizes
*/
removeWatermarkSimpleClone(ctx, width, height, config = NOTEBOOK_CLONE_CONFIG) {
const longEdge = Math.max(width, height);
const shortEdge = Math.min(width, height);
const getDim = (ratio, ref) => Math.round((ref === 'short' ? shortEdge : longEdge) * ratio);
const wmW = getDim(config.wRatio, config.wRef);
const wmH = getDim(config.hRatio, config.hRef);
const mr = getDim(config.mrRatio, config.mrRef);
const mb = getDim(config.mbRatio, config.mbRef);
const feather = config.featherSize || 12;
const x = width - wmW - mr;
const y = height - wmH - mb;
const srcY = Math.max(0, y - wmH);
if (srcY < 0) return;
const src = ctx.getImageData(x, srcY, wmW, wmH);
const dst = ctx.getImageData(x, y, wmW, wmH);
const res = ctx.createImageData(wmW, wmH);
for (let i = 0; i < wmH; i++) {
for (let j = 0; j < wmW; j++) {
const idx = (i * wmW + j) * 4;
let a = 1.0;
// Simple feathering at edges
if (i < feather) a = Math.min(a, i / feather);
if (j < feather) a = Math.min(a, j / feather);
for (let c = 0; c < 4; c++) {
res.data[idx + c] = Math.round(dst.data[idx + c] * (1 - a) + src.data[idx + c] * a);
}
}
}
ctx.putImageData(res, x, y);
}
/**
* Remove watermark from image
* @param {HTMLImageElement|HTMLCanvasElement} image - Input image
* @param {Object} [options] - Options
* @param {boolean} [options.forceSimpleClone=false] - Force use of simple clone method
* @returns {Promise<HTMLCanvasElement>} Processed canvas
*/
async removeWatermarkFromImage(image) {
async removeWatermarkFromImage(image, options = {}) {
// Create canvas to process image
const canvas = document.createElement('canvas');
canvas.width = image.width;
@ -128,6 +293,12 @@ export class WatermarkEngine {
// Draw original image onto canvas
ctx.drawImage(image, 0, 0);
// Use simple clone if forced (e.g. for PDFs or explicit override)
if (options.forceSimpleClone) {
this.removeWatermarkSimpleClone(ctx, canvas.width, canvas.height, NOTEBOOK_CLONE_CONFIG);
return canvas;
}
// Get image data
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@ -135,11 +306,15 @@ export class WatermarkEngine {
const config = detectWatermarkConfig(canvas.width, canvas.height);
const position = calculateWatermarkPosition(canvas.width, canvas.height, config);
// Get alpha map for watermark size
const alphaMap = await this.getAlphaMap(config.logoSize);
// Remove watermark from image data
removeWatermark(imageData, alphaMap, position);
if (config.type === 'notebooklm') {
// Use alpha map + background estimation for NotebookLM
const isLarge = canvas.width > 1000 || canvas.height > 1000;
this.removeNotebookLMWatermark(imageData, position, isLarge);
} else {
// Original Gemini logic - unchanged, using static assets for 48/96
const alphaMap = await this.getAlphaMap(config.logoSize);
removeWatermark(imageData, alphaMap, position);
}
// Write processed image data back to canvas
ctx.putImageData(imageData, 0, 0);
@ -158,7 +333,10 @@ export class WatermarkEngine {
const position = calculateWatermarkPosition(imageWidth, imageHeight, config);
return {
size: config.logoSize,
size: config.logoSize || config.logoWidth,
width: config.logoWidth || config.logoSize,
height: config.logoHeight || config.logoSize,
type: config.type,
position: position,
config: config
};

1015
yarn.lock 100644

File diff suppressed because it is too large Load Diff