diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-09-23 09:57:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-09-23 09:57:34 +0900 |
| commit | 23565ef2a5cfe3fe2a599e8b36fdf191892d2c4c (patch) | |
| tree | 53d59b02e6ddc0faf913dace4984b4f91e96fcbf /scripts | |
| parent | d9b4276d838c1cdaa89f3be2fbe98b7c9719a921 (diff) | |
parse-sanpi-hyo.py: 罫線が無い年度に対応し、件名の抽出を修正
議員列は、議員名のx座標をクラスタリングして作る方式に変更した。令和8年6月のPDFには列の縦罫線がほとんど無く、罫線では列を作れなかったため。
同じ議案でも、セルの高さの違いで賛否記号のyが最大9pxずれる。議案どうしの行間は17px以上あるので、その間を取って13px以内は同じ行にまとめる。
件名は中央揃えの複数行になるため、単純に連結すると号数欄・議決結果欄が件名の途中に挟まる。議決結果は「いちばん右にある議決語」だけを採用し、その語だけを件名から取り除く。
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/parse-sanpi-hyo.py | 244 |
1 files changed, 140 insertions, 104 deletions
diff --git a/scripts/parse-sanpi-hyo.py b/scripts/parse-sanpi-hyo.py index 474440a..a9a7bd0 100644 --- a/scripts/parse-sanpi-hyo.py +++ b/scripts/parse-sanpi-hyo.py @@ -1,12 +1,15 @@ """賛否一覧PDFから、Markdownの表を生成する。 -- 上部の「会派名:正式名(議員数)」を読む(推測しない) -- 表の縦罫線で列を厳密に区切り、議員名(縦書き)と賛否記号を列へ割り当てる -- 議案ごとに区分・番号・件名・議決結果と議員の賛否をまとめ、 - PDFに載っている「賛成・反対」の合計と突き合わせて検算する +- 上部の「会派名:正式名(議員数)」をページ内から探して読む(位置は年度で変わる) +- 議員の列は、議員名(縦書き)の文字のx座標をクラスタリングして作る + (年度によって罫線が無いため)。「賛成」「反対」の列は合計欄として使う +- 議案行は、賛否記号のある行。番号・件名・議決結果は文字列から切り出す +- PDFに載っている「賛成・反対」の合計と突き合わせて検算する +- 「一人会派」は構成員ごとに列を分ける(議員名を出すため)。安竹の列は強調する 使い方: python scripts/parse-sanpi-hyo.py <pdfのパス> "<定例会名>" """ +import os import re import sys @@ -17,36 +20,31 @@ sys.stdout.reconfigure(encoding="utf-8") YES, NO, ABS = set("〇○◯"), set("×✕✖"), set("-—−-・") VOTE = YES | NO | ABS FULL2HALF = str.maketrans("0123456789ABC", "0123456789ABC") +KEKKA_WORDS = ["原案可決", "原案否決", "不採択", "可決", "認定", "採択", "否決", "同意", "承認"] +KEKKA_RE = re.compile("|".join(KEKKA_WORDS)) +NO_RE = re.compile(r"第[0-90-9]+号") +LEGEND_RE = re.compile(r"[〇○◯×✕✖-—−-・][::]\s*(?:賛成|反対)") +TOTAL_LABELS = ("賛成", "反対") +ME = "安竹洋平" +# 同じ議案のマークでも、セルの高さの違いで y が数pxずれることがある(実測で最大9px)。 +# 一方、議案どうしの行間は17px以上ある。その間を取って、この距離以内は同じ行にまとめる +ROW_GAP = 13 -# 議決結果 → 印(可決系は朱色の印、否決系は青色の印) +# 議決結果 → 印(可決系はグレーの印、否決系は青の印) KEKKA_ICON = { - "原案可決": ("pass", "可決"), - "可決": ("pass", "可決"), - "認定": ("pass", "認定"), - "採択": ("pass", "採択"), - "同意": ("pass", "同意"), - "承認": ("pass", "承認"), - "否決": ("stop", "否決"), - "原案否決": ("stop", "否決"), - "不採択": ("stop", "不採択"), + "原案可決": ("pass", "可決"), "可決": ("pass", "可決"), "認定": ("pass", "認定"), + "採択": ("pass", "採択"), "同意": ("pass", "同意"), "承認": ("pass", "承認"), + "否決": ("stop", "否決"), "原案否決": ("stop", "否決"), "不採択": ("stop", "不採択"), } def kekka_icon(text): - """議決結果の文字列から、印の種類とラベルを返す(部分一致で判定)。""" for key in sorted(KEKKA_ICON, key=len, reverse=True): if key in text: return KEKKA_ICON[key] return ("", text) -def col_of(x, bounds): - for i in range(len(bounds) - 1): - if bounds[i] <= x < bounds[i + 1]: - return i - return None - - def chars(pg): out = [] for block in pg.get_text("rawdict")["blocks"]: @@ -59,9 +57,44 @@ def chars(pg): return out -def text_of(cs, x0, x1, y0, y1): +def row_chars(cs, x0, x1, y0, y1): + """行の帯に入る文字を、読む順(行→列)に並べて返す。""" sel = [c for c in cs if x0 <= c["x"] < x1 and y0 <= c["y"] < y1] - return "".join(c["c"] for c in sorted(sel, key=lambda c: (round(c["y"] / 4), c["x"]))) + return sorted(sel, key=lambda c: (round(c["y"] / 4), c["x"])) + + +def split_row(sel): + """行の帯の文字から、号数・件名・議決結果を切り出す。 + + PDF の件名はセルの中で中央揃えの複数行になるため、単純に連結すると + 左の号数欄・右の議決結果欄が件名の途中に挟まる。そこで議決結果は + 「いちばん右にある議決語」とし、その語だけを件名から取り除く。 + """ + tch = [c["c"].translate(FULL2HALF) for c in sel] + tstr = "".join(tch) + drop = set() + + no = "" + no_m = NO_RE.search(tstr) + if no_m: + no = no_m.group(0) + drop.update(range(*no_m.span())) + + kekka = "" + best = None + for m in KEKKA_RE.finditer(tstr): + x = sel[m.start()]["x"] + if best is None or x > best[0]: + best = (x, m) + if best: + kekka = best[1].group(0) + drop.update(range(*best[1].span())) + + for m in LEGEND_RE.finditer(tstr): + drop.update(range(*m.span())) + + name = "".join(tch[i] for i in range(len(tch)) if i not in drop) + return no, kekka, name.strip() def main(path, label): @@ -69,8 +102,7 @@ def main(path, label): pg = doc[0] cs = chars(pg) - # 会派(「短名:正式名(議員数)」)の行を、ページ内から探す。 - # (年度によって、全会一致の欄が先に来るなど、位置が変わるため) + # 会派(「短名:正式名(議員数)」)の行をページ内から探す(年度で位置が変わるため) best = None for y0 in range(40, 420, 2): seg = "".join(c["c"] for c in sorted( @@ -80,125 +112,129 @@ def main(path, label): best = (y0, [(s, f.strip(), int(n)) for s, f, n in p]) header_y, parties = best total = sum(n for _, _, n in parties) - print(f"会派の行 y={header_y} / {len(parties)}会派 / {total}人") + shorts = [s for s, _, _ 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) - lo, hi = min(a.y, b.y), max(a.y, b.y) - info = xs.setdefault(k, [1e9, -1e9]) - info[0] = min(info[0], lo) - info[1] = max(info[1], hi) - # 列の境界は表の高さに近い縦線(文字の輪郭線などを除くため、上下の幅で判定する) - grid = sorted(x for x, (lo, hi) in xs.items() if hi > 200 and (hi - lo) > 120 and 30 < x < 780) - - # 議案行(賛否記号のある行)。凡例等を除くため、議員数の半分以上の記号がある行だけを採用する - # (議員名の範囲を決めるため、先に計算する) - marks = [c for c in cs if c["c"] in VOTE and c["y"] > 145 and c["x"] > 300] + # 議案行(賛否記号のある行)。議員数の半分以上の記号がある行だけを採用(凡例を除く) + # 同じ行でもセルの高さにより y が少しずれるため、ROW_GAP以内は同じ行にまとめる + marks = [c for c in cs if c["c"] in VOTE and c["y"] > header_y + 20 and c["x"] > 300] rows0 = {} - for c in marks: - rows0.setdefault(round(c["y"] / 10) * 10, []).append(c) + for c in sorted(marks, key=lambda c: c["y"]): + if rows0 and c["y"] - max(rows0) <= ROW_GAP: + rows0[max(rows0)].append(c) + else: + rows0[round(c["y"])] = [c] ys = sorted(y for y, v in rows0.items() if len(v) >= max(4, total // 2)) rows = {y: v for y, v in rows0.items() if y in ys} + first_row_y = int(min(c["y"] for c in rows[ys[0]])) - # 議員名の帯(見出しの下。縦書きの名が収まる範囲) - namechars = [c for c in cs if 88 <= c["y"] <= 140 and c["x"] > 308 and c["c"] not in VOTE] - nx0 = min(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] + # 表の見出し行(会派の短名が並ぶ行)を探して、議員名の帯から除く + hdr_ys = set() + for y0 in range(header_y + 6, min(header_y + 120, first_row_y), 2): + seg = "".join(c["c"] for c in sorted( + [c for c in cs if y0 <= c["y"] <= y0 + 10 and c["x"] > 300], key=lambda c: c["x"])) + if sum(1 for s in shorts if s in seg) >= 2: + hdr_ys.update(range(y0 - 5, y0 + 13)) - cols = {} + # 議員名の文字(会派名・見出し行の下、最初の賛否行の上) + namechars = [c for c in cs if header_y + 10 <= c["y"] <= first_row_y - 3 + and c["x"] > 308 and c["c"] not in VOTE and round(c["y"]) not in hdr_ys] + + # 名前のx座標をクラスタリングして列を作る + centers = [] + for x in sorted(c["x"] for c in namechars): + if centers and x - centers[-1][-1] <= 7: + centers[-1].append(x) + else: + centers.append([x]) + cc = [sum(g) / len(g) for g in centers] + + def nearest(x, tol=10): + bi, bd = None, 1e9 + for i, cx in enumerate(cc): + d = abs(x - cx) + if d < bd: + bi, bd = i, d + return bi if bd <= tol else None + + mnames = ["" for _ in cc] for c in namechars: - i = col_of(c["x"], bounds) + i = nearest(c["x"]) 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)] - member_only = members[:total] - mcols = [m[0] for m in member_only] # 議員の列index + mnames[i] += c["c"] - # 左側(議案)の列境界 - x_kubun, x_no, x_name, x_kekka = (40, 78), (82, 118), (120, 268), (270, 312) + # 賛成・反対の合計欄と、議員の列を分ける + total_cols = [i for i, n in enumerate(mnames) if n in TOTAL_LABELS] + member_cols = [i for i, n in enumerate(mnames) if n not in TOTAL_LABELS] + print(f"会派の行 y={header_y} / {len(parties)}会派 / {total}人 / " + f"議員列 {len(member_cols)} / 合計欄 {len(total_cols)}") - # 合計欄(議員列より右)の数字 + # 合計欄の数字(マークと同じROW_GAP単位でまとめる) nums = {} - for c in cs: - if c["c"].isdigit() or c["c"] in "0123456789": - i = col_of(c["x"], bounds) - if i is not None and i >= total: - nums.setdefault(round(c["y"] / 10) * 10, []).append((c["y"], c["x"], c["c"])) + for c in sorted([c for c in cs if (c["c"].isdigit() or c["c"] in "0123456789") + and nearest(c["x"]) in total_cols], key=lambda c: c["y"]): + if nums and c["y"] - max(nums) <= ROW_GAP: + nums[max(nums)].append((c["y"], c["x"], c["c"])) + else: + nums[round(c["y"])] = [(c["y"], c["x"], c["c"])] - # 議案行の組み立て(前後の中点を境界にして、行のテキストを拾う) + left_end = member_cols and cc[member_cols[0]] - 7 or 300 data = [] - kubun = "" + ymid = [sum(c["y"] for c in rows[y]) / len(rows[y]) for y in ys] for n, y in enumerate(ys): - lo = (ys[n - 1] + y) / 2 if n > 0 else y - 14 - hi = (y + ys[n + 1]) / 2 if n + 1 < len(ys) else y + 14 - k = text_of(cs, *x_kubun, lo, hi).translate(FULL2HALF) - if k: - kubun = k - no = text_of(cs, *x_no, lo, hi).translate(FULL2HALF) - name = text_of(cs, *x_name, lo, hi).translate(FULL2HALF) - kekka = text_of(cs, *x_kekka, lo, hi).translate(FULL2HALF) + lo = (ymid[n - 1] + ymid[n]) / 2 if n > 0 else ymid[0] - 16 + hi = (ymid[n] + ymid[n + 1]) / 2 if n + 1 < len(ys) else ymid[n] + 16 + sel = row_chars(cs, 74, left_end, lo, hi) + no, kekka, name = split_row(sel) + if os.environ.get("GEN_DEBUG"): + print(f" [row n={n} lo={lo:.0f} hi={hi:.0f}] no={no!r} kekka={kekka!r} name={name!r}") cells = {} for c in rows[y]: - i = col_of(c["x"], bounds) - if i in mcols: + i = nearest(c["x"]) + if i in member_cols: cells[i] = c["c"] - data.append({"no": no, "name": name, "kekka": kekka, "votes": cells, "kubun": kubun}) - # 検算 - yes = sum(1 for i in mcols if cells.get(i) in YES) - no_ = sum(1 for i in mcols if cells.get(i) in NO) + data.append({"no": no, "name": name, "kekka": kekka, "votes": cells}) + yes = sum(1 for i in member_cols if cells.get(i) in YES) + no_ = sum(1 for i in member_cols if cells.get(i) in NO) pdfs = "".join(c for _, _, c in sorted(nums.get(y, []))).translate(FULL2HALF) ok = str(yes) in pdfs and str(no_) in pdfs print(f" [検算] {no} {kekka}: 賛成{yes} 反対{no_} / PDF={pdfs} {'✓' if ok else '✗要確認'}") - # 会派ごとの列(同じ議案行の中で会派内の賛否が分かれたときだけ議員ごとに分ける) - out_cols = [] # (見出し, [列index]) + # 会派ごとの列(会派内で分かれたとき、および「一人会派」は議員ごとに分ける) + out_cols = [] k = 0 for s, f, n in parties: - idx = mcols[k:k + n] + idx = member_cols[k:k + n] k += n - split = any( + split = ("一人" in s) or any( len({d["votes"].get(i) for i in idx if d["votes"].get(i) in (YES | NO)}) > 1 for d in data ) if not split: - if n == 1: # 1人会派は議員名も添える - nm = next(m[1] for m in member_only if m[0] == idx[0]) - out_cols.append((f"{s} {nm}", idx)) + if n == 1 and idx: + out_cols.append((f"{s} {mnames[idx[0]]}", idx)) else: out_cols.append((s, idx)) else: for i in idx: - nm = next(m[1] for m in member_only if m[0] == i) - out_cols.append((f"{nm}({s})", [i])) + out_cols.append((f"{mnames[i]}({s})", [i])) print(f"\n### {label}\n") print("| 議案 | 議決結果 | " + " | ".join(h for h, _ in out_cols) + " |") print("|---|---|" + "---|" * len(out_cols)) for d in data: cells = [] - for _, idx in out_cols: + for h, idx in out_cols: got = {d["votes"].get(i) for i in idx if d["votes"].get(i) in (YES | NO)} - if len(got) == 1: - v = got.pop() - cells.append("〇" if v in YES else "×") - elif not got: - cells.append("—") - else: - cells.append("/".join("〇" if d["votes"].get(i) in YES else - "×" if d["votes"].get(i) in NO else "—" for i in idx)) + v = "—" if not got else ("〇" if len(got) == 1 and next(iter(got)) in YES else + "×" if len(got) == 1 and next(iter(got)) in NO else + "/".join("〇" if d["votes"].get(i) in YES else + "×" if d["votes"].get(i) in NO else "—" for i in idx)) + if ME in h: + v = f'<span class="sanpi-me">{v}</span>' + cells.append(v) ic, mark = kekka_icon(d["kekka"]) kekka = f'<span class="sanpi-kekka {ic}" title="{d["kekka"]}">{mark}</span>' if ic else d["kekka"] - # 請願・議員提出議案は区分を頭に付ける - head = "" - title = (head + d["no"] + " " + d["name"]).strip() + title = (d["no"] + " " + d["name"]).strip() print(f"| {title} | {kekka} | " + " | ".join(cells) + " |") print(f"\n(会派: " + " / ".join(f"{s}({n})" for s, _, n in parties) + f" = {total}人)") |
