diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-09-23 09:11:52 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-09-23 09:11:52 +0900 |
| commit | 2e9dae139d7bcb00d94aa31a71e9206d0f454d8d (patch) | |
| tree | 4009c3c38b3153815766321bd4abf2932358d2a3 /scripts | |
| parent | fcd023276d57c378487f1e9f538e229953fe0d27 (diff) | |
parse-sanpi-hyo.py: 罫線で列を区切り、合計欄で検算する方式に変更
従来は表のセル抽出と会派の区切りの推測に頼っていて、会派や議員ごとの列がずれることがあった。上部の「会派名(議員数)」を読み、縦罫線で列を厳密に区切って議員名と賛否記号を割り当て、PDFに載っている議案ごとの賛成・反対の合計と突き合わせて検算する方式にした。令和7年12月定例会で、会派・議員27人が市の会派名簿と一致し、4議案の賛成・反対数もPDFの合計とすべて一致することを確認。(表の生成側 gen-sanpi-table.py は、この出力に合わせて後日更新が必要)
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/parse-sanpi-hyo.py | 196 |
1 files changed, 106 insertions, 90 deletions
diff --git a/scripts/parse-sanpi-hyo.py b/scripts/parse-sanpi-hyo.py index 2030549..457cac0 100644 --- a/scripts/parse-sanpi-hyo.py +++ b/scripts/parse-sanpi-hyo.py @@ -1,111 +1,127 @@ -import sys -import json -import re -import pdfplumber - -sys.stdout.reconfigure(encoding="utf-8") +"""賛否一覧PDF(新レイアウト)を読み、Markdown表を出力する。合計欄で検算する。 -VOTE_CHARS = set("〇○×✕✖◯") # 賛成・反対の記号候補 +使い方: python .extract-final.py <pdf> "<定例会名>" +""" +import re +import sys +import pymupdf -def norm(c): - """セル文字列を正規化(空白・改行を除去)""" - if c is None: - return "" - return "".join(str(c).split()) +sys.stdout.reconfigure(encoding="utf-8") +YES, NO, ABS = set("〇○◯"), set("×✕✖"), set("-—−-・") +VOTE = YES | NO | ABS +DIGITS = set("01234567890123456789") -HEADER_WORDS = {"区分", "番号", "件名", "議決結果", "賛成", "反対", "会派", "議員", "賛否"} +def col_of(x, bounds): + for i in range(len(bounds) - 1): + if bounds[i] <= x < bounds[i + 1]: + return i + return None -def is_vote(c): - s = norm(c) - return len(s) == 1 and s in VOTE_CHARS +def chars(pg): + out = [] + for block in pg.get_text("rawdict")["blocks"]: + for line in block.get("lines", []): + for span in line["spans"]: + for ch in span["chars"]: + if ch["c"].strip(): + x0, y0, x1, y1 = ch["bbox"] + out.append({"c": ch["c"], "x": (x0 + x1) / 2, "y": (y0 + y1) / 2}) + return out -def is_name(c): - s = norm(c) - if not (2 <= len(s) <= 8): - return False - if is_vote(c) or s in HEADER_WORDS: - return False - return not re.search(r"[0-90-9]", s) +def main(path, label): + doc = pymupdf.open(path) + pg = doc[0] + cs = chars(pg) -def is_label(c): - s = norm(c) - if not (1 <= len(s) <= 5): - return False - if is_vote(c) or s in HEADER_WORDS: - return False - return not re.search(r"[0-90-9]", s) + # 会派(上部の2行、y 45〜70。文字は y,x の順に並べる) + topcs = sorted([c for c in cs if 45 <= c["y"] <= 70], key=lambda c: (round(c["y"] / 6), c["x"])) + top = "".join(c["c"] for c in topcs) + parties = [(s, f.strip(), int(n)) for s, f, n in + re.findall(r"([ぁ-んァ-ヶ一-龥A-Za-z]{2})[::]([^((]+?)[((](\d+)", top)] + total = sum(n for _, _, n in parties) + # 縦罫線グリッド + xs = {} + for d in pg.get_drawings(): + for it in d["items"]: + if it[0] == "l": + a, b = it[1], it[2] + if abs(a.x - b.x) < 0.8: + k = round(a.x, 1) + info = xs.setdefault(k, {"maxy": 0}) + info["maxy"] = max(info["maxy"], a.y, b.y) + grid = sorted(x for x, i in xs.items() if i["maxy"] > 200 and 250 < x < 770) -def main(path): - members = [] # [(col, 会派, 議員名)] - rows = [] # [{区分, 番号, 件名, 議決結果, votes:{col: '〇'/'×'}}] - carries = {} # 区分の繰越 + # 議員名の帯(y 88〜140、左の区分・番号・件名・議決結果を除く) + namechars = [c for c in cs if 88 <= c["y"] <= 140 and c["x"] > 308] + nx0, nx1 = min(c["x"] for c in namechars), max(c["x"] for c in namechars) + left = [x for x in grid if x <= nx0] + bounds = ([left[-1]] if left else [nx0 - 8]) + [x for x in grid if nx0 < x <= nx1 + 15] - with pdfplumber.open(path) as pdf: - for page in pdf.pages: - for table in page.extract_tables(): - if not table: - continue - ncols = max(len(r) for r in table) - grid = [list(r) + [None] * (ncols - len(r)) for r in table] + cols = {} + for c in namechars: + i = col_of(c["x"], bounds) + if i is not None: + cols.setdefault(i, []).append(c) + members = [(i, "".join(x["c"] for x in sorted(cols[i], key=lambda c: c["y"]))) for i in sorted(cols)] - # 議員名の行=2〜8文字の名前らしいセルが最も多く並ぶ行 - best = None - for i, row in enumerate(grid[:6]): - n = sum(1 for c in row if is_name(c)) - if n >= 5 and (best is None or n > best[1]): - best = (i, n) - if best is None: - continue - member_row_idx = best[0] + # 会派ごとに区切る + groups, k = [], 0 + for s, f, n in parties: + groups.append((s, members[k:k + n])) + k += n + ngroups = len(members) - sum(n for _, _, n in parties) # 末尾の合計欄(賛成/反対) + member_only = members[:sum(n for _, _, n in parties)] - # 会派名は、その上の行(無ければ同じ行の短いラベル)から拾う - label_row = grid[member_row_idx - 1] if member_row_idx > 0 else grid[member_row_idx] - local = {} - group = "" - for c in range(ncols): - lab = norm(label_row[c]) - if lab and is_label(label_row[c]): - group = lab - if is_name(grid[member_row_idx][c]): - local[c] = (group, norm(grid[member_row_idx][c])) - if local and not members: - members = [(c, g, n) for c, (g, n) in sorted(local.items())] + # 賛否 + marks = [c for c in cs if c["c"] in VOTE and c["y"] > 145] + rows = {} + for c in marks: + rows.setdefault(round(c["y"] / 10) * 10, []).append(c) - # データ行(〇×がある行) - for row in grid[member_row_idx + 1:]: - votes = {c: norm(row[c]) for c in local if is_vote(row[c])} - if len(votes) < 2: - continue - vals = [norm(x) for x in row] - disp = vals[0] if vals[0] else carries.get("区分", "") - if vals[0]: - carries["区分"] = vals[0] - rows.append({ - "区分": disp, - "番号": next((v for v in vals[1:4] if v and "第" in v), ""), - "件名": next((v for v in vals[1:6] if len(v) >= 8), ""), - "議決結果": next((v for v in vals if v in ("認 定", "認定", "原案可決", "可決", "不採択", "採択", "否決", "原案否決")), ""), - "votes": votes, - "raw": vals[:4], - }) + # 合計欄(末尾の列)の数字 + nums = {} + for c in cs: + if c["c"] in DIGITS: + i = col_of(c["x"], bounds) + if i is not None and i >= len(member_only): + nums.setdefault(round(c["y"] / 10) * 10, []).append((c["y"], c["x"], c["c"])) - print("議員(会派 / 名前):") - for c, g, n in members: - print(f" col{c:>3}: {g:<6} {n}") - print("\n議案行数:", len(rows)) - for r in rows[:6]: - vs = "".join(r["votes"].get(c, "・") for c, _, _ in members) - print(f" {r['区分'][:6]:<6} {r['番号']:<8} {r['議決結果']:<8} {r['件名'][:28]:<28} {vs}") + # 議案名(左側) + left_txt = {} + for c in cs: + if c["x"] < 300 and c["y"] > 120: + left_txt.setdefault(round(c["y"] / 10) * 10, []).append((c["x"], c["c"])) - with open(".sanpi-parsed.json", "w", encoding="utf-8") as f: - json.dump({"members": members, "rows": rows}, f, ensure_ascii=False, indent=1) - print("\n.sanpi-parsed.json に保存しました") + print(f"### {label}") + print(f"会派: " + " / ".join(f"{s}({n})" for s, _, n in parties) + f" = {total}人") + print(f"議員抽出数: {len(member_only)}(一致: {len(member_only) == total})") + for s, ms in groups: + print(f" {s}: {', '.join(m[1] for m in ms)}") + print("\n議案行の検算:") + ok = True + for y in sorted(rows): + cells = {} + for c in rows[y]: + i = col_of(c["x"], bounds) + if i is not None: + cells.setdefault(i, []).append(c["c"]) + yes = sum(1 for i, _ in member_only if any(v in YES for v in cells.get(i, []))) + no = sum(1 for i, _ in member_only if any(v in NO for v in cells.get(i, []))) + if yes + no == 0: + continue # 賛否の記号がない行(全会一致など) + pdfs = "".join(c for _, _, c in sorted(nums.get(y, []))) + norm = pdfs.translate(str.maketrans("0123456789", "0123456789")) + match = (str(yes) in norm and str(no) in norm) + ok = ok and match + print(f" y={y}: 賛成{yes} 反対{no} / PDF={pdfs} {'✓' if match else '✗ 要確認'}") + print(" ⇒ 検算:", "全行一致" if ok else "不一致あり") + doc.close() -main(".tmp-hyo.pdf") +main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "") |
