Add APNG format support for animated signature export
- Backend: Add cubic bezier solver, static frame generator, PNG parser, CRC32, and APNG encoder for server-side APNG generation via format=apng - Backend: Cache raw font buffer for resvg rendering with custom fonts - Frontend: Add format selector UI (SVG / APNG buttons) in download tab - Frontend: Add browser-based APNG encoder using Canvas API - Frontend: Add progress indicator during APNG frame generation - Frontend: Update API URLs section with APNG Animation URL - Add package.json with @resvg/resvg-js dependency - Add .gitignore to exclude node_modules Co-authored-by: Corgiking0127 <54504179+Corgiking0127@users.noreply.github.com>
This commit is contained in:
1 parent
9ee25317fa
commit
136e0dfc89
5 files changed
+464
-4
No files matched your search
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
+96
-2
@@ -1,4 +1,8 @@
|
||||
const https = require("https");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
let Resvg;try{Resvg=require("@resvg/resvg-js").Resvg}catch(e){}
|
||||
|
||||
// ─── Font & Color Data ───────────────────────────────────────────
|
||||
const FONTS = {
|
||||
@@ -28,7 +32,7 @@ async function loadFont(k){
|
||||
if(!all.length) throw 0;
|
||||
const w=await get(all[all.length-1][1]); if(!w.ok) throw 0;
|
||||
// Omit unicode-range so the embedded font applies to all characters
|
||||
cache[k]={css:`@font-face{font-family:'${f.family}';font-style:${f.style};font-weight:${f.weight};src:url(data:font/woff2;base64,${w.buf.toString("base64")}) format('woff2');}`,m:"ok"};
|
||||
cache[k]={css:`@font-face{font-family:'${f.family}';font-style:${f.style};font-weight:${f.weight};src:url(data:font/woff2;base64,${w.buf.toString("base64")}) format('woff2');}`,m:"ok",fontBuf:w.buf};
|
||||
}catch(e){/* keep fallback */}
|
||||
}
|
||||
|
||||
@@ -60,7 +64,87 @@ ${b.t?"":`<rect width="${W}" height="${H}" rx="4" fill="${b.bg}"/>`}${dt}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Vercel Handler ──────────────────────────────────────────────
|
||||
// ─── APNG Support ────────────────────────────────────────────────
|
||||
const _crcT=(()=>{const t=new Uint32Array(256);for(let n=0;n<256;n++){let c=n;for(let k=0;k<8;k++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);t[n]=c}return t})();
|
||||
function _crc32(b){let c=0xFFFFFFFF;for(let i=0;i<b.length;i++)c=_crcT[(c^b[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0}
|
||||
|
||||
function _bez(x1,y1,x2,y2){return t=>{if(t<=0)return 0;if(t>=1)return 1;let b=t;for(let i=0;i<8;i++){const x=3*(1-b)*(1-b)*b*x1+3*(1-b)*b*b*x2+b*b*b;const d=3*(1-b)*(1-b)*x1+6*(1-b)*b*(x2-x1)+3*b*b*(1-x2);if(Math.abs(x-t)<1e-6)break;if(Math.abs(d)>1e-6)b-=(x-t)/d}return 3*(1-b)*(1-b)*b*y1+3*(1-b)*b*b*y2+b*b*b}}
|
||||
const _easeStd=_bez(0.25,0.1,0.25,1);
|
||||
const _easeFl=_bez(0.4,0,0.2,1);
|
||||
|
||||
function buildStaticFrame(text,font,fk,color,bgC,progress){
|
||||
const W=600,H=200,b=bi(bgC);
|
||||
const sk=font.skewX?`skewX(${font.skewX})`:"";
|
||||
const f=fl(font,text,W,H);
|
||||
const dt=b.t?"":dots(W,H,b.gr);
|
||||
const clipW=W*_easeStd(progress);
|
||||
const penX=W*_easeStd(progress);
|
||||
const penOp=progress<=0.92?1:Math.max(0,1-(progress-0.92)/0.08);
|
||||
let dashOff=parseFloat(f.l);
|
||||
if(progress>0.72&&progress<=0.95){const sp=(progress-0.72)/(0.95-0.72);dashOff=parseFloat(f.l)*(1-_easeFl(sp))}
|
||||
else if(progress>0.95)dashOff=0;
|
||||
const txtEl=`<text x="${W/2}" y="${H/2+font.size*.08+font.yo}" font-family="'${font.family}',cursive,serif" font-size="${font.size}" font-weight="${font.weight}" font-style="${font.style}" fill="${color}" text-anchor="middle" dominant-baseline="middle" letter-spacing="${font.ls}" transform="translate(0,0) ${sk}" transform-origin="${W/2} ${H/2}" ${font.sw?`stroke="${color}" stroke-width="${font.sw}"`:""}>${esc(text)}</text>`;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
|
||||
<style>${fc(fk)}</style>
|
||||
<defs><clipPath id="r"><rect x="0" y="0" width="${clipW.toFixed(1)}" height="${H}"/></clipPath></defs>
|
||||
${b.t?"":`<rect width="${W}" height="${H}" rx="4" fill="${b.bg}"/>`}${dt}
|
||||
<g clip-path="url(#r)">${txtEl}</g>
|
||||
<g opacity="${penOp.toFixed(2)}" transform="translate(${penX.toFixed(1)},0)"><g transform="translate(0,${H/2-8}) rotate(22)"><rect x="-1.5" y="-28" width="3" height="26" rx="1" fill="${b.pn}"/><polygon points="0,1 -1.8,-5 1.8,-5" fill="${color}"/></g></g>
|
||||
<path d="${f.d}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linecap="round" opacity=".45" stroke-dasharray="${f.l}" stroke-dashoffset="${dashOff.toFixed(1)}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function _parsePNG(buf){const chunks=[];let pos=8;while(pos<buf.length){const len=buf.readUInt32BE(pos);const type=buf.slice(pos+4,pos+8).toString("ascii");const data=buf.slice(pos+8,pos+8+len);chunks.push({type,data,raw:buf.slice(pos,pos+12+len)});pos+=12+len;if(type==="IEND")break}return chunks}
|
||||
function _mkChunk(type,data){const t=Buffer.from(type,"ascii");const l=Buffer.alloc(4);l.writeUInt32BE(data.length,0);const ci=Buffer.concat([t,data]);const c=Buffer.alloc(4);c.writeUInt32BE(_crc32(ci),0);return Buffer.concat([l,t,data,c])}
|
||||
|
||||
function encodeAPNG(pngBuffers,delays){
|
||||
const parts=[Buffer.from([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A])];
|
||||
const fc0=_parsePNG(pngBuffers[0]);
|
||||
const ihdr=fc0.find(c=>c.type==="IHDR");
|
||||
parts.push(ihdr.raw);
|
||||
const actl=Buffer.alloc(8);actl.writeUInt32BE(pngBuffers.length,0);actl.writeUInt32BE(0,4);
|
||||
parts.push(_mkChunk("acTL",actl));
|
||||
let seq=0;
|
||||
for(let i=0;i<pngBuffers.length;i++){
|
||||
const chunks=i===0?fc0:_parsePNG(pngBuffers[i]);
|
||||
const fctl=Buffer.alloc(26);
|
||||
fctl.writeUInt32BE(seq++,0);
|
||||
fctl.writeUInt32BE(ihdr.data.readUInt32BE(0),4);
|
||||
fctl.writeUInt32BE(ihdr.data.readUInt32BE(4),8);
|
||||
fctl.writeUInt32BE(0,12);fctl.writeUInt32BE(0,16);
|
||||
fctl.writeUInt16BE(delays[i],20);fctl.writeUInt16BE(1000,22);
|
||||
fctl.writeUInt8(0,24);fctl.writeUInt8(0,25);
|
||||
parts.push(_mkChunk("fcTL",fctl));
|
||||
const idats=chunks.filter(c=>c.type==="IDAT");
|
||||
if(i===0){for(const d of idats)parts.push(d.raw)}
|
||||
else{for(const d of idats){const fd=Buffer.alloc(4+d.data.length);fd.writeUInt32BE(seq++,0);d.data.copy(fd,4);parts.push(_mkChunk("fdAT",fd))}}
|
||||
}
|
||||
parts.push(_mkChunk("IEND",Buffer.alloc(0)));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
async function generateAPNG(text,font,fk,color,speed,bgC){
|
||||
if(!Resvg)throw new Error("APNG requires @resvg/resvg-js");
|
||||
const dur=2.4/speed;
|
||||
const fps=20,frameCount=Math.min(60,Math.max(12,Math.ceil(dur*fps)));
|
||||
const delay=Math.round(dur*1000/frameCount);
|
||||
const holdFrames=5,holdDelay=200;
|
||||
const fontOpts={loadSystemFonts:false,defaultFontFamily:"serif"};
|
||||
if(cache[fk]&&cache[fk].fontBuf){
|
||||
const tmp=path.join(os.tmpdir(),"sig_"+fk+".woff2");
|
||||
fs.writeFileSync(tmp,cache[fk].fontBuf);
|
||||
fontOpts.fontFiles=[tmp];
|
||||
}
|
||||
const pngs=[],dly=[];
|
||||
for(let i=0;i<frameCount+holdFrames;i++){
|
||||
const progress=i<frameCount?i/(frameCount-1):1;
|
||||
const svg=buildStaticFrame(text,font,fk,color,bgC,progress);
|
||||
const r=new Resvg(svg,{fitTo:{mode:"width",value:600},font:fontOpts});
|
||||
pngs.push(r.render().asPng());
|
||||
dly.push(i<frameCount?delay:holdDelay);
|
||||
}
|
||||
return encodeAPNG(pngs,dly);
|
||||
}
|
||||
module.exports = async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
if (req.method === "OPTIONS") { res.status(204).end(); return; }
|
||||
@@ -80,6 +164,16 @@ module.exports = async (req, res) => {
|
||||
const color= COLORS[ck]||COLORS.navy;
|
||||
if(!cache[fk]||cache[fk].m==="fb") await loadFont(fk);
|
||||
|
||||
if(fmt==="apng"){
|
||||
try{
|
||||
const apng=await generateAPNG(text,font,fk,color,spd,bgC);
|
||||
res.setHeader("Content-Type","image/apng");
|
||||
res.setHeader("Cache-Control","public,s-maxage=3600,stale-while-revalidate=86400");
|
||||
res.status(200).send(apng);
|
||||
}catch(e){res.status(500).json({error:"APNG generation failed",detail:e.message})}
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = buildSVG(text,font,fk,color,spd,bgC,fmt!=="static");
|
||||
res.setHeader("Content-Type","image/svg+xml;charset=utf-8");
|
||||
res.setHeader("Cache-Control","public,s-maxage=3600,stale-while-revalidate=86400");
|
||||
|
||||
Generated
+227
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"name": "readme-signature-generate",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@resvg/resvg-js": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||
"integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@resvg/resvg-js-android-arm-eabi": "2.6.2",
|
||||
"@resvg/resvg-js-android-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-x64": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm-eabi": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
|
||||
"integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-x64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
|
||||
"integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
|
||||
"integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-arm64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-ia32-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-x64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@resvg/resvg-js": "^2.6.2"
|
||||
}
|
||||
}
|
||||
+134
-2
@@ -76,6 +76,15 @@ input[type=text]:focus{border-color:rgba(200,162,78,.6)}
|
||||
.srv-reset{padding:10px 16px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:6px;color:var(--dim);font-size:11px;cursor:pointer;letter-spacing:1px;white-space:nowrap}
|
||||
.footer{text-align:center;margin-top:48px;padding-top:24px;border-top:1px solid rgba(255,255,255,.05)}
|
||||
.footer p{color:var(--dark);font-size:11px;letter-spacing:2px}
|
||||
.fmt-row{display:flex;justify-content:center;gap:12px;margin-bottom:20px}
|
||||
.fmt-btn{padding:12px 20px;cursor:pointer;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:8px;transition:all .25s;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:120px}
|
||||
.fmt-btn.on{background:rgba(200,162,78,.15);border-color:rgba(200,162,78,.5)}
|
||||
.fmt-icon{font-size:24px}
|
||||
.fmt-label{font-size:13px;font-weight:600;color:#8a8578;letter-spacing:1px}
|
||||
.fmt-btn.on .fmt-label{color:#f0e6d3}
|
||||
.fmt-desc{font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:#5a5550}
|
||||
.fmt-btn.on .fmt-desc{color:var(--gold)}
|
||||
.apng-progress{display:none;text-align:center;padding:12px;color:var(--dim);font-size:12px;letter-spacing:1px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -135,12 +144,17 @@ input[type=text]:focus{border-color:rgba(200,162,78,.6)}
|
||||
<!-- Local tab -->
|
||||
<div id="tab-local">
|
||||
<div style="text-align:center">
|
||||
<p style="color:var(--dim);font-size:13px;margin-bottom:8px">Download an animated SVG — scalable, lightweight, and crisp at any size.</p>
|
||||
<p style="color:var(--dim);font-size:13px;margin-bottom:12px">Choose a format and download your signature.</p>
|
||||
<div class="fmt-row">
|
||||
<button class="fmt-btn on" data-fmt="svg" onclick="setFormat('svg')"><span class="fmt-icon">📐</span><span class="fmt-label">SVG</span><span class="fmt-desc">Vector · Scalable</span></button>
|
||||
<button class="fmt-btn" data-fmt="apng" onclick="setFormat('apng')"><span class="fmt-icon">🎞️</span><span class="fmt-label">APNG</span><span class="fmt-desc">Animated PNG</span></button>
|
||||
</div>
|
||||
<div style="display:inline-flex;gap:12px;align-items:center;background:rgba(200,162,78,.06);border:1px solid rgba(200,162,78,.15);border-radius:8px;padding:10px 20px;margin-bottom:24px">
|
||||
<span style="font-size:11px;color:var(--dim)">Size ≈</span>
|
||||
<span id="svgSize" style="font-size:14px;color:var(--gold);font-weight:600">—</span>
|
||||
</div>
|
||||
<div><button class="gold-btn" onclick="downloadSvg()">↓ Download Animated SVG</button></div>
|
||||
<div id="apngProgress" class="apng-progress">⏳ Generating frames… <span id="apngPct">0</span>%</div>
|
||||
<div><button class="gold-btn" id="dlBtn" onclick="doDownload()">↓ Download Animated SVG</button></div>
|
||||
<div style="margin-top:24px"><button class="outline-btn" id="copySrcBtn" onclick="copySvgSrc()">Copy SVG Source Code</button></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,6 +319,7 @@ function updateUrls(){
|
||||
const rows=[
|
||||
{l:"Animated SVG URL",u:svgU,f:"svg"},
|
||||
{l:"Static SVG URL",u:staticU,f:"static"},
|
||||
{l:"APNG Animation URL",u:svgU+"&format=apng",f:"apng"},
|
||||
{l:"HTML Embed",u:`<img src="${svgU}" alt="Signature" />`,f:"html"},
|
||||
{l:"Markdown Embed",u:``,f:"md"},
|
||||
];
|
||||
@@ -336,6 +351,123 @@ function renderSpeedBtns(){
|
||||
document.getElementById("speedFill").style.width=`${(state.speed/3)*100}%`;
|
||||
}
|
||||
|
||||
// ─── Format Selector & APNG Download ────────────────────────────
|
||||
let dlFormat="svg";
|
||||
function setFormat(fmt){
|
||||
dlFormat=fmt;
|
||||
document.querySelectorAll(".fmt-btn").forEach(b=>b.classList.toggle("on",b.dataset.fmt===fmt));
|
||||
const btn=document.getElementById("dlBtn");
|
||||
btn.textContent=fmt==="apng"?"↓ Download APNG Animation":"↓ Download Animated SVG";
|
||||
document.getElementById("copySrcBtn").style.display=fmt==="apng"?"none":"";
|
||||
}
|
||||
function doDownload(){if(dlFormat==="apng")downloadAPNG();else downloadSvg()}
|
||||
|
||||
// ─── Browser APNG Encoder ───────────────────────────────────────
|
||||
function _bezFn(x1,y1,x2,y2){return function(t){if(t<=0)return 0;if(t>=1)return 1;let b=t;for(let i=0;i<8;i++){const x=3*(1-b)*(1-b)*b*x1+3*(1-b)*b*b*x2+b*b*b;const d=3*(1-b)*(1-b)*x1+6*(1-b)*b*(x2-x1)+3*b*b*(1-x2);if(Math.abs(x-t)<1e-6)break;if(Math.abs(d)>1e-6)b-=(x-t)/d}return 3*(1-b)*(1-b)*b*y1+3*(1-b)*b*b*y2+b*b*b}}
|
||||
const _easeStd=_bezFn(0.25,0.1,0.25,1),_easeFl=_bezFn(0.4,0,0.2,1);
|
||||
|
||||
function buildStaticFrameSVG(text,fontFamily,inkColor,bgColor,speed,fontUrl,fontWeight,fontStyle,progress){
|
||||
const W=600,H=180,bg=bgColor||"#faf8f3",isT=bg==="transparent";
|
||||
const estW=text.length*28,fsx=W/2-estW/2,fBy=H/2+36;
|
||||
let fD=`M ${fsx} ${fBy}`;
|
||||
for(let x=0;x<=estW;x+=4){const t=x/estW;fD+=` L ${(fsx+x).toFixed(1)} ${(fBy+Math.sin(t*Math.PI*2.5)*6*(1-t*.7)).toFixed(1)}`}
|
||||
const fLen=(estW*1.05).toFixed(0);
|
||||
const bR=isT?250:(parseInt(bg.slice(1,3),16)||250),bG=isT?248:(parseInt(bg.slice(3,5),16)||248),bB=isT?243:(parseInt(bg.slice(5,7),16)||243);
|
||||
const lum=(bR*299+bG*587+bB*114)/1000;
|
||||
const grain=lum>128?"rgba(0,0,0,.035)":"rgba(255,255,255,.06)",pen=lum>128?"#3a3a3a":"#aaa";
|
||||
let dots="";if(!isT)for(let i=0;i<50;i++)dots+=`<circle cx="${(i*137+29)%W}" cy="${(i*89+17)%H}" r=".7" fill="${grain}"/>`;
|
||||
const fi=fontUrl?`@import url('${fontUrl.replace(/&/g,"&")}');`:"";
|
||||
const clipW=W*_easeStd(progress);
|
||||
const penX=W*_easeStd(progress);
|
||||
const penOp=progress<=0.92?1:Math.max(0,1-(progress-0.92)/0.08);
|
||||
let dashOff=parseFloat(fLen);
|
||||
if(progress>0.72&&progress<=0.95){const sp=(progress-0.72)/(0.95-0.72);dashOff=parseFloat(fLen)*(1-_easeFl(sp))}
|
||||
else if(progress>0.95)dashOff=0;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
|
||||
<style>${fi}</style>
|
||||
<defs><clipPath id="reveal"><rect x="0" y="0" width="${clipW.toFixed(1)}" height="${H}"/></clipPath></defs>
|
||||
${isT?"":`<rect width="${W}" height="${H}" rx="6" fill="${bg}"/>`}${dots}
|
||||
<g clip-path="url(#reveal)"><text x="${W/2}" y="${H/2+4}" font-family="${fontFamily}" font-size="60" font-weight="${fontWeight||"normal"}" font-style="${fontStyle||"normal"}" fill="${inkColor}" text-anchor="middle" dominant-baseline="middle" letter-spacing="1">${escXml(text)}</text></g>
|
||||
<g opacity="${penOp.toFixed(2)}" transform="translate(${penX.toFixed(1)},0)"><g transform="translate(0,${H/2-22}) rotate(22)"><rect x="-1.8" y="-28" width="3.6" height="26" rx="1.2" fill="${pen}"/><polygon points="0,1 -2,-5 2,-5" fill="${inkColor}"/></g></g>
|
||||
<path d="${fD}" fill="none" stroke="${inkColor}" stroke-width="1.4" stroke-linecap="round" opacity=".5" stroke-dasharray="${fLen}" stroke-dashoffset="${dashOff.toFixed(1)}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function _svgToImg(svgStr){return new Promise((ok,no)=>{const img=new Image();img.onload=()=>ok(img);img.onerror=no;img.src="data:image/svg+xml;charset=utf-8,"+encodeURIComponent(svgStr)})}
|
||||
|
||||
const _crcT=(()=>{const t=new Uint32Array(256);for(let n=0;n<256;n++){let c=n;for(let k=0;k<8;k++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);t[n]=c}return t})();
|
||||
function _crc32(b){let c=0xFFFFFFFF;for(let i=0;i<b.length;i++)c=_crcT[(c^b[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0}
|
||||
function _ru32(b,o){return(b[o]<<24|b[o+1]<<16|b[o+2]<<8|b[o+3])>>>0}
|
||||
function _wu32(b,o,v){b[o]=(v>>>24)&0xFF;b[o+1]=(v>>>16)&0xFF;b[o+2]=(v>>>8)&0xFF;b[o+3]=v&0xFF}
|
||||
function _wu16(b,o,v){b[o]=(v>>>8)&0xFF;b[o+1]=v&0xFF}
|
||||
function _parsePNGChunks(buf){const u=new Uint8Array(buf);const chunks=[];let pos=8;while(pos<u.length){const len=_ru32(u,pos);const type=String.fromCharCode(u[pos+4],u[pos+5],u[pos+6],u[pos+7]);const data=u.slice(pos+8,pos+8+len);chunks.push({type,data,raw:u.slice(pos,pos+12+len)});pos+=12+len;if(type==="IEND")break}return chunks}
|
||||
function _mkPNGChunk(type,data){const t=new Uint8Array([type.charCodeAt(0),type.charCodeAt(1),type.charCodeAt(2),type.charCodeAt(3)]);const r=new Uint8Array(12+data.length);_wu32(r,0,data.length);r.set(t,4);r.set(data,8);const ci=new Uint8Array(4+data.length);ci.set(t,0);ci.set(data,4);_wu32(r,8+data.length,_crc32(ci));return r}
|
||||
|
||||
async function _encodeAPNG(pngBlobs,delays){
|
||||
const bufs=await Promise.all(pngBlobs.map(b=>b.arrayBuffer()));
|
||||
const fc0=_parsePNGChunks(bufs[0]);
|
||||
const ihdr=fc0.find(c=>c.type==="IHDR");
|
||||
const parts=[new Uint8Array([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]),ihdr.raw];
|
||||
const actl=new Uint8Array(8);_wu32(actl,0,bufs.length);_wu32(actl,4,0);
|
||||
parts.push(_mkPNGChunk("acTL",actl));
|
||||
let seq=0;
|
||||
for(let i=0;i<bufs.length;i++){
|
||||
const chunks=i===0?fc0:_parsePNGChunks(bufs[i]);
|
||||
const fctl=new Uint8Array(26);
|
||||
_wu32(fctl,0,seq++);_wu32(fctl,4,_ru32(ihdr.data,0));_wu32(fctl,8,_ru32(ihdr.data,4));
|
||||
_wu32(fctl,12,0);_wu32(fctl,16,0);_wu16(fctl,20,delays[i]);_wu16(fctl,22,1000);
|
||||
fctl[24]=0;fctl[25]=0;
|
||||
parts.push(_mkPNGChunk("fcTL",fctl));
|
||||
const idats=chunks.filter(c=>c.type==="IDAT");
|
||||
if(i===0){for(const d of idats)parts.push(d.raw)}
|
||||
else{for(const d of idats){const fd=new Uint8Array(4+d.data.length);_wu32(fd,0,seq++);fd.set(d.data,4);parts.push(_mkPNGChunk("fdAT",fd))}}
|
||||
}
|
||||
parts.push(_mkPNGChunk("IEND",new Uint8Array(0)));
|
||||
let total=0;for(const p of parts)total+=p.length;
|
||||
const out=new Uint8Array(total);let off=0;for(const p of parts){out.set(p,off);off+=p.length}
|
||||
return new Blob([out],{type:"image/apng"});
|
||||
}
|
||||
|
||||
async function downloadAPNG(){
|
||||
const name=document.getElementById("nameInput").value;
|
||||
if(!name.trim())return;
|
||||
const btn=document.getElementById("dlBtn");
|
||||
const prog=document.getElementById("apngProgress");
|
||||
const pctEl=document.getElementById("apngPct");
|
||||
const origText=btn.textContent;
|
||||
btn.textContent="⏳ Generating…";btn.disabled=true;prog.style.display="block";
|
||||
try{
|
||||
const dur=2.4/state.speed;
|
||||
const fps=20,frameCount=Math.min(60,Math.max(12,Math.ceil(dur*fps)));
|
||||
const delay=Math.round(dur*1000/frameCount);
|
||||
const holdFrames=5,holdDelay=200;
|
||||
const W=600,H=180;
|
||||
const canvas=document.createElement("canvas");canvas.width=W;canvas.height=H;
|
||||
const ctx=canvas.getContext("2d");
|
||||
let fontCSS="";
|
||||
if(state.font.url){
|
||||
const fd=await fetchFontBase64(state.font);
|
||||
if(fd)fontCSS=`@font-face{font-family:'${fd.familyName}';font-style:${state.font.fontStyle||"normal"};font-weight:${state.font.weight||"normal"};src:url(data:font/woff2;base64,${fd.base64}) format('woff2');}`;
|
||||
}
|
||||
const pngs=[],delays=[];
|
||||
const totalFrames=frameCount+holdFrames;
|
||||
for(let i=0;i<totalFrames;i++){
|
||||
const progress=i<frameCount?i/(frameCount-1):1;
|
||||
let svg=buildStaticFrameSVG(name,state.font.family,state.ink.color,state.bg,state.speed,state.font.url,state.font.weight,state.font.fontStyle,progress);
|
||||
if(fontCSS&&state.font.url){const imp=`@import url('${state.font.url.replace(/&/g,"&")}');`;svg=svg.replace(imp,fontCSS)}
|
||||
const img=await _svgToImg(svg);
|
||||
ctx.clearRect(0,0,W,H);ctx.drawImage(img,0,0,W,H);
|
||||
const blob=await new Promise(r=>canvas.toBlob(r,"image/png"));
|
||||
pngs.push(blob);delays.push(i<frameCount?delay:holdDelay);
|
||||
pctEl.textContent=Math.round((i+1)/totalFrames*100);
|
||||
}
|
||||
const apng=await _encodeAPNG(pngs,delays);
|
||||
const a=document.createElement("a");a.href=URL.createObjectURL(apng);
|
||||
a.download=`signature_${name.replace(/\s+/g,"_")}.apng`;a.click();URL.revokeObjectURL(a.href);
|
||||
}catch(e){console.error("APNG generation failed:",e);alert("APNG generation failed. Please try SVG format.")}
|
||||
finally{btn.textContent=origText;btn.disabled=false;prog.style.display="none"}
|
||||
}
|
||||
|
||||
// Boot
|
||||
document.getElementById("apiBase").value=defaultBase();
|
||||
renderFontGrid();renderColorRow();renderBgSwatches();renderSpeedBtns();
|
||||
|
||||
Reference in new issue
Block a user