PDFIX / edit.html
Talha912's picture
Update edit.html
3451988 verified
Raw History Blame Contribute Delete
33 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDFix – Online PDF Editor</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf-lib/1.17.1/pdf-lib.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@pdf-lib/fontkit@1.1.1/dist/fontkit.umd.min.js"></script>
<style>
:root{--ink:#172033;--muted:#667085;--line:#d9e1ee;--blue:#2864e8;--soft:#e8f0ff;--red:#c93434}
*{box-sizing:border-box}body{margin:0;font-family:Inter,Segoe UI,Arial,sans-serif;color:var(--ink);background:#e9edf3}
button,input,select{font:inherit}
header{background:#fff;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20;padding:10px 16px;display:flex;flex-direction:column;align-items:center;gap:8px}
.top{width:100%;display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:9px;font-size:19px;font-weight:800}.logo{width:30px;height:30px;border-radius:9px;background:linear-gradient(135deg,#2864e8,#7c4dff);display:grid;place-items:center;color:#fff;font-size:16px}
.btn{cursor:pointer;border:1px solid var(--line);background:#fff;color:var(--ink);border-radius:8px;padding:7px 12px;font-weight:600;font-size:13px}.btn:hover{border-color:#9ab5f7}.btn.primary{background:var(--blue);color:#fff;border-color:var(--blue)}.btn.danger{color:var(--red)}.btn:disabled{opacity:.4;cursor:not-allowed}
.tb{display:flex;flex-wrap:wrap;justify-content:center;border:1px solid var(--blue);border-radius:8px;overflow:hidden;background:#fff}
.tb button{cursor:pointer;border:0;border-right:1px solid #c9d8fb;background:#fff;color:#1f3b8c;padding:9px 14px;font-size:14px;display:flex;align-items:center;gap:6px}.tb button:last-child{border-right:0}.tb button:hover{background:#f3f7ff}.tb button.active{background:var(--soft);font-weight:700}.tb button:disabled{opacity:.4;cursor:not-allowed}
.bar{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);flex-wrap:wrap;justify-content:center}.bar .btn{padding:4px 9px;font-size:12px}
.status{text-align:center;font-size:12px;color:var(--muted);padding:6px;background:#f7f9fc;border-bottom:1px solid var(--line)}
.cv{overflow:auto;padding:24px 12px 80px;min-height:calc(100vh - 150px)}
.welcome{max-width:520px;margin:6vh auto;text-align:center;background:#fff;border:1px solid var(--line);border-radius:18px;padding:40px 28px}.welcome h1{font-size:25px;margin:10px 0}.welcome p{color:var(--muted);line-height:1.6}
.page-wrap{position:relative;margin:0 auto;background:#fff;box-shadow:0 6px 28px rgba(22,34,61,.2)}.page-wrap canvas{display:block}
.overlay{position:absolute;inset:0;overflow:hidden;touch-action:none}.overlay.t{cursor:text}.overlay.c{cursor:crosshair}
.blk{position:absolute;border:1px dashed transparent;cursor:text}.blk:hover{border-color:#5b8cf5;background:rgba(40,100,232,.1)}
.item{position:absolute;cursor:move}.item.sel{outline:1px dashed var(--blue);outline-offset:2px}
.item.text{white-space:pre;min-width:8px}.item.editing{outline:2px solid var(--blue);background:rgba(255,255,255,.95);cursor:text;min-width:24px}.item.editing:empty:before{content:'Type here';color:#9aa5b8}
.item.highlight{mix-blend-mode:multiply;opacity:.45}.item.rect{border:2px solid}.item img{width:100%;height:100%;pointer-events:none}.ghost{position:absolute;border:1px dashed var(--blue);background:rgba(40,100,232,.1);pointer-events:none}
.hd{position:absolute;right:-6px;bottom:-6px;width:12px;height:12px;background:var(--blue);cursor:nwse-resize;border-radius:3px}
.ft{position:absolute;z-index:10;display:none;align-items:center;gap:2px;background:#fff;border:1px solid var(--blue);border-radius:8px;box-shadow:0 6px 20px rgba(22,34,61,.2);padding:3px;line-height:normal;white-space:nowrap}
.ft button{cursor:pointer;border:0;background:#fff;color:#1f3b8c;min-width:34px;height:34px;border-radius:6px;font-size:15px}.ft button:hover{background:#f3f7ff}.ft button.on{background:var(--soft)}
.ft input[type=number]{width:52px;height:30px;border:1px solid var(--line);border-radius:6px;text-align:center}.ft select{height:30px;border:1px solid var(--line);border-radius:6px;max-width:130px}.ft input[type=color]{width:34px;height:30px;border:1px solid var(--line);border-radius:6px;padding:1px;background:#fff}
.tl{position:absolute;inset:0;overflow:hidden;line-height:1;pointer-events:none}.tl.on{pointer-events:auto}
.tl span,.tl br{color:transparent;position:absolute;white-space:pre;cursor:text;transform-origin:0% 0%}.tl ::selection{background:rgba(40,100,232,.35)}
.overlay.s,.overlay.s *{pointer-events:none}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#172033;color:#fff;padding:11px 16px;border-radius:10px;font-size:13px;z-index:30;display:none;max-width:90vw}
</style>
</head>
<body>
<header>
<div class="top">
<div class="brand"><div class="logo">P</div>PDFix</div>
<div style="display:flex;gap:8px"><button class="btn" id="openBtn">Open PDF</button><button class="btn primary" id="saveBtn" disabled>Export PDF</button></div>
</div>
<div class="tb" id="tb">
<button data-mode="text" class="active">𝙸A Text</button>
<button data-mode="select">⌶ Select / Copy</button>
<button data-mode="image">▧ Image</button>
<button data-mode="cover">◇ Whiteout</button>
<button data-mode="highlight">🖍 Highlight</button>
<button data-mode="draw">✎ Draw</button>
<button data-mode="rect">▱ Rectangle</button>
<button id="fontBtn">Aa Fonts</button>
<button id="undoBtn" disabled>⎌ Undo</button>
</div>
<div class="bar">
<button class="btn" id="prevBtn" disabled>←</button><span id="pageLabel">No PDF open</span><button class="btn" id="nextBtn" disabled>→</button>
<span>|</span><button class="btn" id="zoomOut" disabled>−</button><span id="zoomLabel">125%</span><button class="btn" id="zoomIn" disabled>+</button><button class="btn" id="fitBtn" disabled>Fit width</button>
<span>|</span><button class="btn danger" id="delPage" disabled>Delete page</button>
<span id="fontNames" title="Custom fonts loaded">Fonts: none</span>
</div>
</header>
<input id="fileInput" type="file" accept="application/pdf,.pdf" hidden><input id="imageInput" type="file" accept="image/png,image/jpeg" hidden><input id="fontInput" type="file" accept=".ttf,.otf" multiple hidden>
<div class="status" id="status">Open a PDF, then click any text to edit it. Click empty space to add new text.</div>
<div class="cv" id="canvasArea">
<div class="welcome" id="welcome"><div style="font-size:50px">📄</div><h1>Edit PDF online</h1><p>Click text to edit it, move text boxes, and add images or shapes. Font matching depends on the PDF's embedded fonts; for the closest match, upload the original TTF/OTF font using Aa Fonts.</p><button class="btn primary" id="welcomeOpen" style="padding:11px 22px;font-size:15px">Choose a PDF</button></div>
</div>
<div id="toast" class="toast"></div>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const {PDFDocument,rgb,StandardFonts}=PDFLib,$=i=>document.getElementById(i);
const FF={sans:'Arial,Helvetica,sans-serif',serif:'"Times New Roman",Times,serif',mono:'"Courier New",Courier,monospace'};
const FN={sans:['Helvetica','HelveticaBold','HelveticaOblique','HelveticaBoldOblique'],serif:['TimesRoman','TimesRomanBold','TimesRomanItalic','TimesRomanBoldItalic'],mono:['Courier','CourierBold','CourierOblique','CourierBoldOblique']};
const MSG={select:'Select objects to move or delete. Use Duplicate to create another copy.',text:'Click any text to edit it. Click empty space to add text. Drag an object to move it.',image:'Choose an image to insert.',cover:'Drag over content to whiteout it.',highlight:'Drag to highlight.',draw:'Draw freely. Click the tool again to stop.',rect:'Drag to draw a rectangle.'};
const COL={highlight:'#ffeb3b',rect:'#2864e8',cover:'#ffffff',draw:'#2864e8'};
const ASC=.945,CF={};
let curPg,tl,bytes,doc,page=1,pages=0,zoom=1.25,mode='text',sel=null,editing=null,objs=[],undo=[],W=0,H=0,cv,ov,blocks=[],drawId=0;
const isC=f=>f.startsWith('c:'),BL=f=>CF[f]?CF[f].bl:ASC,uid=()=>Math.random().toString(36).slice(2),cur=()=>objs.find(o=>o.id===sel);
const fcss=o=>isC(o.family)&&CF[o.family]?`"${CF[o.family].name}",${FF[o.fb]||FF.sans}`:FF[o.family]||FF.sans;
const stk=o=>JSON.stringify([o.family,o.size,o.bold,o.italic,o.color]);
const toast=m=>{const t=$('toast');t.textContent=m;t.style.display='block';clearTimeout(toast.t);toast.t=setTimeout(()=>t.style.display='none',3200)};
const status=s=>$('status').textContent=s;
const snap=()=>{undo.push(JSON.stringify(objs));if(undo.length>40)undo.shift();$('undoBtn').disabled=false};
const xy=e=>{const r=ov.getBoundingClientRect();return{x:(e.clientX-r.left)/zoom,y:(e.clientY-r.top)/zoom}};
/* ---------- floating toolbar (like Sejda) ---------- */
const ft=document.createElement('div');ft.className='ft';
ft.innerHTML=`<button data-a="B" class="tx"><b>B</b></button><button data-a="I" class="tx"><i>I</i></button><button data-a="m" class="tx" title="Smaller">A−</button><input data-a="S" class="tx" type="number" min="4" max="200" step="0.5"><button data-a="p" class="tx" title="Bigger">A+</button><select data-a="F" class="tx"><option value="sans">Helvetica</option><option value="serif">Times</option><option value="mono">Courier</option></select><input data-a="C" type="color" title="Colour"><button data-a="D" title="Duplicate">⧉</button><button data-a="X" title="Delete">🗑</button>`;
const fq=a=>ft.querySelector(`[data-a="${a}"]`);
ft.onpointerdown=e=>{if(!/INPUT|SELECT/.test(e.target.tagName))e.preventDefault()};
const upd=fn=>{const o=cur();if(!o)return;fn(o);render()};
ft.onclick=e=>{const a=e.target.closest('button')?.dataset.a;if(!a)return;
if(a==='B')upd(o=>o.bold=!o.bold);if(a==='I')upd(o=>o.italic=!o.italic);
if(a==='m')upd(o=>{o.size=Math.max(4,+(o.size-1).toFixed(1));o.h=o.size*1.2});if(a==='p')upd(o=>{o.size=+(o.size+1).toFixed(1);o.h=o.size*1.2});
if(a==='D'){const o=cur();if(!o)return;endEdit();snap();const c=JSON.parse(JSON.stringify(o));c.id=uid();c.page=page;delete c.key;delete c.orig;delete c.st;c.x=Math.min(Math.max(0,c.x+18),Math.max(0,W-c.w));c.y=Math.min(Math.max(0,c.y+18),Math.max(0,H-c.h));objs.push(c);sel=c.id;editing=null;render();toast('Object duplicated.')}
if(a==='X')del()};
ft.oninput=e=>{const a=e.target.dataset.a;if(a==='S'){const v=+e.target.value;if(v>=4)upd(o=>{o.size=v;o.h=v*1.2})}else if(a==='F')upd(o=>o.family=e.target.value);else if(a==='C')upd(o=>o.color=e.target.value)};
function del(){const o=cur();if(!o)return;endEdit();snap();
if(o.key&&o.type==='cover')objs=objs.filter(x=>x.key!==o.key);else objs=objs.filter(x=>x.id!==o.id);sel=null;render()}
function syncFT(){
const o=cur(),el=o&&ov&&ov.querySelector(`[data-id="${o.id}"]`);ft.style.display=o&&el?'flex':'none';if(!o||!el)return;
const t=o.type==='text',set=(a,f)=>{const n=fq(a);if(document.activeElement!==n)f(n)};
ft.querySelectorAll('.tx').forEach(n=>n.style.display=t?'':'none');
if(t&&isC(o.family)){fq('B').style.display='none';fq('I').style.display='none'}
set('C',n=>n.value=o.color);if(t){set('F',n=>n.value=o.family);set('S',n=>n.value=Math.round(o.size*2)/2);fq('B').classList.toggle('on',!!o.bold);fq('I').classList.toggle('on',!!o.italic)}
const top=el.offsetTop<56?el.offsetTop+el.offsetHeight+8:el.offsetTop-50;ft.style.left=Math.max(0,el.offsetLeft)+'px';ft.style.top=top+'px';
}
/* ---------- open / render ---------- */
$('openBtn').onclick=$('welcomeOpen').onclick=()=>$('fileInput').click();
$('fileInput').onchange=async e=>{const f=e.target.files[0];if(!f)return;try{bytes=new Uint8Array(await f.arrayBuffer());await load();objs=[];undo=[];sel=editing=null;page=1;$('welcome').hidden=true;$('undoBtn').disabled=true;await draw();status(f.name+' · '+pages+' page(s). '+MSG.text)}catch(x){console.error(x);toast('Could not open this PDF (it may be encrypted).')}e.target.value=''};
async function load(){doc=await pdfjsLib.getDocument({data:bytes.slice()}).promise;pages=doc.numPages}
async function draw(){
const my=++drawId,pg=await doc.getPage(page),vp=pg.getViewport({scale:zoom}),b=pg.getViewport({scale:1});W=b.width;H=b.height;
$('canvasArea').querySelectorAll('.page-wrap').forEach(n=>n.remove());
const wrap=document.createElement('div');wrap.className='page-wrap';wrap.style.width=vp.width+'px';wrap.style.height=vp.height+'px';
cv=document.createElement('canvas');
const dpr=Math.max(1,Math.min(2,window.devicePixelRatio||1));
cv.width=Math.ceil(vp.width*dpr);cv.height=Math.ceil(vp.height*dpr);
cv.style.width=vp.width+'px';cv.style.height=vp.height+'px';wrap.appendChild(cv);
tl=document.createElement('div');tl.className='tl';wrap.appendChild(tl);
ov=document.createElement('div');ov.onpointerdown=ovDown;wrap.appendChild(ov);wrap.appendChild(ft);$('canvasArea').appendChild(wrap);
await pg.render({canvasContext:cv.getContext('2d',{willReadFrequently:true}),viewport:vp,transform:dpr===1?null:[dpr,0,0,dpr,0,0]}).promise;
if(my!==drawId)return;curPg=pg;const tc=await pg.getTextContent();if(my!==drawId)return;
try{pdfjsLib.renderTextLayer({textContentSource:tc,container:tl,viewport:vp,textDivs:[]})}catch(e){console.warn('text layer',e)}
blocks=await getBlocks(pg,b,tc);render();ctl();
}
function ctl(){const l=!!doc;['zoomIn','zoomOut','fitBtn','saveBtn','delPage'].forEach(i=>$(i).disabled=!l);$('prevBtn').disabled=!l||page<=1;$('nextBtn').disabled=!l||page>=pages;$('pageLabel').textContent=l?`Page ${page} / ${pages}`:'No PDF open';$('zoomLabel').textContent=Math.round(zoom*100)+'%'}
/* ---------- read existing text: position, font, size, colour ---------- */
async function getBlocks(pg,b,tc){
const out=[];let last=null;
for(const it of tc.items){
if(!it.str||!it.transform)continue;const t=it.transform,size=Math.hypot(t[2],t[3]);if(!size)continue;
const [x,y]=b.convertToViewportPoint(t[4],t[5]);
if(last&&last.font===it.fontName&&Math.abs(last.y-y)<size*.3&&x>=last.x&&x-(last.x+last.w)<size*.8){
const gap=x-(last.x+last.w);if(gap>size*.2&&!/\s$/.test(last.text)&&!/^\s/.test(it.str))last.text+=' ';
last.text+=it.str;last.w=x+it.width-last.x;continue}
let name='';try{name=pg.commonObjs.get(it.fontName).name||''}catch(e){}
last={x,y,w:it.width,size,font:it.fontName,text:it.str,name,ff:(tc.styles[it.fontName]||{}).fontFamily||''};out.push(last);
}
return out.filter(o=>o.text.trim());
}
function fam(o){
const n=(o.name+' '+o.ff).toLowerCase().replace(/^[a-z]{6}\\+/,'');
if(/courier|mono|consolas|typewriter|lucida console/.test(n))return'mono';
if(/times|georgia|garamond|cambria|palatino|minion|bookman|roman|baskerville/.test(n)||o.ff==='serif')return'serif';
return 'sans';
}
function cssSourceFont(o){
const n=(o.name+' '+o.ff).toLowerCase();
if(/arial/.test(n))return 'Arial, Helvetica, sans-serif';
if(/helvetica/.test(n))return 'Helvetica, Arial, sans-serif';
if(/calibri/.test(n))return 'Calibri, Arial, sans-serif';
if(/aptos/.test(n))return 'Aptos, Calibri, Arial, sans-serif';
if(/verdana/.test(n))return 'Verdana, Arial, sans-serif';
if(/tahoma/.test(n))return 'Tahoma, Arial, sans-serif';
if(/times new roman|timesroman/.test(n))return '"Times New Roman", Times, serif';
if(/georgia/.test(n))return 'Georgia, serif';
if(/courier new|courier/.test(n))return '"Courier New", Courier, monospace';
return FF[fam(o)]||FF.sans;
}
function sample(x,y,w,h){
const X=Math.max(0,Math.floor(x*zoom)),Y=Math.max(0,Math.floor(y*zoom)),w2=Math.max(1,Math.min(cv.width-X,Math.ceil(w*zoom))),h2=Math.max(1,Math.min(cv.height-Y,Math.ceil(h*zoom)));
const d=cv.getContext('2d').getImageData(X,Y,w2,h2).data,cnt={};let bg=[255,255,255],bn=0;
for(let i=0;i<d.length;i+=4){const k=(d[i]>>4)<<8|(d[i+1]>>4)<<4|d[i+2]>>4,e=cnt[k]||(cnt[k]={n:0,c:[d[i],d[i+1],d[i+2]]});if(++e.n>bn){bn=e.n;bg=e.c}}
let fg=[0,0,0],md=-1;for(let i=0;i<d.length;i+=4){const dd=Math.abs(d[i]-bg[0])+Math.abs(d[i+1]-bg[1])+Math.abs(d[i+2]-bg[2]);if(dd>md){md=dd;fg=[d[i],d[i+1],d[i+2]]}}
const hx=a=>'#'+a.map(v=>v.toString(16).padStart(2,'0')).join('');return{bg:hx(bg),fg:md<60?'#000000':hx(fg)};
}
/* ---------- custom fonts: auto-matched by name ---------- */
const nrm=s=>s.replace(/^[A-Z]{6}\+/,'').toLowerCase().replace(/[^a-z0-9]/g,''),STY=/bold|italic|oblique|regular|black|heavy|demi|semi|light|medium/g;
const sty=n=>(/bold|black|heavy|demi/.test(n)?1:0)+(/italic|oblique/.test(n)?2:0);
function matchCustom(bk){const n=nrm(bk.name),b=n.replace(STY,'');if(b.length<3)return null;let best=null,bs=9;
for(const k in CF){const m=nrm(CF[k].name),mb=m.replace(STY,'');if(mb.length<3||!(b.includes(mb)||mb.includes(b)))continue;const d=Math.abs(sty(n)-sty(m));if(d<bs){bs=d;best=k}}return best}
function fontList(){$('fontNames').textContent='Fonts: '+(Object.values(CF).filter(f=>!f.emb).map(f=>f.name).join(', ')||'none');const s=fq('F');s.querySelectorAll('.cu').forEach(n=>n.remove());
for(const k in CF){const op=new Option(CF[k].label||CF[k].name,k);op.className='cu';s.add(op)}}
/* use the PDF's own embedded font program (exact look) when the browser/fontkit can read it */
const BAD=new Set();
function embFor(bk){const k='c:emb-'+bk.font;if(CF[k])return k;if(BAD.has(k))return null;
try{const fo=curPg.commonObjs.get(bk.font),d=fo&&fo.data;if(!d||!d.length)throw 0;
const u8=new Uint8Array(d),fk=fontkit.create(u8),a=fk.ascent/fk.unitsPerEm,dd=Math.abs(fk.descent)/fk.unitsPerEm;
CF[k]={name:fo.loadedName,label:'PDF font: '+(bk.name||'embedded').replace(/^[A-Z]{6}\+/,''),bytes:u8,bl:(1.2-(a+dd))/2+a,emb:1};fontList();return k}
catch(e){BAD.add(k);return null}}
$('fontBtn').onclick=()=>$('fontInput').click();
$('fontInput').onchange=async e=>{
for(const f of e.target.files){try{
const buf=await f.arrayBuffer(),name=f.name.replace(/\.[^.]+$/,'').replace(/["']/g,''),ff=new FontFace(name,buf.slice(0));await ff.load();document.fonts.add(ff);
let bl=ASC;try{const fk=fontkit.create(new Uint8Array(buf)),a=fk.ascent/fk.unitsPerEm,d=Math.abs(fk.descent)/fk.unitsPerEm;bl=(1.2-(a+d))/2+a}catch(x){}
CF['c:'+name]={name,bytes:new Uint8Array(buf),bl};
}catch(x){console.error(x);toast('Could not load font '+f.name)}}
e.target.value='';fontList();toast('Fonts loaded. Upload the PDF’s own fonts (regular/bold/italic files) and they match automatically.')};
/* ---------- editing existing text ---------- */
function editBlock(bk){
endEdit();const key=page+':'+Math.round(bk.x)+':'+Math.round(bk.y);let t=objs.find(o=>o.key===key&&o.type==='text');snap();
if(!t){const top=bk.y-ASC*bk.size,h=bk.size*1.2,cl=sample(bk.x,top,bk.w,h),um=matchCustom(bk),cm=um||embFor(bk),family=cm||fam(bk);
if(!objs.find(o=>o.key===key&&o.type==='cover'))objs.push({id:uid(),page,type:'cover',key,x:bk.x-1,y:top-.5,w:bk.w+3,h:h+1,color:cl.bg});
t={id:uid(),page,type:'text',key,x:bk.x,y:bk.y-BL(family)*bk.size,h,text:bk.text,orig:bk.text,size:+bk.size.toFixed(1),family,sourceCss:cssSourceFont(bk),fb:fam(bk),bold:/bold|black|heavy|demi/i.test(bk.name),italic:/italic|oblique/i.test(bk.name),color:cl.fg};
t.st=stk(t);objs.push(t);if(um)toast('Matched your font: '+CF[um].name)}
sel=editing=t.id;render();
}
function endEdit(){const o=objs.find(x=>x.id===editing);if(o)finish(o,true)}
function finish(o,quiet){
if(editing!==o.id)return;const el=ov.querySelector('.editing'),v=el?el.innerText.replace(/\n$/,''):(o.draft??o.text);editing=null;delete o.draft;
if(o.orig!==undefined&&v===o.orig&&stk(o)===o.st)objs=objs.filter(x=>x.key!==o.key); // unchanged → restore original
else if(!v.trim())objs=objs.filter(x=>x.id!==o.id); // emptied → text deleted
else o.text=v;
if(!objs.includes(o))sel=null;if(!quiet)render();
}
/* ---------- overlay ---------- */
function render(){
if(!ov)return;const old=ov.querySelector('.editing');
if(old){const o=objs.find(x=>x.id===editing);if(o)o.draft=old.innerText.replace(/\n$/,'');old.onblur=null}
ov.innerHTML='';ov.className='overlay '+(mode==='text'?'t':mode==='select'?'s':'c');if(tl)tl.classList.toggle('on',mode==='select');
if(mode==='text')for(const bk of blocks){
const d=document.createElement('div');
d.className='blk';
d.title='Drag to move · double-click to edit';
d.style.cssText=`left:${bk.x*zoom}px;top:${(bk.y-ASC*bk.size)*zoom}px;width:${Math.max(8,bk.w)*zoom}px;height:${Math.max(12,bk.size*1.2)*zoom}px;touch-action:none;user-select:none`;
let sx=0,sy=0,moved=false,created=null,baseX=0,baseY=0;
d.onpointerdown=e=>{
if(e.button!==0)return;
e.preventDefault();e.stopPropagation();
sx=e.clientX;sy=e.clientY;moved=false;created=null;
try{d.setPointerCapture(e.pointerId)}catch(_){}
const mv=ev=>{
const dx=ev.clientX-sx,dy=ev.clientY-sy;
if(!moved && Math.hypot(dx,dy)>3){
moved=true;
endEdit();
const key=page+':'+Math.round(bk.x)+':'+Math.round(bk.y);
created=objs.find(o=>o.key===key&&o.type==='text');
if(!created){
snap();
const top=bk.y-ASC*bk.size,h=Math.max(12,bk.size*1.2);
const cl=sample(bk.x,top,Math.max(8,bk.w),h);
const um=matchCustom(bk),cm=um||embFor(bk),family=cm||fam(bk);
if(!objs.find(o=>o.key===key&&o.type==='cover')){
objs.push({id:uid(),page,type:'cover',key,x:Math.max(0,bk.x-1),y:Math.max(0,top-.5),w:Math.max(8,bk.w)+3,h:h+1,color:cl.bg});
}
created={id:uid(),page,type:'text',key,x:bk.x,y:bk.y-BL(family)*bk.size,h,
text:bk.text,orig:bk.text,size:+bk.size.toFixed(1),family,sourceCss:cssSourceFont(bk),
fb:fam(bk),bold:/bold|black|heavy|demi/i.test(bk.name),
italic:/italic|oblique/i.test(bk.name),color:cl.fg};
created.st=stk(created);objs.push(created);
}else snap();
sel=created.id;editing=null;
render();
const current=ov.querySelector('.item[data-id="'+created.id+'"]');
if(current){current.style.pointerEvents='none';current.style.outline='1px dashed #2864e8'}
baseX=created.x;baseY=created.y;
}
if(moved && created){
// Apply horizontal and vertical pointer movement independently.
created.x=Math.max(0,Math.min(W-created.size,baseX+dx/zoom));
created.y=Math.max(0,Math.min(H-created.size,baseY+dy/zoom));
const current=ov.querySelector('.item[data-id="'+created.id+'"]');
if(current){
current.style.left=(created.x*zoom)+'px';
current.style.top=(created.y*zoom)+'px';
}
syncFT();
}
};
const up=()=>{
removeEventListener('pointermove',mv);
removeEventListener('pointerup',up);
removeEventListener('pointercancel',up);
if(moved && created){render();toast('Text moved.')}
};
addEventListener('pointermove',mv);
addEventListener('pointerup',up);
addEventListener('pointercancel',up);
};
d.ondblclick=e=>{e.preventDefault();e.stopPropagation();editBlock(bk)};
ov.appendChild(d);
}
for(const o of objs.filter(o=>o.page===page)){
const el=document.createElement('div'),s=el.style;el.className='item '+o.type+(sel===o.id?' sel':'');el.dataset.id=o.id;s.left=o.x*zoom+'px';s.top=o.y*zoom+'px';
if(o.type==='text'){
const cu=isC(o.family);s.fontSize=o.size*zoom+'px';s.lineHeight=o.size*1.2*zoom+'px';s.color=o.color;s.fontFamily=(o.sourceCss&&!cu&&!CF[o.family])?o.sourceCss:fcss(o);s.fontWeight=!cu&&o.bold?700:400;s.fontStyle=!cu&&o.italic?'italic':'normal';
el.textContent=editing===o.id&&o.draft!==undefined?o.draft:o.text;
if(editing===o.id){el.contentEditable='true';el.classList.add('editing');
setTimeout(()=>{el.focus();const r=document.createRange();r.selectNodeContents(el);const g=getSelection();g.removeAllRanges();g.addRange(r)});
el.onblur=()=>finish(o);el.onpaste=e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'))};
el.onkeydown=e=>{if(e.key==='Escape'){delete o.draft;el.textContent=o.text;el.blur()}else if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();el.blur()}}}
}else{
s.width=o.w*zoom+'px';s.height=o.h*zoom+'px';
if(o.type==='cover'||o.type==='highlight')s.background=o.color;else if(o.type==='rect')s.borderColor=o.color;
else if(o.type==='image'){const im=document.createElement('img');im.src=o.src;el.appendChild(im)}
else if(o.type==='draw')el.innerHTML=`<svg width="${o.w*zoom+4}" height="${o.h*zoom+4}" style="overflow:visible"><polyline fill="none" stroke="${o.color}" stroke-width="${2*zoom}" stroke-linecap="round" stroke-linejoin="round" points="${o.pts.map(p=>p.x*zoom+','+p.y*zoom).join(' ')}"/></svg>`;
if(sel===o.id&&o.type!=='draw'&&mode==='text'){const h=document.createElement('div');h.className='hd';h.onpointerdown=e=>{e.stopPropagation();snap();const sx=e.clientX,sy=e.clientY,w0=o.w,h0=o.h;
const mv=ev=>{o.w=Math.max(5,w0+(ev.clientX-sx)/zoom);o.h=Math.max(5,h0+(ev.clientY-sy)/zoom);s.width=o.w*zoom+'px';s.height=o.h*zoom+'px'};
const up=()=>{removeEventListener('pointermove',mv);removeEventListener('pointerup',up)};addEventListener('pointermove',mv);addEventListener('pointerup',up)};el.appendChild(h)}
}
el.onpointerdown=e=>{
if(mode!=='text'||editing===o.id)return;
if(e.target.classList.contains('hd')) return;
e.preventDefault(); e.stopPropagation();
endEdit(); sel=o.id;
const sx=e.clientX,sy=e.clientY,ox=o.x,oy=o.y;
let moved=false;
const mv=ev=>{
const dx=ev.clientX-sx,dy=ev.clientY-sy;
if(!moved&&Math.hypot(dx,dy)>3){moved=true;snap();el.classList.add('sel')}
if(moved){
o.x=Math.max(0,ox+dx/zoom);o.y=Math.max(0,oy+dy/zoom);
s.left=o.x*zoom+'px';s.top=o.y*zoom+'px';
syncFT();
}
};
const up=()=>{
removeEventListener('pointermove',mv);
removeEventListener('pointerup',up);
removeEventListener('pointercancel',up);
if(!moved&&o.type==='text'){snap();editing=o.id}
render();
};
addEventListener('pointermove',mv);
addEventListener('pointerup',up);
addEventListener('pointercancel',up);
};
ov.appendChild(el);
}
syncFT();
}
function add(o){snap();o.id=uid();o.page=page;objs.push(o);sel=o.id;$('saveBtn').disabled=false;if(mode!=='draw')setMode('text');else render();return o}
function ovDown(e){
if(!doc)return;const p=xy(e);
if(mode==='text'){
if(e.target!==ov)return;
if(editing||sel){endEdit();sel=null;render();return}
snap();const o={id:uid(),page,type:'text',x:p.x,y:p.y,h:16.8,text:'',size:14,family:'sans',bold:false,italic:false,color:'#000000'};objs.push(o);sel=editing=o.id;render();return}
if(!COL[mode])return;
const g=document.createElement('div');g.className='ghost';ov.appendChild(g);const pts=[p],md=mode;
const mv=ev=>{const q=xy(ev);pts.push(q);Object.assign(g.style,{left:Math.min(p.x,q.x)*zoom+'px',top:Math.min(p.y,q.y)*zoom+'px',width:Math.abs(q.x-p.x)*zoom+'px',height:Math.abs(q.y-p.y)*zoom+'px'});if(md==='draw'){g.style.border='0';g.style.background='transparent'}};
const up=ev=>{removeEventListener('pointermove',mv);removeEventListener('pointerup',up);g.remove();const q=xy(ev);
if(md==='draw'){if(pts.length<3)return;const x=Math.min(...pts.map(a=>a.x)),y=Math.min(...pts.map(a=>a.y));add({type:'draw',x,y,w:Math.max(...pts.map(a=>a.x))-x,h:Math.max(...pts.map(a=>a.y))-y,pts:pts.map(a=>({x:a.x-x,y:a.y-y})),color:COL.draw})}
else{const w=Math.abs(q.x-p.x),h=Math.abs(q.y-p.y);if(w>3&&h>3)add({type:md,x:Math.min(p.x,q.x),y:Math.min(p.y,q.y),w,h,color:COL[md]})}};
addEventListener('pointermove',mv);addEventListener('pointerup',up);
}
/* ---------- toolbar ---------- */
function setMode(m){endEdit();if(m===mode&&m!=='text')m='text';mode=m;if(m!=='text')sel=null;
document.querySelectorAll('#tb [data-mode]').forEach(b=>b.classList.toggle('active',b.dataset.mode===m));status(MSG[m]);if(m==='image'&&doc)$('imageInput').click();render()}
document.querySelectorAll('#tb [data-mode]').forEach(b=>b.onclick=()=>setMode(b.dataset.mode));
$('imageInput').onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();r.onload=()=>{const im=new Image();im.onload=()=>{const k=Math.min(1,220/im.width);add({type:'image',x:40,y:40,w:im.width*k,h:im.height*k,src:r.result})};im.src=r.result};r.readAsDataURL(f);e.target.value=''};
$('undoBtn').onclick=()=>{const s=undo.pop();if(s){objs=JSON.parse(s);sel=editing=null;render()}$('undoBtn').disabled=!undo.length};
addEventListener('keydown',e=>{
if(/INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)||editing)return;
if((e.key==='Delete'||e.key==='Backspace')&&sel){e.preventDefault();del()}
if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();$('undoBtn').click()}
});
const go=n=>{if(n<1||n>pages)return;endEdit();page=n;sel=editing=null;draw()};
$('prevBtn').onclick=()=>go(page-1);$('nextBtn').onclick=()=>go(page+1);
$('zoomIn').onclick=()=>{endEdit();zoom=Math.min(3,zoom+.15);draw()};$('zoomOut').onclick=()=>{endEdit();zoom=Math.max(.5,zoom-.15);draw()};
$('fitBtn').onclick=()=>{endEdit();zoom=Math.max(.5,Math.min(3,($('canvasArea').clientWidth-40)/W));draw()};
$('delPage').onclick=async()=>{if(pages<2)return toast('A PDF needs at least one page.');if(!confirm('Delete page '+page+'?'))return;
const d=await PDFDocument.load(bytes);d.removePage(page-1);bytes=await d.save();objs=objs.filter(o=>o.page!==page);objs.forEach(o=>{if(o.page>page)o.page--});
await load();page=Math.min(page,pages);undo=[];$('undoBtn').disabled=true;sel=editing=null;draw()};
/* ---------- export ---------- */
const hexC=h=>{const n=parseInt(h.slice(1),16);return rgb((n>>16&255)/255,(n>>8&255)/255,(n&255)/255)};
$('saveBtn').onclick=async()=>{try{
endEdit();status('Preparing PDF…');const out=await PDFDocument.load(bytes),fc={},csC=new Map();let warn=false;out.registerFontkit(fontkit);
const getF=async o=>{if(isC(o.family)&&CF[o.family]){const k=o.family;return fc[k]||(fc[k]=await out.embedFont(CF[k].bytes,{subset:true}))}
const k=FN[FN[o.family]?o.family:'sans'][(o.bold?1:0)+(o.italic?2:0)];return fc[k]||(fc[k]=await out.embedFont(StandardFonts[k]))};
for(const o of objs){
if(o.page<1||o.page>out.getPageCount())continue;
const p=out.getPage(o.page-1),{width:pw,height:ph}=p.getSize(),sx=pw/W,sy=ph/H;
if(o.type==='text'){
const csOf=f=>{let c=csC.get(f);if(!c){c=new Set(f.getCharacterSet());csC.set(f,c)}return c};
const fbO={family:FN[o.fb]?o.fb:'sans',bold:o.bold,italic:o.italic};let f,fb=null;
try{f=await getF(o);if(isC(o.family)&&CF[o.family])fb=await getF(fbO)}catch(err){console.warn('font embed failed',err);f=await getF(fbO);fb=null}
const cs=csOf(f),fcs=fb&&csOf(fb),sz=o.size*sy;
String(o.text).split('\n').forEach((ln,i)=>{
let x=o.x*sx;const y=ph-(o.y+BL(o.family)*o.size+i*o.size*1.2)*sy,runs=[];
for(const ch of ln){const cp=ch.codePointAt(0);let uf=f,c=ch;
if(!cs.has(cp)){if(fb&&fcs.has(cp))uf=fb;else{c='?';warn=true;uf=fb||f}}
const last=runs[runs.length-1];if(last&&last.f===uf)last.t+=c;else runs.push({f:uf,t:c})}
for(const r of runs){p.drawText(r.t,{x,y,size:sz,font:r.f,color:hexC(o.color)});x+=r.f.widthOfTextAtSize(r.t,sz)}});
continue}
const x=o.x*sx,y=ph-(o.y+o.h)*sy,w=o.w*sx,h=o.h*sy;
if(o.type==='cover')p.drawRectangle({x,y,width:w,height:h,color:hexC(o.color),borderWidth:0});
else if(o.type==='highlight')p.drawRectangle({x,y,width:w,height:h,color:hexC(o.color),opacity:.45,borderWidth:0});
else if(o.type==='rect')p.drawRectangle({x,y,width:w,height:h,borderColor:hexC(o.color),borderWidth:2});
else if(o.type==='image'){const d=Uint8Array.from(atob(o.src.split(',')[1]),c=>c.charCodeAt(0)),im=o.src.startsWith('data:image/png')?await out.embedPng(d):await out.embedJpg(d);p.drawImage(im,{x,y,width:w,height:h})}
else if(o.type==='draw')for(let j=1;j<o.pts.length;j++)p.drawLine({start:{x:(o.x+o.pts[j-1].x)*sx,y:ph-(o.y+o.pts[j-1].y)*sy},end:{x:(o.x+o.pts[j].x)*sx,y:ph-(o.y+o.pts[j].y)*sy},thickness:2,color:hexC(o.color)});
}
const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([await out.save()],{type:'application/pdf'}));a.download='PDFix-edited.pdf';a.click();
status('Exported.');toast(warn?'Exported. Some characters are not supported by the font and became “?”.':'Edited PDF exported.');
}catch(e){console.error(e);toast('Export failed.');status('Export failed.')}};
</script>
</body>
</html>