Files
-----/pdf-sign.html
T
2026-07-08 02:14:47 +08:00

629 lines
29 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-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PDF 簽名工具</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
<!-- type="text/tailwindcss" required for @apply to work with Play CDN -->
<style type="text/tailwindcss">
/* ── Floating signature wrapper ───────────────────────────── */
.sig-el {
position: absolute; cursor: move; user-select: none;
}
.sig-el:hover { outline: 2px dashed #3b82f6; outline-offset: 2px; }
.sig-el:hover .sig-del { display: flex !important; }
.sig-el:hover .sig-rs { display: block !important; }
/* Resize handles */
.sig-rs {
display: none; position: absolute;
width: 10px; height: 10px;
background: #3b82f6; border: 2px solid #fff;
border-radius: 2px; z-index: 10;
}
.sig-rs-nw { top:-5px; left:-5px; cursor:nw-resize; }
.sig-rs-ne { top:-5px; right:-5px; cursor:ne-resize; }
.sig-rs-sw { bottom:-5px; left:-5px; cursor:sw-resize; }
.sig-rs-se { bottom:-5px; right:-5px; cursor:se-resize; }
/* Delete button */
.sig-del {
display: none; position: absolute;
top:-12px; right:-12px; width:22px; height:22px;
background:#ef4444; border-radius:50%;
align-items:center; justify-content:center;
color:#fff; font-size:12px; cursor:pointer;
z-index:10; border:2px solid #fff;
}
/* ── Toolbar buttons ──────────────────────────────────────── */
.tbtn {
@apply px-3 py-1.5 rounded-lg text-sm font-medium transition cursor-pointer select-none;
}
.tbtn-blue { @apply tbtn bg-blue-500 hover:bg-blue-600 text-white; }
.tbtn-green { @apply tbtn bg-emerald-500 hover:bg-emerald-600 text-white; }
.tbtn-purple { @apply tbtn bg-violet-500 hover:bg-violet-600 text-white; }
.tbtn-orange { @apply tbtn bg-orange-500 hover:bg-orange-600 text-white; }
.tbtn-gray {
@apply tbtn bg-slate-100 hover:bg-slate-200 text-slate-600;
}
.tbtn-gray:disabled { @apply opacity-40 cursor-not-allowed; }
/* ── Pen type buttons ─────────────────────────────────────── */
.pen-btn {
@apply px-3 py-1.5 rounded-lg text-sm font-medium border-2 border-transparent
bg-slate-100 text-slate-600 transition cursor-pointer;
}
.pen-btn.active { @apply border-blue-500 bg-blue-50 text-blue-700; }
/* ── Color dot buttons ────────────────────────────────────── */
.cdot {
@apply w-9 h-9 rounded-full border-2 border-white shadow cursor-pointer
transition ring-2 ring-transparent ring-offset-1;
}
.cdot.active { @apply ring-blue-500; }
/* ── Small action buttons inside modal ───────────────────── */
.mbtn-red { @apply px-4 py-1.5 rounded-lg text-sm font-medium bg-red-100 hover:bg-red-200 text-red-600 transition; }
.mbtn-gray { @apply px-4 py-1.5 rounded-lg text-sm font-medium bg-slate-100 hover:bg-slate-200 text-slate-600 transition; }
</style>
<style>
#pdf-container { position:relative; display:inline-block; line-height:0; }
#sig-canvas { touch-action:none; cursor:crosshair; display:block; }
#pdf-scroll { overflow:auto; max-height:calc(100vh - 180px); }
</style>
</head>
<body class="bg-slate-100 min-h-screen flex flex-col">
<!-- ═══════════════ HEADER TOOLBAR ═══════════════ -->
<header class="bg-white border-b shadow-sm sticky top-0 z-30">
<div class="max-w-screen-xl mx-auto px-4 py-2 flex flex-wrap items-center gap-2">
<div class="mr-2">
<h1 class="text-base font-bold text-slate-700 leading-tight">📄 PDF 簽名工具</h1>
<p class="text-[11px] text-slate-400 leading-tight">開發者:GinoHung</p>
</div>
<label class="tbtn-blue">
📂 載入 PDF
<input type="file" accept=".pdf" id="upload-pdf" class="hidden" />
</label>
<label class="tbtn-green">
🖼️ 匯入簽名圖片
<input type="file" accept="image/*" id="upload-sig-img" class="hidden" />
</label>
<button id="btn-open-pad" class="tbtn-purple">✍️ 手寫簽名</button>
<div class="flex items-center gap-1 ml-auto">
<button id="btn-prev" class="tbtn-gray" disabled>← 上頁</button>
<span id="page-label" class="text-xs text-slate-500 min-w-[90px] text-center">尚未載入</span>
<button id="btn-next" class="tbtn-gray" disabled>下頁 →</button>
</div>
<button id="btn-export" class="tbtn-orange">💾 匯出 PDF</button>
</div>
</header>
<!-- ═══════════════ MAIN PDF AREA ═══════════════ -->
<main class="flex-1 flex flex-col items-center py-6 px-4">
<div id="empty-state"
class="w-full max-w-2xl flex flex-col items-center justify-center gap-3 h-72
border-2 border-dashed border-slate-300 rounded-2xl bg-white
text-slate-400 cursor-pointer hover:border-blue-400 transition"
onclick="document.getElementById('upload-pdf').click()">
<div class="text-5xl">📄</div>
<p class="text-lg font-medium">點擊或拖放 PDF 至此處開始</p>
<p class="text-sm">支援多頁 PDF・手寫簽名・PNG 圖片簽名</p>
</div>
<div id="pdf-scroll" class="hidden w-full justify-center">
<div id="pdf-container" class="shadow-2xl rounded-lg overflow-visible">
<canvas id="pdf-canvas" class="block rounded-lg"></canvas>
</div>
</div>
</main>
<!-- ═══════════════ HANDWRITING MODAL ═══════════════ -->
<div id="modal-pad"
class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center
p-4 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-3 border-b">
<h2 class="font-bold text-slate-700">✍️ 手寫簽名</h2>
<button id="btn-close-pad" class="text-slate-400 hover:text-slate-600 text-2xl leading-none">&times;</button>
</div>
<!-- Toolbar -->
<div class="px-5 py-3 border-b flex flex-wrap gap-5 items-end">
<!-- Pen type -->
<div>
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">筆觸</p>
<div class="flex gap-1" id="pen-type-group">
<button class="pen-btn active" data-pen="ballpoint">原子筆</button>
<button class="pen-btn" data-pen="fountain" >鋼筆</button>
<button class="pen-btn" data-pen="brush" >毛筆</button>
<button class="pen-btn" data-pen="pencil" >鉛筆</button>
<button class="pen-btn" data-pen="marker" >奇異筆</button>
</div>
</div>
<!-- Color -->
<div>
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">顏色</p>
<div class="flex gap-2 items-center" id="color-group">
<button class="cdot active" data-color="#111111" style="background:#111111;" title="黑色"></button>
<button class="cdot" data-color="#dc2626" style="background:#dc2626;" title="紅色"></button>
<button class="cdot" data-color="#16a34a" style="background:#16a34a;" title="綠色"></button>
<button class="cdot" data-color="#2563eb" style="background:#2563eb;" title="藍色"></button>
</div>
</div>
<!-- Stroke width 110 -->
<div class="flex-1 min-w-[130px]">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">
粗細 <span id="width-val">3</span> px
</p>
<input type="range" id="stroke-width" min="1" max="10" value="3"
class="w-full accent-blue-500 cursor-pointer" />
</div>
</div>
<!-- Canvas -->
<div class="px-5 py-4">
<div class="relative border-2 border-dashed border-slate-200 rounded-xl overflow-hidden bg-white">
<canvas id="sig-canvas" width="620" height="280" class="w-full"></canvas>
<p id="pad-hint"
class="absolute inset-0 flex items-center justify-center
text-slate-300 text-sm pointer-events-none select-none">
在此手寫您的簽名…
</p>
</div>
</div>
<!-- Footer -->
<div class="flex justify-between px-5 py-3 border-t">
<div class="flex gap-2">
<button id="btn-clear" class="mbtn-red" >清除</button>
<button id="btn-undo" class="mbtn-gray">↩ 撤銷</button>
</div>
<button id="btn-apply" class="tbtn-blue">✓ 套用到 PDF</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast"
class="hidden fixed bottom-6 right-6 bg-slate-800 text-white text-sm
px-5 py-3 rounded-xl shadow-xl z-50">
</div>
<!-- ═══════════════ SCRIPT ═══════════════ -->
<script>
// ── Setup ────────────────────────────────────────────────────────
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const state = {
pdfJsDoc: null, rawBytes: null, origName: '',
curPage: 1, totalPages: 0, renderScale: 1.5,
sigs: [], sigId: 0,
};
const pad = {
drawing: false, penType: 'ballpoint',
color: '#111111', lineWidth: 3,
lastX: 0, lastY: 0, lastVel: 0,
history: [], hasContent: false,
};
// ── PDF loading ──────────────────────────────────────────────────
document.getElementById('upload-pdf').addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const buf = await file.arrayBuffer();
// Slice a copy for pdf-lib export; pass the original to pdf.js
// (pdf.js worker may transfer/detach the ArrayBuffer it receives)
state.rawBytes = new Uint8Array(buf.slice(0));
state.origName = file.name.replace(/\.pdf$/i, '');
try {
state.pdfJsDoc = await pdfjsLib.getDocument({ data: buf }).promise;
state.totalPages = state.pdfJsDoc.numPages;
state.curPage = 1;
state.sigs = [];
document.getElementById('empty-state').classList.add('hidden');
const scr = document.getElementById('pdf-scroll');
scr.classList.remove('hidden');
scr.classList.add('flex');
await renderPage(1);
toast(`PDF 已載入,共 ${state.totalPages} 頁`);
} catch(err) { toast('PDF 載入失敗:' + err.message, true); }
e.target.value = '';
});
// Drag-and-drop on empty area
const emptyEl = document.getElementById('empty-state');
emptyEl.addEventListener('dragover', e => { e.preventDefault(); emptyEl.classList.add('border-blue-400'); });
emptyEl.addEventListener('dragleave', () => emptyEl.classList.remove('border-blue-400'));
emptyEl.addEventListener('drop', e => {
e.preventDefault(); emptyEl.classList.remove('border-blue-400');
const f = e.dataTransfer.files[0];
if (f?.type === 'application/pdf') {
const dt = new DataTransfer(); dt.items.add(f);
const inp = document.getElementById('upload-pdf');
inp.files = dt.files;
inp.dispatchEvent(new Event('change'));
}
});
async function renderPage(n) {
const page = await state.pdfJsDoc.getPage(n);
const viewport = page.getViewport({ scale: state.renderScale });
const cv = document.getElementById('pdf-canvas');
cv.width = viewport.width;
cv.height = viewport.height;
await page.render({ canvasContext: cv.getContext('2d'), viewport }).promise;
document.getElementById('page-label').textContent = `第 ${n} 頁 / 共 ${state.totalPages} 頁`;
document.getElementById('btn-prev').disabled = n <= 1;
document.getElementById('btn-next').disabled = n >= state.totalPages;
refreshSigElements(n);
}
document.getElementById('btn-prev').addEventListener('click', () => { if (state.curPage > 1) renderPage(--state.curPage); });
document.getElementById('btn-next').addEventListener('click', () => { if (state.curPage < state.totalPages) renderPage(++state.curPage); });
// ── Floating signature elements ──────────────────────────────────
function addSig(src, page, x = 80, y = 80, w = 200, h = 80) {
const id = ++state.sigId;
state.sigs.push({ id, src, x, y, w, h, page });
mountSigEl(state.sigs.at(-1));
}
function refreshSigElements(page) {
document.querySelectorAll('.sig-el').forEach(el => el.remove());
state.sigs.filter(s => s.page === page).forEach(s => mountSigEl(s));
}
function mountSigEl(sig) {
const wrap = document.getElementById('pdf-container');
const el = document.createElement('div');
el.className = 'sig-el';
el.dataset.id = sig.id;
el.style.cssText = `left:${sig.x}px;top:${sig.y}px;width:${sig.w}px;height:${sig.h}px;`;
const img = document.createElement('img');
img.src = sig.src;
img.style.cssText = 'width:100%;height:100%;pointer-events:none;display:block;';
img.draggable = false;
el.appendChild(img);
// Delete button
const del = document.createElement('div');
del.className = 'sig-del'; del.innerHTML = '✕';
del.addEventListener('click', e => { e.stopPropagation(); state.sigs = state.sigs.filter(s => s.id !== sig.id); el.remove(); });
el.appendChild(del);
// Resize handles
['nw','ne','sw','se'].forEach(c => {
const h = document.createElement('div');
h.className = `sig-rs sig-rs-${c}`;
h.addEventListener('mousedown', e => startResize(e, el, sig, c));
el.appendChild(h);
});
el.addEventListener('mousedown', e => { if (e.target === el || e.target === img) startDrag(e, el, sig); });
el.addEventListener('touchstart', e => { if (e.target === el || e.target === img) startDragTouch(e, el, sig); }, { passive:false });
wrap.appendChild(el);
}
function startDrag(e, el, sig) {
e.preventDefault();
const ox = e.clientX - sig.x, oy = e.clientY - sig.y;
const mv = e => { sig.x = e.clientX - ox; sig.y = e.clientY - oy; el.style.left = sig.x+'px'; el.style.top = sig.y+'px'; };
const up = () => { removeEventListener('mousemove',mv); removeEventListener('mouseup',up); };
addEventListener('mousemove',mv); addEventListener('mouseup',up);
}
function startDragTouch(e, el, sig) {
e.preventDefault();
const t = e.touches[0], ox = t.clientX - sig.x, oy = t.clientY - sig.y;
const mv = e => { const t=e.touches[0]; sig.x=t.clientX-ox; sig.y=t.clientY-oy; el.style.left=sig.x+'px'; el.style.top=sig.y+'px'; };
const up = () => { removeEventListener('touchmove',mv); removeEventListener('touchend',up); };
addEventListener('touchmove',mv,{passive:false}); addEventListener('touchend',up);
}
function startResize(e, el, sig, c) {
e.preventDefault(); e.stopPropagation();
const sx=e.clientX,sy=e.clientY,ox=sig.x,oy=sig.y,ow=sig.w,oh=sig.h;
const mv = e => {
const dx=e.clientX-sx, dy=e.clientY-sy;
if(c==='se'){sig.w=Math.max(30,ow+dx);sig.h=Math.max(20,oh+dy);}
if(c==='sw'){sig.w=Math.max(30,ow-dx);sig.h=Math.max(20,oh+dy);sig.x=ox+ow-sig.w;}
if(c==='ne'){sig.w=Math.max(30,ow+dx);sig.h=Math.max(20,oh-dy);sig.y=oy+oh-sig.h;}
if(c==='nw'){sig.w=Math.max(30,ow-dx);sig.h=Math.max(20,oh-dy);sig.x=ox+ow-sig.w;sig.y=oy+oh-sig.h;}
el.style.left=sig.x+'px';el.style.top=sig.y+'px';el.style.width=sig.w+'px';el.style.height=sig.h+'px';
};
const up = () => { removeEventListener('mousemove',mv); removeEventListener('mouseup',up); };
addEventListener('mousemove',mv); addEventListener('mouseup',up);
}
// ── Import PNG signature ─────────────────────────────────────────
document.getElementById('upload-sig-img').addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
if (!state.pdfJsDoc) { toast('請先載入 PDF', true); return; }
const rd = new FileReader();
rd.onload = ev => { addSig(ev.target.result, state.curPage); toast('圖片簽名已插入,可拖曳調整'); };
rd.readAsDataURL(file);
e.target.value = '';
});
// ── Handwriting modal open/close ─────────────────────────────────
document.getElementById('btn-open-pad').addEventListener('click', () => {
if (!state.pdfJsDoc) { toast('請先載入 PDF', true); return; }
clearPad(); document.getElementById('modal-pad').classList.remove('hidden');
});
document.getElementById('btn-close-pad').addEventListener('click', closePad);
document.getElementById('modal-pad').addEventListener('click', e => { if(e.target===e.currentTarget) closePad(); });
function closePad() { document.getElementById('modal-pad').classList.add('hidden'); }
// ── Pen type selection ───────────────────────────────────────────
document.getElementById('pen-type-group').addEventListener('click', e => {
const btn = e.target.closest('.pen-btn');
if (!btn) return;
document.querySelectorAll('#pen-type-group .pen-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
pad.penType = btn.dataset.pen;
});
// ── Color selection ──────────────────────────────────────────────
document.getElementById('color-group').addEventListener('click', e => {
const btn = e.target.closest('.cdot');
if (!btn) return;
document.querySelectorAll('#color-group .cdot').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
pad.color = btn.dataset.color;
});
// ── Stroke width ─────────────────────────────────────────────────
document.getElementById('stroke-width').addEventListener('input', e => {
pad.lineWidth = +e.target.value;
document.getElementById('width-val').textContent = e.target.value;
});
// ── Canvas drawing ───────────────────────────────────────────────
const sigCv = document.getElementById('sig-canvas');
const sigCtx = sigCv.getContext('2d');
function clearPad() {
sigCtx.clearRect(0,0,sigCv.width,sigCv.height);
pad.history=[]; pad.hasContent=false;
document.getElementById('pad-hint').style.opacity='1';
}
function snapshot() {
pad.history.push(sigCtx.getImageData(0,0,sigCv.width,sigCv.height));
if(pad.history.length>40) pad.history.shift();
}
document.getElementById('btn-clear').addEventListener('click', clearPad);
document.getElementById('btn-undo').addEventListener('click', () => {
if(!pad.history.length) return;
sigCtx.putImageData(pad.history.pop(),0,0);
if(!pad.history.length) pad.hasContent=false;
});
function evPos(e) {
const r=sigCv.getBoundingClientRect(), sx=sigCv.width/r.width, sy=sigCv.height/r.height;
const s=e.touches?e.touches[0]:e;
return { x:(s.clientX-r.left)*sx, y:(s.clientY-r.top)*sy };
}
// ═══════════════════════════════════════════
// PEN ALGORITHMS
// ═══════════════════════════════════════════
/** 原子筆 uniform width, full opacity, crisp round joins */
function drawBallpoint(x,y,lx,ly,color,w) {
sigCtx.globalAlpha=1; sigCtx.strokeStyle=color;
sigCtx.lineWidth=w; sigCtx.lineCap='round'; sigCtx.lineJoin='round';
sigCtx.beginPath(); sigCtx.moveTo(lx,ly); sigCtx.lineTo(x,y); sigCtx.stroke();
}
/** 鋼筆 fast → thin; slow/turning → thick; smooth quadratic curve */
function drawFountain(x,y,lx,ly,color,w,vel) {
const t = Math.max(0.2, Math.min(1, 1 - vel/25));
const dyn = w*0.4 + w*t*1.8;
sigCtx.globalAlpha=1; sigCtx.strokeStyle=color;
sigCtx.lineWidth=dyn; sigCtx.lineCap='round'; sigCtx.lineJoin='round';
sigCtx.beginPath(); sigCtx.moveTo(lx,ly);
sigCtx.quadraticCurveTo((lx+x)/2,(ly+y)/2,x,y); sigCtx.stroke();
}
/**
* 毛筆 very wide when slow/turning, razor-thin when fast
* • Main stroke with heavy velocity modulation (0.05 → 4× base width)
* • Soft alpha edges (two passes: outer 40% alpha, inner 85%)
* • Bristle spread: 4 thin parallel lines fan out from centre
* • Ink-bleed dot at segment endpoint for 飛白 feel
*/
function drawBrush(x,y,lx,ly,color,w,vel) {
const t = Math.max(0.05, Math.min(1, 1 - vel/18));
const thick = w*0.3 + w*t*4.5; // 0.3w ... 4.8w
const angle = Math.atan2(y-ly, x-lx) + Math.PI/2;
// Outer soft halo
sigCtx.globalAlpha = 0.30;
sigCtx.strokeStyle = color;
sigCtx.lineWidth = thick*1.45;
sigCtx.lineCap='round'; sigCtx.lineJoin='round';
sigCtx.beginPath(); sigCtx.moveTo(lx,ly);
sigCtx.quadraticCurveTo((lx+x)/2,(ly+y)/2,x,y);
sigCtx.stroke();
// Core stroke
sigCtx.globalAlpha = 0.88;
sigCtx.lineWidth = thick;
sigCtx.beginPath(); sigCtx.moveTo(lx,ly);
sigCtx.quadraticCurveTo((lx+x)/2,(ly+y)/2,x,y);
sigCtx.stroke();
// Bristle lines (fan spread perpendicular to stroke direction)
const offsets = [-0.55, -0.3, 0.3, 0.55];
offsets.forEach(ratio => {
const off = thick * ratio;
sigCtx.globalAlpha = Math.max(0.08, 0.25 * t);
sigCtx.lineWidth = Math.max(0.4, thick*0.08);
sigCtx.beginPath();
sigCtx.moveTo(lx + Math.cos(angle)*off, ly + Math.sin(angle)*off);
sigCtx.lineTo(x + Math.cos(angle)*off, y + Math.sin(angle)*off);
sigCtx.stroke();
});
// 飛白 ink-bleed dot at end of segment when moving fast
if (vel > 12) {
sigCtx.globalAlpha = 0.10 + Math.random()*0.08;
sigCtx.beginPath();
sigCtx.arc(x, y, thick*0.4, 0, Math.PI*2);
sigCtx.fillStyle = color; sigCtx.fill();
}
sigCtx.globalAlpha = 1;
}
/** 鉛筆 low alpha, grainy texture that accumulates on overlap */
function drawPencil(x,y,lx,ly,color,w) {
const dist = Math.hypot(x-lx,y-ly) || 1;
const steps = Math.ceil(dist*1.5);
for(let i=0;i<=steps;i++){
const t=i/steps, px=lx+(x-lx)*t, py=ly+(y-ly)*t;
sigCtx.globalAlpha=0.32; sigCtx.fillStyle=color;
sigCtx.beginPath(); sigCtx.arc(px,py,w/2,0,Math.PI*2); sigCtx.fill();
if(Math.random()<0.45){
sigCtx.globalAlpha=0.10;
sigCtx.beginPath();
sigCtx.arc(px+(Math.random()-0.5)*w*1.8, py+(Math.random()-0.5)*w*1.8,
Math.random()*w*0.35+0.3, 0, Math.PI*2);
sigCtx.fill();
}
}
sigCtx.globalAlpha=1;
}
/** 奇異筆 wide, semi-transparent, square/miter edges */
function drawMarker(x,y,lx,ly,color,w) {
sigCtx.globalAlpha=0.65; sigCtx.strokeStyle=color;
sigCtx.lineWidth=w*3; sigCtx.lineCap='square'; sigCtx.lineJoin='miter';
sigCtx.beginPath(); sigCtx.moveTo(lx,ly); sigCtx.lineTo(x,y); sigCtx.stroke();
sigCtx.globalAlpha=1;
}
// ── Canvas event wiring ──────────────────────────────────────────
function onDown(e){
e.preventDefault(); snapshot();
pad.drawing=true; pad.hasContent=true;
const p=evPos(e); pad.lastX=p.x; pad.lastY=p.y; pad.lastVel=0;
document.getElementById('pad-hint').style.opacity='0';
}
function onMove(e){
if(!pad.drawing) return; e.preventDefault();
const p=evPos(e), vel=Math.hypot(p.x-pad.lastX,p.y-pad.lastY);
const {penType:pt,color:col,lineWidth:lw,lastX:lx,lastY:ly}=pad;
if(pt==='ballpoint') drawBallpoint(p.x,p.y,lx,ly,col,lw);
else if(pt==='fountain') drawFountain(p.x,p.y,lx,ly,col,lw,vel);
else if(pt==='brush') drawBrush (p.x,p.y,lx,ly,col,lw,vel);
else if(pt==='pencil') drawPencil (p.x,p.y,lx,ly,col,lw);
else if(pt==='marker') drawMarker (p.x,p.y,lx,ly,col,lw);
pad.lastX=p.x; pad.lastY=p.y; pad.lastVel=vel;
}
function onUp(){ pad.drawing=false; }
sigCv.addEventListener('mousedown', onDown);
sigCv.addEventListener('mousemove', onMove);
sigCv.addEventListener('mouseup', onUp);
sigCv.addEventListener('mouseleave', onUp);
sigCv.addEventListener('touchstart', onDown, {passive:false});
sigCv.addEventListener('touchmove', onMove, {passive:false});
sigCv.addEventListener('touchend', onUp);
// ── Apply handwritten signature ──────────────────────────────────
document.getElementById('btn-apply').addEventListener('click', () => {
if(!pad.hasContent){ toast('請先在簽名板書寫',true); return; }
const imgData=sigCtx.getImageData(0,0,sigCv.width,sigCv.height);
const b=contentBounds(imgData);
if(!b){ toast('簽名板是空的',true); return; }
const pad2=8;
const cx=Math.max(0,b.x1-pad2), cy=Math.max(0,b.y1-pad2);
const cw=Math.min(sigCv.width, b.x2+pad2)-cx;
const ch=Math.min(sigCv.height,b.y2+pad2)-cy;
const tmp=document.createElement('canvas'); tmp.width=cw; tmp.height=ch;
tmp.getContext('2d').drawImage(sigCv,cx,cy,cw,ch,0,0,cw,ch);
const dispW=Math.min(cw,280), dispH=Math.round(ch/cw*dispW);
addSig(tmp.toDataURL('image/png'), state.curPage, 100, 100, dispW, dispH);
closePad(); clearPad();
toast('簽名已插入,可拖曳調整位置與大小');
});
function contentBounds(imgData){
const {data,width,height}=imgData;
let x1=width,y1=height,x2=0,y2=0,found=false;
for(let y=0;y<height;y++) for(let x=0;x<width;x++)
if(data[(y*width+x)*4+3]>8){
if(x<x1)x1=x; if(x>x2)x2=x; if(y<y1)y1=y; if(y>y2)y2=y; found=true;
}
return found?{x1,y1,x2,y2}:null;
}
// ── Export PDF ───────────────────────────────────────────────────
document.getElementById('btn-export').addEventListener('click', async () => {
if(!state.rawBytes) { toast('請先載入 PDF',true); return; }
if(!state.sigs.length) { toast('尚未加入任何簽名',true); return; }
toast('正在產生 PDF…');
try {
const {PDFDocument}=PDFLib;
const doc=await PDFDocument.load(state.rawBytes);
for(const sig of state.sigs){
const page=doc.getPage(sig.page-1);
const {width:pW,height:pH}=page.getSize();
const pdfJsPage=await state.pdfJsDoc.getPage(sig.page);
const vp=pdfJsPage.getViewport({scale:state.renderScale});
const scX=pW/vp.width, scY=pH/vp.height;
const raw=sig.src.split(',')[1];
const bytes=Uint8Array.from(atob(raw),c=>c.charCodeAt(0));
const emb=sig.src.startsWith('data:image/png')
? await doc.embedPng(bytes)
: await doc.embedJpg(bytes);
page.drawImage(emb,{
x:sig.x*scX, y:pH-(sig.y+sig.h)*scY,
width:sig.w*scX, height:sig.h*scY,
});
}
const out=await doc.save();
const url=URL.createObjectURL(new Blob([out],{type:'application/pdf'}));
Object.assign(document.createElement('a'),{href:url,download:`簽名_${state.origName}.pdf`}).click();
URL.revokeObjectURL(url);
toast('PDF 匯出成功 ✓');
} catch(err){ toast('匯出失敗:'+err.message,true); console.error(err); }
});
// ── Toast ────────────────────────────────────────────────────────
let toastTm;
function toast(msg, isErr=false){
const el=document.getElementById('toast');
el.textContent=msg;
el.className=`fixed bottom-6 right-6 text-white text-sm px-5 py-3
rounded-xl shadow-xl z-50 ${isErr?'bg-red-600':'bg-slate-800'}`;
clearTimeout(toastTm);
toastTm=setTimeout(()=>el.classList.add('hidden'),3200);
}
</script>
</body>
</html>