File size: 15,554 Bytes
5af2f86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
"""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))