// Parse Appendix B financial/GHG tables from items.jsonl into rows (JSON). // Reads items in page then y order; table headings and column header blocks set context. import fs from 'fs'; const [,, inFile, outFile] = process.argv; const items = fs.readFileSync(inFile, 'utf8').trim().split('\n').map(JSON.parse); const pages = new Map(); for (const it of items) { if (!pages.has(it.p)) pages.set(it.p, []); pages.get(it.p).push(it); } const colNames = ['title', 'approp', 'account', 'exp', 'recip', 'desc', 'county', 'yes', 'tons', 'cost']; let agency = null, inFin = false, cols = null; const rows = []; let cur = null; for (const [p, its] of [...pages.entries()].sort((a, b) => a[0] - b[0])) { its.sort((a, b) => a.y - b.y || a.x - b.x); // Header blocks on this page: each 'Projected' item marks one; find its column xs and bottom. const headers = its.filter(i => i.s === 'Projected').map(proj => { const near = re => its.find(i => re.test(i.s) && i.y >= proj.y - 40 && i.y <= proj.y + 90); const hs = [/^Appropriation/, /^2023-25/, /^Climate( Commitment)?( Act)?$/, /^Fiscal year/, /^Pass-through/, /^Project description/, /^County/, /^Are( quantifiable)?$/, /^Projected$/, /^Cost per/].map(near); const bottom = Math.max(...its.filter(i => i.y >= proj.y - 40 && i.y <= proj.y + 95 && (i.x >= proj.x - 2 && /MTCO|^e\)$|^2$|reduced|emissions|gas|greenhouse|quantity/.test(i.s))).map(i => i.y)); return { top: Math.min(...hs.filter(Boolean).map(h => h.y)), bottom: bottom + 3, cols: hs.map(h => h ? h.x : null) }; }); const colOf = x => { let k = -1; for (let j = 0; j < cols.length; j++) if (cols[j] != null && x >= cols[j] - 4) k = j; return k; }; for (const it of its) { if (it.y > 735 || (it.y < 95 && !/^Table \d+\./.test(it.s))) continue; // page header/footer if (/^Table \d+\./.test(it.s)) { inFin = false; const m = it.s.match(/^Table \d+\.\s*(.+?)\s*-\s*fiscal/); if (m && !/financial and greenhouse/.test(it.s)) { cur = null; agency = m[1].trim(); continue; } // benefits table if (m) agency = m[1].trim(); cur = null; continue; } const h = headers.find(h => it.y >= h.top - 1 && it.y <= h.bottom); if (h) { cols = h.cols; inFin = true; continue; } if (!inFin || !cols) continue; const k = colOf(it.x); if (k < 0) continue; if (colNames[k] === 'yes' && /^(Yes|No|N\/A)$/.test(it.s.trim()) && (!cur || cur.page !== p || it.y > cur.y + 1.5 || cur.f.yes.length)) { cur = { agency, page: p, y: it.y, f: Object.fromEntries(colNames.map(n => [n, []])) }; rows.push(cur); // pull items on this page at the same row start y (already passed, left columns) for (const o of its) if (o !== it && Math.abs(o.y - it.y) < 1.5 && o.x < it.x) { const kk = colOf(o.x); if (kk >= 0 && !/^Table/.test(o.s)) cur.f[colNames[kk]].push(o.s); } cur.f.yes.push(it.s.trim()); cur.started = true; continue; } if (!cur) continue; // items on the row's start line left of the Are column were already pulled in if (cur.page === p && Math.abs(it.y - cur.y) < 1.5 && k < 7) continue; // items just above the next row's start line belong to the next row; top alignment means // left-column items appearing before the next 'Are' token at the same y are handled by the pull-in above if (k < 7) { const next = its.find(o => o.y > it.y - 1.5 && o.y < it.y + 1.5 && colOf(o.x) === 7 && /^(Yes|No|N\/A)$/.test(o.s.trim()) && o !== it); if (next && !(cur.page === p && Math.abs(next.y - cur.y) < 1.5)) continue; // will be pulled into next row } cur.f[colNames[k]].push(it.s); } } const money = s => { const m = s.match(/\$?\s?(-?[\d,]+(?:\.\d+)?)/); return m ? +m[1].replace(/,/g, '') : null; }; const out = rows.map(r => { const j = n => r.f[n].join(' ').replace(/\s+/g, ' ').trim(); const expVals = r.f.exp.map(s => (s.match(/\$\s?[\d,]+(\.\d+)?/) ? money(s) : null)).filter(v => v != null); return { agency: r.agency, pdfPage: r.page, title: j('title'), approp: j('approp'), account: j('account'), expRaw: j('exp'), exp: expVals.reduce((a, b) => a + b, 0), recip: j('recip'), desc: j('desc'), county: j('county'), quant: r.f.yes[0] || '', tonsRaw: j('tons'), tons: /\d/.test(j('tons')) ? money(j('tons')) : null, costRaw: j('cost'), cost: /\d/.test(j('cost')) ? money(j('cost')) : null, }; }); fs.writeFileSync(outFile, JSON.stringify(out, null, 1)); console.log('rows', out.length);