"""賛否一覧PDFから、Markdownの表を生成する。 - 上部の「会派名:正式名(議員数)」をページ内から探して読む(位置は年度で変わる) - 議員の列は、議員名(縦書き)の文字のx座標をクラスタリングして作る (年度によって罫線が無いため)。「賛成」「反対」の列は合計欄として使う - 議案行は、賛否記号のある行。番号・件名・議決結果は文字列から切り出す - PDFに載っている「賛成・反対」の合計と突き合わせて検算する - 「一人会派」は構成員ごとに列を分ける(議員名を出すため)。安竹の列は強調する 使い方: python scripts/parse-sanpi-hyo.py "<定例会名>" """ import os import re import sys import pymupdf 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", "不採択"), } 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 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 row_chars(cs, x0, x1, y0, y1): """行の帯に入る文字を、読む順(行→列)に並べて返す。""" sel = [c for c in cs if x0 <= c["x"] < x1 and y0 <= c["y"] < y1] 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): doc = pymupdf.open(path) pg = doc[0] cs = chars(pg) # 会派(「短名:正式名(議員数)」)の行をページ内から探す(年度で位置が変わるため) best = None for y0 in range(40, 420, 2): seg = "".join(c["c"] for c in sorted( [c for c in cs if y0 <= c["y"] <= y0 + 12], key=lambda c: (round(c["y"] / 6), c["x"]))) p = re.findall(r"([ぁ-んァ-ヶ一-龥A-Za-z]{2})[::]([^((]+?)[((](\d+)", seg) if p and (best is None or len(p) > len(best[1])): best = (y0, [(s, f.strip(), int(n)) for s, f, n in p]) header_y, parties = best total = sum(n for _, _, n in parties) shorts = [s for s, _, _ in parties] # 議案行(賛否記号のある行)。議員数の半分以上の記号がある行だけを採用(凡例を除く) # 同じ行でもセルの高さにより 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 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]])) # 表の見出し行(会派の短名が並ぶ行)を探して、議員名の帯から除く 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)) # 議員名の文字(会派名・見出し行の下、最初の賛否行の上) 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 = nearest(c["x"]) if i is not None: mnames[i] += c["c"] # 賛成・反対の合計欄と、議員の列を分ける 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 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 = [] ymid = [sum(c["y"] for c in rows[y]) / len(rows[y]) for y in ys] for n, y in enumerate(ys): 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 = nearest(c["x"]) if i in member_cols: cells[i] = c["c"] 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 '✗要確認'}") # 会派ごとの列。 # - 「一人会派」は常に議員ごとに分ける(会派名だけでは構成員が分からないため) # - それ以外は、全議案を通した賛否が同じ議員をまとめる。 # 分かれた会派では、複数名が同じ票なら「会派(n人)」にまとめ、 # 1人だけ異なる議員は名前を出す(それで列が増えすぎるのを防ぐ) # - 議長・副議長など、全議案で表決に加わらない議員は、まとめた列に含める out_cols = [] k = 0 for s, f, n in parties: idx = member_cols[k:k + n] k += n if "一人" in s: for i in idx: out_cols.append((f"{mnames[i]}({s})", [i])) continue groups, novote = {}, [] for i in idx: pat = tuple(d["votes"].get(i) for d in data) if any(v in (YES | NO) for v in pat): groups.setdefault(pat, []).append(i) else: novote.append(i) if not groups: # 会派全員が表決に加わっていない out_cols.append((s, idx)) continue order = sorted(groups.values(), key=lambda g: g[0]) order[0] = order[0] + novote if len(order) == 1: if n == 1 and idx: out_cols.append((f"{s} {mnames[idx[0]]}", idx)) else: out_cols.append((s, idx)) continue for g in order: if len(g) == 1: out_cols.append((f"{mnames[g[0]]}({s})", g)) else: out_cols.append((f"{s}({len(g)}人)", g)) print(f"\n### {label}\n") print("| 議案 | 議決結果 | " + " | ".join(h for h, _ in out_cols) + " |") print("|---|---|" + "---|" * len(out_cols)) for d in data: cells = [] for h, idx in out_cols: got = {d["votes"].get(i) for i in idx if d["votes"].get(i) in (YES | NO)} 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'{v}' cells.append(v) ic, mark = kekka_icon(d["kekka"]) kekka = f'{mark}' if ic else d["kekka"] 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}人)") doc.close() main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "")