728 lines
22 KiB
HTML
728 lines
22 KiB
HTML
<!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> | 文件:<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> |