上傳檔案到「/」 #1
+628
@@ -0,0 +1,628 @@
|
||||
<!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">×</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 1–10 -->
|
||||
<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>
|
||||
Reference in New Issue
Block a user