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)=>`
`).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"}};