pay-equity-for-eu / scripts /extract_engine.py
Franskaman110's picture
RAG pipeline
5af2f86
Raw
History Blame Contribute Delete
15.6 kB
"""Deterministic salary-table extraction engine for Danish lønstatistik PDFs.
Both IDA (lattice) and Djøf (borderless) are digital-born PDFs with reliable
text layers, so we extract exact digits by word-position clustering rather than
vision OCR (which risks digit errors in a salary knowledge base).
Pipeline per page:
1. cluster words into rows by y (top)
2. detect numeric data columns by clustering x-centers of numeric tokens
3. map header words above each column to a canonical statistic key
4. emit one record per (row-label x column) numeric cell? No -> one record per
data row, with a dict of {stat_key: value}
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
import pdfplumber
# ---- numeric parsing -------------------------------------------------------
# Danish format: thousands '.', decimal ',' e.g. 81.629 5,0% 45.171
NUM_RE = re.compile(r"^-?\d{1,3}(?:\.\d{3})*(?:,\d+)?%?$|^-?\d+(?:,\d+)?%?$")
DASH = {"-", "\u2013", "\u2014", "\u2212", "."} # placeholders for missing
def is_number(tok: str) -> bool:
t = tok.strip()
if t in DASH:
return True # missing-value marker
return bool(NUM_RE.match(t))
def parse_number(tok: str):
"""Return (value, is_pct, is_missing)."""
t = tok.strip()
if t in DASH or t == "":
return None, False, True
pct = t.endswith("%")
t = t.rstrip("%")
t = t.replace(".", "").replace(",", ".")
try:
v = float(t)
except ValueError:
return None, pct, True
if v.is_integer():
v = int(v)
return v, pct, False
# ---- row clustering --------------------------------------------------------
def cluster_rows(words, ytol=3.0):
"""Group words into rows by their vertical center."""
ws = sorted(words, key=lambda w: (round(w["top"]), w["x0"]))
rows = []
cur = []
cur_y = None
for w in ws:
yc = (w["top"] + w["bottom"]) / 2
if cur_y is None or abs(yc - cur_y) <= ytol:
cur.append(w)
ys = [(x["top"] + x["bottom"]) / 2 for x in cur]
cur_y = sum(ys) / len(ys)
else:
rows.append(cur)
cur = [w]
cur_y = yc
if cur:
rows.append(cur)
for r in rows:
r.sort(key=lambda w: w["x0"])
return rows
def norm(s: str) -> str:
s = s.lower().strip()
# fold Danish letters explicitly (NFKD does not decompose ø)
s = (s.replace("ø", "o").replace("æ", "ae").replace("å", "aa")
.replace("\xad", ""))
s = unicodedata.normalize("NFKD", s)
s = "".join(c for c in s if not unicodedata.combining(c))
return s
# ---- header -> canonical stat key ------------------------------------------
def classify_stat(header_text: str) -> str | None:
h = norm(header_text).replace("-", "").replace(" ", "")
# order matters
if "antal" in h:
return "count"
if "procent" in h and "bonus" in h:
return "bonus_pct"
if ("far" in h and "bonus" in h):
return "bonus_pct"
if "arligbonus" in h or "bonus" in h:
return "bonus_avg"
if "basislon" in h or "grundlon" in h:
return "base"
if "tillaeg" in h or "tillg" in h:
return "supplement"
if "pension" in h:
return "pension"
if "90" in h:
return "p90"
if "75" in h or "ovrekvartil" in h or h == "ovre":
return "p75"
if "median" in h or "50" in h:
return "median"
if "25" in h or "nedrekvartil" in h or h == "nedre":
return "p25"
if "gennemsnit" in h or "bruttolon" in h or "brutolon" in h or "nettolon" in h:
return "mean"
return None
def xcenter(w):
return (w["x0"] + w["x1"]) / 2
def row_numeric_tokens(row):
return [w for w in row if is_number(w["text"])]
def cluster_columns(centers, gap=14.0):
"""1-D clustering of x-centers into columns. Returns list of (lo,hi,mid)."""
if not centers:
return []
centers = sorted(centers)
cols = [[centers[0]]]
for c in centers[1:]:
if c - cols[-1][-1] <= gap:
cols[-1].append(c)
else:
cols.append([c])
return [(min(c), max(c), sum(c) / len(c)) for c in cols]
def _overlap(a0, a1, b0, b1):
return max(0.0, min(a1, b1) - max(a0, b0))
def get_rulings(pg, min_count=4):
"""Return sorted x-positions of vertical ruling lines, or [] if borderless."""
vx = sorted({round(e["x0"]) for e in pg.edges if e["orientation"] == "v"})
# merge near-duplicates
merged = []
for x in vx:
if not merged or x - merged[-1] > 3:
merged.append(x)
return merged if len(merged) >= min_count else []
def extract_lattice_table(pg, rulings, ytol=3.0):
"""Extract a ruled table (IDA): columns = gaps between vertical rulings.
Column 0 (first ruling..second) is the row label; rulings[0] is the left
edge of the table content, so caption text at x<rulings[0] is excluded.
"""
left = rulings[0]
right = rulings[-1]
bounds = rulings # column i spans bounds[i]..bounds[i+1]
ncol = len(bounds) - 1
if ncol < 3:
return None
words = [w for w in pg.extract_words(keep_blank_chars=False)
if w["x0"] >= left - 2 and w["x1"] <= right + 2]
rows = cluster_rows(words, ytol=ytol)
def colof(w):
cx = xcenter(w)
for i in range(ncol):
if bounds[i] - 1 <= cx <= bounds[i + 1] + 1:
return i
return None
# classify data rows (>=3 numeric) vs header rows.
# A row is a HEADER (not data) if it contains stat keywords, even if it has
# numeric tokens like "25%"/"50%" (these are column titles, not values).
HDR_KW = ("fraktil", "kvartil", "antal", "median", "gennemsnit",
"bruttoløn", "basisløn", "grundløn", "tillæg", "pension",
"bonus", "procent", "stilling", "årgang", "alder", "branche",
"region", "løntrin", "stillingsniveau")
def is_header_row(r):
txt = norm(" ".join(w["text"] for w in r))
return any(k in txt for k in HDR_KW)
data_rows, header_rows = [], []
for r in rows:
nums = [w for w in r if is_number(w["text"])]
if len(nums) >= 3 and not is_header_row(r):
data_rows.append(r)
else:
header_rows.append(r)
if len(data_rows) < 2:
return None
first_data_top = min(min(w["top"] for w in r) for r in data_rows)
# build header text per column from ALL words above first data row
# (captures multi-line category headers in matrix tables).
col_headers = [[] for _ in range(ncol)]
for w in words:
# include numeric tokens too: "25%"/"50%" are part of headers
if w["top"] < first_data_top - 1:
ci = colof(w)
if ci is not None and ci > 0: # col 0 is label
col_headers[ci].append((w["top"], w["x0"], w["text"]))
col_stats, col_labels = [], []
for ci in range(ncol):
hs = sorted(col_headers[ci])
# strip page-header noise (all-caps section words, page numbers)
toks = [t for _, _, t in hs if t not in ("PRIVATANSATTE", "OFFENTLIGT",
"ANSATTE", "SELVSTÆNDIG", "GENERELT")]
htext = " ".join(toks)
col_stats.append(classify_stat(htext))
col_labels.append(re.sub(r"\s+", " ", htext).strip())
out_rows = []
for r in data_rows:
cells = {i: [] for i in range(ncol)}
for w in r:
ci = colof(w)
if ci is not None:
cells[ci].append(w)
# label = col 0 text
label = re.sub(r"\s+", " ", " ".join(
w["text"] for w in sorted(cells[0], key=lambda w: w["x0"]))).strip()
cellvals, raw = {}, {}
for ci in range(1, ncol):
toks = sorted(cells[ci], key=lambda w: w["x0"])
if not toks:
continue
txt = "".join(t["text"] for t in toks)
v, pct, missing = parse_number(txt)
if missing:
continue
cellvals[ci] = v
raw[ci] = txt
if label and cellvals:
out_rows.append({"label": label, "cells": cellvals, "raw": raw})
return {
"columns": [{"bounds": [bounds[i], bounds[i + 1]], "stat": col_stats[i],
"header": col_labels[i]} for i in range(ncol)],
"rows": out_rows,
"mode": "lattice",
}
STAT_HEADER_TOKENS = {
"antal": "count", "gennemsnit": "mean", "median": "median",
"nedre": "p25", "ovre": "p75", "90%-fraktil": "p90", "90%fraktil": "p90",
"90%": "p90", "bruttoløn": "mean",
}
def _token_stat(text):
t = norm(text).replace(" ", "")
if "antal" in t:
return "count"
if "gennemsnit" in t or "bruttolon" in t:
return "mean"
if t == "nedre":
return "p25"
if "median" in t:
return "median"
if t == "ovre":
return "p75"
if "90" in t and "fraktil" in t:
return "p90"
return None
def find_header_anchors(rows):
"""Find the statistics header band in a borderless Djøf table.
Scans header rows for stat tokens (Antal, Gennemsnit, Nedre, Median, Øvre,
90%-fraktil) which may be spread across 2-3 stacked rows, and merges them
into one ordered list of anchors. Handles dual-panel pages (two side-by-side
blocks) by simply returning all anchors ordered by x.
Returns (last_header_row_index, anchors) where anchors is a list of
(stat_key, x_center, label).
"""
# locate the band: rows containing >=2 stat tokens
hit_rows = []
for ri, r in enumerate(rows):
n = sum(1 for w in r if _token_stat(w["text"]))
if n >= 2:
hit_rows.append(ri)
if not hit_rows:
return None, None
lo, hi = min(hit_rows), max(hit_rows)
# the band spans lo..hi (typically 1-3 rows); collect all stat tokens
anchors = []
for ri in range(lo, hi + 1):
for w in rows[ri]:
s = _token_stat(w["text"])
if s:
anchors.append((s, xcenter(w), w["text"]))
anchors.sort(key=lambda a: a[1])
return hi, anchors
def dedupe_glyphs(s):
"""Fix pdfplumber doubled-glyph artifact: 'BBrruuttttoolløønn' -> 'Bruttoløn'.
Only collapses when a token is entirely pairs of identical chars.
"""
out = []
for tok in s.split():
if len(tok) >= 4 and len(tok) % 2 == 0 and all(
tok[i] == tok[i + 1] for i in range(0, len(tok), 2)):
out.append(tok[::2])
else:
out.append(tok)
return " ".join(out)
def split_panels(anchors):
"""Split anchors into panels at large x-gaps where the stat sequence restarts.
A new panel starts when we see 'count' or 'mean' again after already having
seen a full-ish block, or when there's a big x-gap. Returns list of
(start_idx, end_idx) anchor index ranges.
"""
if not anchors:
return []
panels = []
start = 0
seen = set()
for i, (stat, x, _) in enumerate(anchors):
if i > start and stat in ("count", "mean") and stat in seen:
panels.append((start, i))
start = i
seen = set()
seen.add(stat)
panels.append((start, len(anchors)))
return panels
_TITLE_SKIP = {"nedre", "ovre", "kvartil", "antal", "gennemsnit", "median",
"bruttolon", "for", "fraktil"}
def find_panel_titles(rows, hri, first_anchor_x):
"""Find panel/segment titles (e.g. 'Direktører', 'kandidater') above headers.
Picks fragments from the 1-2 rows directly above the stat band that sit over
the numeric region (x >= first_anchor_x - 40) and are not stat/structural
words. Returns list of (x_center, title_fragment).
"""
titles = []
for ri in range(max(0, hri - 3), hri):
for w in rows[ri]:
if xcenter(w) < first_anchor_x - 40:
continue
t = dedupe_glyphs(w["text"])
tn = norm(t).replace(".", "").replace("-", "")
if tn in _TITLE_SKIP or is_number(t):
continue
titles.append((xcenter(w), t, ri))
return titles
def extract_page_table(pg, ytol=3.0):
"""Extract a borderless statistics table (Djøf) by anchoring on the header band.
Handles dual-panel pages by splitting anchors into panels at stat restarts.
Each numeric data cell is assigned to the nearest header anchor by x-center.
Missing values (dashes) are simply absent, so this is robust to sparse rows.
"""
words = pg.extract_words(keep_blank_chars=False)
if not words:
return None
rows = cluster_rows(words, ytol=ytol)
hri, anchors = find_header_anchors(rows)
if not anchors or len(anchors) < 3:
return None
anchor_x = [a[1] for a in anchors]
anchor_stat = [a[0] for a in anchors]
ncol = len(anchors)
panels = split_panels(anchors)
title_frags = find_panel_titles(rows, hri, anchor_x[0])
def assign_col(w):
return min(range(ncol), key=lambda i: abs(anchor_x[i] - xcenter(w)))
# panel x-boundaries
panel_bounds = []
for (s, e) in panels:
lo = anchor_x[s] - 30
hi = anchor_x[e - 1] + 30
panel_bounds.append((lo, hi))
def panel_title(lo, hi):
frags = [t for x, t, ri in title_frags if lo - 20 <= x <= hi + 20]
return re.sub(r"\s+", " ", " ".join(frags)).strip()
out_rows = []
for ri, r in enumerate(rows):
if ri <= hri:
continue
nums = [w for w in r if is_number(w["text"])]
if len(nums) < 2:
continue
first_num_x = min(xcenter(w) for w in nums)
label_words = [w for w in r if xcenter(w) < first_num_x and not is_number(w["text"])]
label = re.sub(r"\s+", " ", " ".join(
w["text"] for w in sorted(label_words, key=lambda w: w["x0"]))).strip()
if not label:
continue
cellvals, raw = {}, {}
for w in nums:
v, pct, missing = parse_number(w["text"])
if missing:
continue
ci = assign_col(w)
cellvals[ci] = v
raw[ci] = w["text"]
if label and cellvals:
out_rows.append({"label": label, "cells": cellvals, "raw": raw})
if len(out_rows) < 2:
return None
return {
"columns": [
{"xcenter": round(anchor_x[i], 1), "stat": anchor_stat[i],
"header": dedupe_glyphs(anchors[i][2])}
for i in range(ncol)
],
"panels": [
{"col_range": [s, e], "x_range": [round(panel_bounds[pi][0], 1),
round(panel_bounds[pi][1], 1)], "title": panel_title(*panel_bounds[pi])}
for pi, (s, e) in enumerate(panels)
],
"rows": out_rows,
"mode": "borderless",
}
if __name__ == "__main__":
import sys, json
fn = sys.argv[1]
pi = int(sys.argv[2]) - 1
mode = sys.argv[3] if len(sys.argv) > 3 else "table"
with pdfplumber.open(fn) as pdf:
pg = pdf.pages[pi]
if mode == "raw":
for r in cluster_rows(pg.extract_words(keep_blank_chars=False)):
print(" ".join(f"{round(w['x0'])}:{w['text']}" for w in r))
else:
rul = get_rulings(pg)
if rul:
res = extract_lattice_table(pg, rul)
else:
res = extract_page_table(pg)
print(json.dumps(res, ensure_ascii=False, indent=2))