"""賛否一覧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 _text_lines(sel, tol=3.0): """文字を y 座標で「行」にまとめ、行内は x 順に並べて返す。""" lines = [] for c in sorted(sel, key=lambda c: c["y"]): if lines and c["y"] - lines[-1][-1]["y"] <= tol: lines[-1].append(c) else: lines.append([c]) return [sorted(ln, key=lambda c: c["x"]) for ln in lines] def _clusters_by_x(cs, gap=12.0): """文字を x 座標でまとめ、列(横位置の塊)に分ける。""" out = [] for c in sorted(cs, key=lambda c: c["x"]): if out and c["x"] - out[-1][-1]["x"] <= gap: out[-1].append(c) else: out.append([c]) return out def split_row(sel): """行の帯の文字から、号数・件名・議決結果を切り出す。 PDF は列ごとに独立したテキストになっているため、単純に連結すると号数や 議決結果が件名の途中に挟まる(号数は左右に分かれた件名の行の「間」に 来ることがある)。そこで、 - 号数(第N号)は行ごとに探し、いちばん左にあるものを採る - 議決結果は、いちばん右にある議決語 - 件名は、号数より右の文字を x でまとめ、最初(左)の塊を採る とする。号数・議決結果の列や、左端の「議員提出議案」等の欄や票の列は 自然に外れる。 """ tch = [c["c"].translate(FULL2HALF) for c in sel] tstr = "".join(tch) no, no_hi, best_x = "", None, None for ln in _text_lines(sel): s = "".join(c["c"].translate(FULL2HALF) for c in ln) for m in re.finditer(r"第[0-9]+号", s): x = ln[m.start()]["x"] if best_x is None or x < best_x: best_x, no, no_hi = x, m.group(0), ln[m.end() - 1]["x"] 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) if no_hi is None: # 号数が見つからない行は、従来どおり全体を連結して組み立てる drop = set() if best: 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() # 件名: 号数より右の文字を x でまとめ、最初(左)の塊を読む順に連結する。 # 議決結果や票の列は右側の別の塊になるので、件名には混ざらない。 # (件名自体に「同意」などの議決語が入っていても、それは消さない) right = [c for c in sel if c["x"] > no_hi + 2] clusters = _clusters_by_x(right) title = clusters[0] if clusters else [] title.sort(key=lambda c: (round(c["y"]), c["x"])) name = "".join(c["c"].translate(FULL2HALF) for c in title) 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, 0, 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)) # 安竹洋平・伊藤央・石津はるかの列は、議決結果の右にまとめて並べる def _lead(h): for i, key in enumerate(("安竹", "伊藤央", "石津")): if key in h: return i return 9 out_cols.sort(key=lambda col: _lead(col[0])) # 会派名だけを示す議員は、氏名を出さない drop = re.compile(r"\s*(?:水口かずえ|中倉茂和|岡田しんぺい)") out_cols = [(drop.sub("", h).strip(), idx) for h, idx in out_cols] print(f"\n### {label}\n") heads = [f'{h}' if ME in h else h for h, _ in out_cols] print("| 議案 | 議決結果 | " + " | ".join(heads) + " |") 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 "")