<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>PDF → Excel (cliente) — Layout fiel</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:20px}
.card{border:1px solid #e3e3e3;padding:18px;border-radius:8px;max-width:980px}
label{display:block;margin-bottom:8px}
pre{white-space:pre-wrap;background:#f8f8f8;padding:10px;border-radius:6px; height:220px; overflow:auto}
button{padding:10px 14px;border-radius:6px;border:0;background:#2563eb;color:#fff}
input[type=number]{width:80px}
</style>
</head>
<body>
<div class="card">
<h2>PDF → Excel (cliente) — Layout mais fiel</h2>
<p>Escolha um PDF (texto, não escaneado). O script tenta manter posição X/Y criando uma grade mais parecida com o PDF.</p>
<label>Arquivo PDF: <input id="filePdf" type="file" accept="application/pdf"></label>
<label>Páginas (ex.: 1-3 ou 1,3,5; deixe vazio para todas): <input id="pages" type="text" placeholder="1-3 or 1,3,5"></label>
<label>Gap X (px) — quanto menor, mais colunas: <input id="xgap" type="number" value="8"/></label>
<label>Gap Y (px) — quanto maior, mais separação entre linhas: <input id="ygap" type="number" value="4"/></label>
<div style="margin-top:10px">
<button id="btn">Gerar Excel</button>
</div>
<h3>Status</h3>
<pre id="log">Aguardando arquivo...</pre>
</div>
<!-- PDF.js CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script>
<!-- SheetJS (XLSX) CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';
const fileInput = document.getElementById('filePdf');
const btn = document.getElementById('btn');
const logEl = document.getElementById('log');
const pagesInput = document.getElementById('pages');
const xgapInput = document.getElementById('xgap');
const ygapInput = document.getElementById('ygap');
function log(msg){
logEl.textContent += '\\n' + msg;
logEl.scrollTop = logEl.scrollHeight;
}
function parsePagesSpec(spec, total){
if(!spec) return Array.from({length: total}, (_,i)=>i+1);
spec = spec.replace(/\\s+/g,'');
const parts = spec.split(',');
const out = new Set();
for(const p of parts){
if(p.includes('-')){
const [a,b] = p.split('-').map(x=>parseInt(x,10));
if(!isNaN(a) && !isNaN(b)) for(let i=a;i<=b;i++) if(i>=1 && i<=total) out.add(i);
} else {
const n = parseInt(p,10);
if(!isNaN(n) && n>=1 && n<=total) out.add(n);
}
}
return Array.from(out).sort((a,b)=>a-b);
}
// cria buckets X por proximidade (gap menor => mais colunas)
function makeXBuckets(xs, gap){
xs = Array.from(new Set(xs)).sort((a,b)=>a-b);
if(xs.length===0) return [];
const buckets = [{sum: xs[0], count: 1, center: xs[0]}];
for(let i=1;i<xs.length;i++){
const x = xs[i];
const last = buckets[buckets.length-1];
if(x - last.center > gap){
buckets.push({sum:x,count:1,center:x});
} else {
last.sum += x; last.count += 1; last.center = last.sum/last.count;
}
}
return buckets.map(b=>b.center);
}
btn.addEventListener('click', async ()=>{
logEl.textContent = '';
const f = fileInput.files[0];
if(!f){ alert('Escolha um arquivo PDF primeiro'); return; }
log('Lendo arquivo...');
const arrayBuffer = await f.arrayBuffer();
log('Abrindo PDF com pdf.js (scale=2)');
const loadingTask = pdfjsLib.getDocument({data: arrayBuffer});
const pdf = await loadingTask.promise;
log('Páginas no PDF: ' + pdf.numPages);
const pagesToProcess = parsePagesSpec(pagesInput.value, pdf.numPages);
log('Páginas a processar: ' + pagesToProcess.join(', '));
const allRows = []; // array de arrays (grid)
let globalMaxCols = 0;
const pageMetrics = []; // para col widths
for(const pnum of pagesToProcess){
log('Processando página ' + pnum);
// scale maior para melhor posição (posições menos inteiras)
const page = await pdf.getPage(pnum);
const viewport = page.getViewport({scale:2});
const textContent = await page.getTextContent();
const items = textContent.items.map(it => {
const t = it.transform;
const x = t[4];
const y = t[5];
return {str: it.str.replace(/\\s+$/,'').replace(/^\\s+/,'') , x: x, y: y};
}).filter(i => i.str && i.str.length>0);
if(items.length===0){ log(' => Sem texto nesta página'); continue; }
// Agrupa por Y arredondado (linha) usando ygap
const ygap = Number(ygapInput.value) || 4;
const groupedByY = new Map();
const allXs = [];
for(const it of items){
const yKey = Math.round(it.y / ygap) * ygap; // quantiza pelo ygap
if(!groupedByY.has(yKey)) groupedByY.set(yKey, []);
groupedByY.get(yKey).push(it);
allXs.push(Math.round(it.x));
}
// buckets X (mais finos)
const xgap = Number(xgapInput.value) || 8;
const bucketCenters = makeXBuckets(allXs, xgap);
bucketCenters.sort((a,b)=>a-b);
if(bucketCenters.length===0) continue;
// montar linhas ordenadas por Y (de cima para baixo: y maior -> topo)
const yKeys = Array.from(groupedByY.keys()).sort((a,b)=>b-a);
// converter cada linha para um array com número de colunas = bucketCenters.length
const pageRows = [];
// tambem vamos computar as alturas de linhas (diferenças de y)
const rowYs = [];
for(let i=0;i<yKeys.length;i++){
const yk = yKeys[i];
const rowItems = groupedByY.get(yk);
// inicializa com vazios
const row = new Array(bucketCenters.length).fill('');
for(const it of rowItems){
// encontra bucket mais próximo
let bestIdx = 0, bestDist = Infinity;
for(let bx=0; bx<bucketCenters.length; bx++){
const d = Math.abs(it.x - bucketCenters[bx]);
if(d < bestDist){ bestDist = d; bestIdx = bx; }
}
// Se já existir conteúdo, junta com quebra de linha (mantém visual)
if(row[bestIdx]) row[bestIdx] += '\\n' + it.str;
else row[bestIdx] = it.str;
}
// trim
for(let c=0;c<row.length;c++) if(row[c]) row[c] = row[c].trim();
pageRows.push(row);
rowYs.push(yk);
}
// salvar métricas da página para ajustar col widths
pageMetrics.push({centers: bucketCenters.slice(), rowYs: rowYs.slice(), viewportWidth: viewport.width});
// adicionar página ao allRows (mantendo separador de página)
for(const r of pageRows){
allRows.push(r.slice()); // copia
}
// linha em branco entre páginas
allRows.push(new Array(bucketCenters.length).fill(''));
globalMaxCols = Math.max(globalMaxCols, bucketCenters.length);
}
if(allRows.length===0){ log('Nenhuma linha extraída'); return; }
// Normalizar todas as linhas para ter globalMaxCols colunas
for(let i=0;i<allRows.length;i++){
const row = allRows[i];
if(row.length < globalMaxCols){
const more = new Array(globalMaxCols - row.length).fill('');
allRows[i] = row.concat(more);
}
}
log('Gerando planilha (mantendo layout)...');
// Criar worksheet a partir de AOA e depois ajustar col widths e row heights
const ws = XLSX.utils.aoa_to_sheet(allRows);
// Calcular larguras de colunas aproximadas com base na média das distâncias dos bucket centers
// usar métricas da primeira página que tenha dados
let cols = [];
if(pageMetrics.length){
const first = pageMetrics[0];
const centers = first.centers;
// distâncias entre centros
const dists = [];
for(let i=1;i<centers.length;i++) dists.push(Math.max(5, centers[i]-centers[i-1]));
// para n colunas, criar widths (em caracteres aproximados) proporcional a dists
const avgDist = dists.length ? (dists.reduce((a,b)=>a+b,0)/dists.length) : 20;
cols = new Array(globalMaxCols).fill({wpx: Math.max(40, avgDist)}); // fallback
// se tiver dists diferentes, mapear
for(let i=0;i<globalMaxCols;i++){
const w = (i<dists.length ? dists[i] : avgDist);
cols[i] = {wpx: Math.max(30, Math.round(w*1.1))};
}
} else {
cols = new Array(globalMaxCols).fill({wpx:80});
}
ws['!cols'] = cols;
// Estimar altura de linhas (proporcional às mudanças de Y). Aqui usamos valor fixo para simplicidade.
const estimatedRowHeight = 18;
const rowsMeta = new Array(allRows.length).fill({hpt: estimatedRowHeight});
ws['!rows'] = rowsMeta;
// Ativar wrapText para todas as células não vazias
const range = XLSX.utils.decode_range(ws['!ref']);
for(let R = range.s.r; R <= range.e.r; ++R) {
for(let C = range.s.c; C <= range.e.c; ++C) {
const cell_address = {c:C, r:R};
const cell_ref = XLSX.utils.encode_cell(cell_address);
const cell = ws[cell_ref];
if(cell && typeof cell.v === 'string' && cell.v.indexOf('\\n') !== -1){
// manter quebras de linha e wrapText
cell.s = cell.s || {};
cell.s.alignment = cell.s.alignment || {};
cell.s.alignment.wrapText = true;
}
}
}
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Extraido');
const nome = (f.name.replace(/\\.pdf$/i,'') || 'saida') + '.xlsx';
log('Baixando ' + nome);
XLSX.writeFile(wb, nome);
log('Concluído — verifique colunas e ajuste XGap/YGap se necessário (valores menores de XGap => mais colunas).');
});
</script>
</body>
</html>