const mm2pt=mm=>mm*72/25.4; const sizes={A4:[595.28,841.89],Letter:[612,792],Legal:[612,1008],A3:[841.89,1190.55]}; const state={items:[]}; const file=document.getElementById("file"); const drop=document.getElementById("drop"); const thumbs=document.getElementById("thumbs"); const clearBtn=document.getElementById("clearBtn"); const reverseBtn=document.getElementById("reverseBtn"); const build=document.getElementById("build"); const buildSplit=document.getElementById("buildSplit"); const download=document.getElementById("download"); const out=document.getElementById("out"); const statusEl=document.getElementById("status"); const rangeEl=document.getElementById("range"); const subsetEl=document.getElementById("subset"); const outfileEl=document.getElementById("outfile"); drop.onclick=()=>file.click(); drop.ondragover=e=>{e.preventDefault();drop.style.borderColor="var(--brand)";}; drop.ondragleave=()=>{drop.style.borderColor="var(--line)";}; drop.ondrop=e=>{e.preventDefault();drop.style.borderColor="var(--line)";addFiles(e.dataTransfer.files)}; file.onchange=e=>addFiles(e.target.files); clearBtn.onclick=()=>{state.items.forEach(it=>URL.revokeObjectURL(it.url));state.items=[];renderThumbs();resetPreview();}; reverseBtn.onclick=()=>{state.items.reverse();renderThumbs()}; function resetPreview(){out.innerHTML=''+(i18n[document.documentElement.lang]||i18n.en).preview+'';download.classList.add("hidden");statusEl.textContent=""} function addFiles(list){ const arr=[...list].filter(f=>/^image\//.test(f.type)); arr.forEach(async f=>{ const url=URL.createObjectURL(f); const img=new Image();img.loading="eager"; img.onload=()=>{state.items.push({file:f,url,w:img.naturalWidth,h:img.naturalHeight});renderThumbs()}; img.src=url; }); } function renderThumbs(){ thumbs.innerHTML=state.items.map((it,i)=>`
Page ${i+1}
`).join(""); thumbs.querySelectorAll(".icon-btn").forEach(b=>b.onclick=thumbAction); } function thumbAction(e){ const i=+e.currentTarget.dataset.i; const a=e.currentTarget.dataset.act; if(a==="up"&&i>0){[state.items[i-1],state.items[i]]=[state.items[i],state.items[i-1]]} else if(a==="down"&&irBox){dH=boxH;dW=dH*rImg}else{dW=boxW;dH=dW/rImg}} else{if(rImg>rBox){dW=boxW;dH=dW/rImg}else{dH=boxH;dW=dH*rImg}} const tx=margin+(boxW-dW)/2;const ty=margin+(boxH-dH)/2;return [dW,dH,tx,ty] } function toU8(s){return new TextEncoder().encode(s)} function pad10(n){n=("0000000000"+n).slice(-10);return n} function hexToRgb01(hex){let c=hex.replace("#","");if(c.length===3){c=c[0]+c[0]+c[1]+c[1]+c[2]+c[2]}const r=parseInt(c.slice(0,2),16)/255,g=parseInt(c.slice(2,4),16)/255,b=parseInt(c.slice(4,6),16)/255;return [r,g,b]} async function rasterizeWithWM(srcUrl,opts){ const img=new Image();img.src=srcUrl;await img.decode(); const maxW=parseInt(document.getElementById("maxW").value,10)||0; const scale=(maxW>0 && img.naturalWidth>maxW)?maxW/img.naturalWidth:1; const w=Math.max(1,Math.round(img.naturalWidth*scale)); const h=Math.max(1,Math.round(img.naturalHeight*scale)); const can=document.createElement("canvas");can.width=w;can.height=h; const ctx=can.getContext("2d"); ctx.fillStyle=opts.bg;ctx.fillRect(0,0,w,h); /* keep for PNG transparency */ ctx.drawImage(img,0,0,w,h); if(opts.wm){ const sample=ctx.getImageData((w/2)|0,(h/2)|0,1,1).data; const luma=0.2126*sample[0]+0.7152*sample[1]+0.0722*sample[2]; const dark=luma<128; const fill=dark?"#ffffff":"#000000"; const stroke=dark?"#000000":"#ffffff"; ctx.save();ctx.textAlign="center";ctx.textBaseline="middle";ctx.translate(w/2,h/2);ctx.rotate(-Math.PI/6); ctx.font=opts.wmSize+"px system-ui, Arial, sans-serif"; ctx.lineWidth=3;ctx.strokeStyle=stroke;ctx.fillStyle=fill; if(opts.wmTile==="grid"){ ctx.globalAlpha=0.18; const step=opts.wmSize*6; for(let y=-h;y<=h;y+=step){ for(let x=-w;x<=w;x+=step){ ctx.save();ctx.translate(x,y);ctx.strokeText(opts.wm,0,0);ctx.fillText(opts.wm,0,0);ctx.restore(); } } }else{ ctx.globalAlpha=0.22;ctx.strokeText(opts.wm,0,0);ctx.fillText(opts.wm,0,0); } ctx.restore(); } const blob=await new Promise(r=>can.toBlob(r,"image/jpeg",opts.quality)); const arr=new Uint8Array(await blob.arrayBuffer()); return {data:arr,w,h} } function buildPDFBytes(pages,pw,ph,margin,fit,bgRgb){ const chunks=[];let offset=0;const offsets=[null];let obj=0; function push(buf){chunks.push(buf);offset+=buf.length} function wobj(str){obj++;offsets[obj]=offset;push(toU8(obj+" 0 obj\n"+str+"\nendobj\n"))} push(toU8("%PDF-1.4\n")); const pageIds=[]; const xobjs=[]; const drawBg = !(Math.abs(bgRgb[0]-1)<1e-6 && Math.abs(bgRgb[1]-1)<1e-6 && Math.abs(bgRgb[2]-1)<1e-6); const bgCmd = drawBg ? (`q ${bgRgb[0].toFixed(4)} ${bgRgb[1].toFixed(4)} ${bgRgb[2].toFixed(4)} rg 0 0 ${pw.toFixed(2)} ${ph.toFixed(2)} re f Q\n`) : ""; for(let i=0;i>\nstream\n")); push(im.data); push(toU8("\nendstream\nendobj\n")); xobjs.push(obj); const [dw,dh,tx,ty]=placeRect(im.w,im.h,pw,ph,margin,fit); const content = bgCmd + "q "+dw.toFixed(2)+" 0 0 "+dh.toFixed(2)+" "+tx.toFixed(2)+" "+ty.toFixed(2)+" cm /Im"+obj+" Do Q"; obj++;offsets[obj]=offset; const cbuf=toU8(content); push(toU8(obj+" 0 obj\n<< /Length "+cbuf.length+" >>\nstream\n"));push(cbuf);push(toU8("\nendstream\nendobj\n")); const contentsId=obj; obj++;offsets[obj]=offset; push(toU8(obj+" 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 "+pw.toFixed(2)+" "+ph.toFixed(2)+"] /Resources << /XObject << /Im"+xobjs[i]+" "+xobjs[i]+" 0 R >> /ProcSet [/PDF /ImageC] >> /Contents "+contentsId+" 0 R >>\nendobj\n")); pageIds.push(obj); } wobj("<< /Type /Pages /Kids [ "+pageIds.map(id=>id+" 0 R").join(" ")+" ] /Count "+pageIds.length+" >>");const pagesId=obj; wobj("<< /Type /Catalog /Pages "+pagesId+" 0 R >>");const rootId=obj; wobj("<< /Producer (Sys-Metrics PDF Toolkit) /Creator (Sys-Metrics) /Title (PDF Toolkit) >>");const infoId=obj; const xrefStart=offset; let xref="xref\n0 "+(obj+1)+"\n0000000000 65535 f \n"; for(let i=1;i<=obj;i++){xref+=pad10(offsets[i]||0)+" 00000 n \n"} const trailer="trailer\n<< /Size "+(obj+1)+" /Root "+rootId+" 0 R /Info "+infoId+" 0 R >>\nstartxref\n"+xrefStart+"\n%%EOF"; push(toU8(xref+trailer)); const total=chunks.reduce((a,b)=>a+b.length,0); const out=new Uint8Array(total);let p=0;for(const c of chunks){out.set(c,p);p+=c.length} return out } function parseRangeInput(count,txt){ if(!txt.trim())return []; const parts=txt.split(",");const set=new Set(); for(const part of parts){ const s=part.trim(); if(!s)continue; if(s.includes("-")){ const [a,b]=s.split("-").map(x=>parseInt(x.trim(),10)); if(isFinite(a)&&isFinite(b)){ const lo=Math.max(1,Math.min(a,b)),hi=Math.min(count,Math.max(a,b)); for(let i=lo;i<=hi;i++)set.add(i-1); } }else{ const n=parseInt(s,10);if(isFinite(n)&&n>=1&&n<=count)set.add(n-1); } } return Array.from(set).sort((a,b)=>a-b) } async function makePDF(selectedIdx=null){ if(!state.items.length){statusEl.textContent=(i18n[document.documentElement.lang]||i18n.en).add;return null} statusEl.textContent="…";download.classList.add("hidden");out.innerHTML=""; const margin=mm2pt(parseFloat(document.getElementById("margin").value||"15")); const [pw,ph]=pageBox(); const fit=document.getElementById("fit").value; const quality=parseFloat(document.getElementById("quality").value||"0.85"); const bgHex=document.getElementById("bg").value; const bgRgb=hexToRgb01(bgHex); const wm=document.getElementById("wm").value.trim(); const wmSize=parseInt(document.getElementById("wmSize").value,10); const wmTile=document.getElementById("wmTile").value; const indices=selectedIdx??state.items.map((_,i)=>i); const pages=[]; for(const i of indices){ const it=state.items[i]; const page=await rasterizeWithWM(it.url,{bg:bgHex,wm,wmSize,wmTile,quality}); pages.push(page); } const bytes=buildPDFBytes(pages,pw,ph,margin,fit,bgRgb); return new Blob([bytes],{type:"application/pdf"}) } function previewAndLink(blob,name){ const url=URL.createObjectURL(blob); const iframe=document.createElement("iframe"); iframe.title="PDF preview";iframe.style.width="100%";iframe.style.height="480px";iframe.style.border="0";iframe.src=url; out.innerHTML="";out.appendChild(iframe); download.href=url;download.download=name;download.classList.remove("hidden"); statusEl.textContent="OK"; } build.onclick=async()=>{try{ let name=(outfileEl.value||"pdf-toolkit.pdf").replace(/\s+/g,"-"); if(!/\.pdf$/i.test(name))name+=".pdf"; const subset=subsetEl.value==="range"?parseRangeInput(state.items.length,rangeEl.value):null; const blob=await makePDF(subset); if(blob)previewAndLink(blob,name); }catch(e){console.error(e);statusEl.textContent="Error"}}; buildSplit.onclick=async()=>{try{ if(!rangeEl.value.trim()){statusEl.textContent="Enter a range";return} const idx=parseRangeInput(state.items.length,rangeEl.value); if(!idx.length){statusEl.textContent="Nothing to export";return} const base=(outfileEl.value||"pdf-toolkit.pdf").replace(/\.pdf$/i,""); const blob=await makePDF(idx); if(blob)previewAndLink(blob,base+"-range.pdf"); }catch(e){console.error(e);statusEl.textContent="Error"}};