| """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 |
|
|
| |
| |
| NUM_RE = re.compile(r"^-?\d{1,3}(?:\.\d{3})*(?:,\d+)?%?$|^-?\d+(?:,\d+)?%?$") |
| DASH = {"-", "\u2013", "\u2014", "\u2212", "."} |
|
|
|
|
| def is_number(tok: str) -> bool: |
| t = tok.strip() |
| if t in DASH: |
| return True |
| 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 |
|
|
|
|
| |
| 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() |
| |
| 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 |
|
|
|
|
| |
| def classify_stat(header_text: str) -> str | None: |
| h = norm(header_text).replace("-", "").replace(" ", "") |
| |
| 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"}) |
| |
| 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 |
| 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 |
|
|
| |
| |
| |
| 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) |
| |
| |
| col_headers = [[] for _ in range(ncol)] |
| for w in words: |
| |
| if w["top"] < first_data_top - 1: |
| ci = colof(w) |
| if ci is not None and ci > 0: |
| col_headers[ci].append((w["top"], w["x0"], w["text"])) |
| col_stats, col_labels = [], [] |
| for ci in range(ncol): |
| hs = sorted(col_headers[ci]) |
| |
| 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 = 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). |
| """ |
| |
| 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) |
| |
| 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_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)) |
|
|