Files
InfoGenie/InfoGenie-frontend/public/toolbox/图片处理/图片加文字水印/图片加文字水印.html
2026-04-01 22:03:57 +08:00

728 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>水印工具 · WaterMark</title>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0e0e0f;
--surface: #18181b;
--surface2: #222226;
--border: #2e2e34;
--accent: #f0a500;
--accent2: #e07b00;
--text: #e8e8e8;
--muted: #888;
--danger: #e05555;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Noto Sans SC', sans-serif;
font-weight: 300;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Header ── */
header {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 28px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
header svg { flex-shrink: 0; }
.brand {
font-family: 'Bebas Neue', cursive;
font-size: 1.7rem;
letter-spacing: 2px;
color: var(--accent);
line-height: 1;
}
.brand span { color: var(--text); }
.subtitle {
font-size: .7rem;
color: var(--muted);
letter-spacing: 3px;
text-transform: uppercase;
margin-left: 2px;
}
/* ── Layout ── */
main {
flex: 1;
display: grid;
grid-template-columns: 340px 1fr;
gap: 0;
height: calc(100vh - 65px);
}
/* ── Sidebar ── */
aside {
background: var(--surface);
border-right: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.panel {
padding: 20px;
border-bottom: 1px solid var(--border);
}
.panel-title {
font-size: .65rem;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 14px;
font-weight: 500;
}
/* Upload zone */
.upload-zone {
border: 1.5px dashed var(--border);
border-radius: 10px;
padding: 28px 16px;
text-align: center;
cursor: pointer;
transition: all .25s;
position: relative;
}
.upload-zone:hover, .upload-zone.drag { border-color: var(--accent); background: rgba(240,165,0,.05); }
.upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
.upload-icon { font-size: 2rem; margin-bottom: 8px; }
.upload-zone p { font-size: .82rem; color: var(--muted); line-height: 1.6; }
.upload-zone strong { color: var(--accent); }
/* Controls */
.field { margin-bottom: 14px; }
.field label {
display: block;
font-size: .72rem;
color: var(--muted);
margin-bottom: 6px;
letter-spacing: 1px;
}
.field input[type="text"],
.field input[type="number"],
.field select,
.field textarea {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 7px;
padding: 9px 12px;
color: var(--text);
font-size: .85rem;
font-family: inherit;
outline: none;
transition: border-color .2s;
}
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); }
.field textarea { resize: vertical; min-height: 64px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
/* Slider */
.slider-wrap { display: flex; align-items: center; gap: 10px; }
.slider-wrap input[type="range"] {
flex: 1;
-webkit-appearance: none;
height: 4px;
background: var(--border);
border-radius: 4px;
outline: none;
cursor: pointer;
}
.slider-wrap input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
.slider-val {
min-width: 36px;
text-align: right;
font-size: .8rem;
color: var(--accent);
font-weight: 500;
}
/* Color input */
.color-wrap { display: flex; align-items: center; gap: 10px; }
.color-wrap input[type="color"] {
width: 40px; height: 36px;
border: 1px solid var(--border);
border-radius: 7px;
padding: 2px;
background: var(--surface2);
cursor: pointer;
flex-shrink: 0;
}
.color-wrap input[type="text"] { flex: 1; }
/* Checkbox toggle */
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.toggle-label { font-size: .82rem; color: var(--text); }
.toggle {
position: relative;
width: 42px; height: 24px;
flex-shrink: 0;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-track {
position: absolute; inset: 0;
background: var(--border);
border-radius: 24px;
cursor: pointer;
transition: background .2s;
}
.toggle-track::after {
content: '';
position: absolute;
width: 18px; height: 18px;
background: #fff;
border-radius: 50%;
top: 3px; left: 3px;
transition: transform .2s;
}
.toggle input:checked + .toggle-track { background: var(--accent); }
.toggle input:checked + .toggle-track::after { transform: translateX(18px); }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 20px;
border-radius: 8px;
font-size: .85rem;
font-family: inherit;
font-weight: 500;
cursor: pointer;
border: none;
transition: all .2s;
width: 100%;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover { background: var(--accent2); }
.btn-primary:disabled { opacity: .4; cursor: not-allowed; }
.btn-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.actions { padding: 16px 20px; margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
/* ── Canvas area ── */
.canvas-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg);
position: relative;
overflow: hidden;
padding: 24px;
}
.canvas-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px);
background-size: 32px 32px;
opacity: .35;
pointer-events: none;
}
.empty-state {
text-align: center;
position: relative;
z-index: 1;
}
.empty-state .big-icon {
font-size: 4rem;
margin-bottom: 16px;
opacity: .3;
}
.empty-state p { color: var(--muted); font-size: .9rem; line-height: 1.7; }
#previewWrap {
position: relative;
z-index: 1;
display: none;
box-shadow: 0 20px 60px rgba(0,0,0,.6);
border-radius: 4px;
overflow: hidden;
max-width: 100%;
max-height: calc(100vh - 140px);
}
#preview {
display: block;
max-width: 100%;
max-height: calc(100vh - 140px);
object-fit: contain;
}
/* info bar */
.info-bar {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,.7);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 16px;
font-size: .7rem;
color: var(--muted);
white-space: nowrap;
z-index: 10;
display: none;
}
.info-bar span { color: var(--text); }
/* Mobile */
@media (max-width: 768px) {
main {
grid-template-columns: 1fr;
height: auto;
}
aside {
border-right: none;
border-bottom: 1px solid var(--border);
}
.canvas-area {
min-height: 55vw;
padding: 16px;
}
header { padding: 14px 16px; }
.brand { font-size: 1.4rem; }
}
</style>
</head>
<body>
<header>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#f0a500" opacity=".15"/>
<rect x="4" y="8" width="24" height="16" rx="3" stroke="#f0a500" stroke-width="1.5" fill="none"/>
<path d="M4 13h24" stroke="#f0a500" stroke-width="1.2" stroke-dasharray="3 2"/>
<text x="16" y="23" text-anchor="middle" font-size="7" fill="#f0a500" font-family="serif" font-style="italic">WM</text>
</svg>
<div>
<div class="brand">Water<span>Mark</span></div>
<div class="subtitle">图片文字水印工具</div>
</div>
</header>
<main>
<aside>
<!-- Upload -->
<div class="panel">
<div class="panel-title">01 · 上传图片</div>
<div class="upload-zone" id="dropZone">
<input type="file" id="fileInput" accept="image/*">
<div class="upload-icon">🖼️</div>
<p><strong>点击选择</strong>或拖拽图片到此处<br>支持 JPG · PNG · WEBP · GIF</p>
</div>
</div>
<!-- Text -->
<div class="panel">
<div class="panel-title">02 · 水印文字</div>
<div class="field">
<label>水印内容</label>
<textarea id="wmText" placeholder="输入水印文字…">© 版权所有</textarea>
</div>
<div class="row2">
<div class="field">
<label>字号 (px)</label>
<input type="number" id="wmSize" value="36" min="8" max="300">
</div>
<div class="field">
<label>字体</label>
<select id="wmFont">
<option value="'Noto Sans SC', sans-serif">思源黑体</option>
<option value="serif">宋体</option>
<option value="'Noto Serif SC', serif">思源宋体</option>
<option value="monospace">等宽体</option>
<option value="cursive">手写体</option>
</select>
</div>
</div>
<div class="field">
<label>颜色</label>
<div class="color-wrap">
<input type="color" id="wmColorPicker" value="#ffffff">
<input type="text" id="wmColorHex" value="#ffffff" maxlength="9" placeholder="#ffffff">
</div>
</div>
<div class="field">
<label>透明度</label>
<div class="slider-wrap">
<input type="range" id="wmOpacity" min="1" max="100" value="40">
<div class="slider-val" id="opacityVal">40%</div>
</div>
</div>
</div>
<!-- Position -->
<div class="panel">
<div class="panel-title">03 · 位置 & 旋转</div>
<div class="field">
<label>预设位置</label>
<select id="wmPosition">
<option value="center">居中</option>
<option value="top-left">左上角</option>
<option value="top-right">右上角</option>
<option value="bottom-left">左下角</option>
<option value="bottom-right" selected>右下角</option>
<option value="tile">平铺</option>
</select>
</div>
<div class="row2">
<div class="field">
<label>旋转角度 (°)</label>
<div class="slider-wrap">
<input type="range" id="wmAngle" min="-180" max="180" value="-30">
<div class="slider-val" id="angleVal">-30°</div>
</div>
</div>
</div>
<div class="field">
<label>边距 (px)</label>
<input type="number" id="wmPadding" value="30" min="0" max="500">
</div>
<!-- Tile spacing (only when tile) -->
<div class="field" id="tileField" style="display:none">
<label>平铺间距 (px)</label>
<div class="row2">
<input type="number" id="tileX" value="200" min="20">
<input type="number" id="tileY" value="160" min="20">
</div>
</div>
<div class="field" style="margin-top:10px">
<div class="toggle-row">
<span class="toggle-label">描边效果</span>
<label class="toggle">
<input type="checkbox" id="wmStroke">
<div class="toggle-track"></div>
</label>
</div>
</div>
<div class="field" id="strokeField" style="display:none">
<label>描边颜色</label>
<div class="color-wrap">
<input type="color" id="strokeColorPicker" value="#000000">
<input type="text" id="strokeColorHex" value="#000000" maxlength="9">
</div>
</div>
<div class="field">
<div class="toggle-row">
<span class="toggle-label">阴影效果</span>
<label class="toggle">
<input type="checkbox" id="wmShadow" checked>
<div class="toggle-track"></div>
</label>
</div>
</div>
</div>
<!-- Output -->
<div class="panel">
<div class="panel-title">04 · 输出设置</div>
<div class="row2">
<div class="field">
<label>格式</label>
<select id="outFormat">
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="image/webp">WEBP</option>
</select>
</div>
<div class="field">
<label>质量</label>
<div class="slider-wrap">
<input type="range" id="outQuality" min="10" max="100" value="92">
<div class="slider-val" id="qualityVal">92</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="actions">
<button class="btn btn-primary" id="applyBtn" disabled>
✦ 生成水印预览
</button>
<button class="btn btn-ghost" id="downloadBtn" style="display:none">
↓ 下载图片
</button>
</div>
</aside>
<!-- Preview -->
<div class="canvas-area" id="canvasArea">
<div class="canvas-bg"></div>
<div class="empty-state" id="emptyState">
<div class="big-icon">🖼️</div>
<p>上传图片后<br>在左侧调整参数<br>即可预览水印效果</p>
</div>
<div id="previewWrap">
<img id="preview" alt="预览">
</div>
<canvas id="canvas" style="display:none"></canvas>
<div class="info-bar" id="infoBar">尺寸:<span id="dimInfo"></span> &nbsp;|&nbsp; 文件:<span id="sizeInfo"></span></div>
</div>
</main>
<script>
const $ = id => document.getElementById(id);
const fileInput = $('fileInput');
const dropZone = $('dropZone');
const applyBtn = $('applyBtn');
const downloadBtn = $('downloadBtn');
const preview = $('preview');
const previewWrap = $('previewWrap');
const emptyState = $('emptyState');
const canvas = $('canvas');
const ctx = canvas.getContext('2d');
const infoBar = $('infoBar');
let originalImage = null;
let currentBlob = null;
// ── Sliders ──────────────────────────────────────
function linkSlider(sliderId, valId, suffix) {
const s = $(sliderId), v = $(valId);
s.addEventListener('input', () => { v.textContent = s.value + suffix; debounceRender(); });
}
linkSlider('wmOpacity', 'opacityVal', '%');
linkSlider('wmAngle', 'angleVal', '°');
linkSlider('outQuality','qualityVal', '');
// ── Color sync ───────────────────────────────────
function syncColor(pickerId, hexId) {
const picker = $(pickerId), hex = $(hexId);
picker.addEventListener('input', () => { hex.value = picker.value; debounceRender(); });
hex.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; debounceRender(); }
});
}
syncColor('wmColorPicker', 'wmColorHex');
syncColor('strokeColorPicker', 'strokeColorHex');
// ── Toggle stroke ─────────────────────────────────
$('wmStroke').addEventListener('change', e => {
$('strokeField').style.display = e.target.checked ? 'block' : 'none';
debounceRender();
});
// ── Toggle tile ───────────────────────────────────
$('wmPosition').addEventListener('change', e => {
$('tileField').style.display = e.target.value === 'tile' ? 'block' : 'none';
debounceRender();
});
// ── Auto render on any change ─────────────────────
let renderTimer;
function debounceRender() {
if (!originalImage) return;
clearTimeout(renderTimer);
renderTimer = setTimeout(render, 200);
}
document.querySelectorAll('input,select,textarea').forEach(el => {
if (el.type !== 'file') el.addEventListener('input', debounceRender);
});
// ── File handling ─────────────────────────────────
function loadFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
originalImage = img;
applyBtn.disabled = false;
render();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
fileInput.addEventListener('change', e => loadFile(e.target.files[0]));
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag');
loadFile(e.dataTransfer.files[0]);
});
// ── Paste image ───────────────────────────────────
document.addEventListener('paste', e => {
const item = [...e.clipboardData.items].find(i => i.type.startsWith('image/'));
if (item) loadFile(item.getAsFile());
});
// ── Render ────────────────────────────────────────
applyBtn.addEventListener('click', render);
function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${alpha})`;
}
function render() {
if (!originalImage) return;
applyBtn.disabled = true;
applyBtn.textContent = '⟳ 渲染中…';
requestAnimationFrame(() => {
canvas.width = originalImage.naturalWidth;
canvas.height = originalImage.naturalHeight;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(originalImage, 0, 0);
const text = $('wmText').value || '水印';
const size = parseInt($('wmSize').value) || 36;
const font = $('wmFont').value;
const color = $('wmColorHex').value;
const opacity = parseInt($('wmOpacity').value) / 100;
const angle = parseInt($('wmAngle').value) * Math.PI / 180;
const pos = $('wmPosition').value;
const pad = parseInt($('wmPadding').value) || 30;
const stroke = $('wmStroke').checked;
const strokeC = $('strokeColorHex').value;
const shadow = $('wmShadow').checked;
ctx.font = `${size}px ${font}`;
ctx.globalAlpha = opacity;
const tw = ctx.measureText(text).width;
const th = size;
if (shadow) {
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = size * 0.2;
ctx.shadowOffsetX = size * 0.05;
ctx.shadowOffsetY = size * 0.05;
} else {
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
}
function drawText(x, y) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.fillStyle = hexToRgba(color, opacity);
ctx.globalAlpha = 1;
if (shadow) {
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = size * 0.2;
ctx.shadowOffsetX = size * 0.05;
ctx.shadowOffsetY = size * 0.05;
}
if (stroke) {
ctx.strokeStyle = hexToRgba(strokeC, opacity);
ctx.lineWidth = size * 0.06;
ctx.lineJoin = 'round';
ctx.strokeText(text, 0, 0);
}
ctx.fillStyle = hexToRgba(color, opacity);
ctx.fillText(text, 0, 0);
ctx.restore();
}
ctx.textBaseline = 'middle';
const W = canvas.width, H = canvas.height;
if (pos === 'tile') {
const gx = parseInt($('tileX').value) || 200;
const gy = parseInt($('tileY').value) || 160;
for (let y = gy/2; y < H + gy; y += gy) {
for (let x = gx/2; x < W + gx; x += gx) {
drawText(x, y);
}
}
} else {
let x, y;
if (pos === 'center') { x = W/2 - tw/2; y = H/2; }
else if (pos === 'top-left') { x = pad; y = pad + th/2; }
else if (pos === 'top-right') { x = W - tw - pad; y = pad + th/2; }
else if (pos === 'bottom-left') { x = pad; y = H - pad - th/2; }
else { x = W - tw - pad; y = H - pad - th/2; }
drawText(x, y);
}
// export
const fmt = $('outFormat').value;
const quality = parseInt($('outQuality').value) / 100;
canvas.toBlob(blob => {
currentBlob = blob;
const url = URL.createObjectURL(blob);
preview.src = url;
previewWrap.style.display = 'block';
emptyState.style.display = 'none';
downloadBtn.style.display = 'block';
infoBar.style.display = 'block';
$('dimInfo').textContent = `${W} × ${H}`;
$('sizeInfo').textContent = formatBytes(blob.size);
applyBtn.disabled = false;
applyBtn.textContent = '✦ 重新渲染';
}, fmt, quality);
});
}
// ── Download ──────────────────────────────────────
downloadBtn.addEventListener('click', () => {
if (!currentBlob) return;
const fmt = $('outFormat').value;
const ext = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }[fmt] || 'jpg';
const a = document.createElement('a');
a.href = URL.createObjectURL(currentBlob);
a.download = `watermarked.${ext}`;
a.click();
});
function formatBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
return (b/1024/1024).toFixed(2) + ' MB';
}
</script>
</body>
</html>