"""賛否一覧PDFから、Markdownの表を生成する。 - 上部の「会派名:正式名(議員数)」を読む(推測しない) - 表の縦罫線で列を厳密に区切り、議員名(縦書き)と賛否記号を列へ割り当てる - 議案ごとに区分・番号・件名・議決結果と議員の賛否をまとめ、 PDFに載っている「賛成・反対」の合計と突き合わせて検算する 使い方: python scripts/parse-sanpi-hyo.py "<定例会名>" """ 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_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 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"]: 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 text_of(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"]))) 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) print(f"会派の行 y={header_y} / {len(parties)}会派 / {total}人") # 縦罫線グリッドと、横罫線(議案行の区切り) 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] rows0 = {} for c in marks: rows0.setdefault(round(c["y"] / 10) * 10, []).append(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} # 議員名の帯(見出しの下。縦書きの名が収まる範囲) 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] 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)] member_only = members[:total] mcols = [m[0] for m in member_only] # 議員の列index # 左側(議案)の列境界 x_kubun, x_no, x_name, x_kekka = (40, 78), (82, 118), (120, 268), (270, 312) # 合計欄(議員列より右)の数字 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"])) # 議案行の組み立て(前後の中点を境界にして、行のテキストを拾う) data = [] kubun = "" 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) cells = {} for c in rows[y]: i = col_of(c["x"], bounds) if i in mcols: 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) 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]) k = 0 for s, f, n in parties: idx = mcols[k:k + n] k += n split = 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)) 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])) 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: 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)) ic, mark = kekka_icon(d["kekka"]) kekka = f'{mark}' if ic else d["kekka"] # 請願・議員提出議案は区分を頭に付ける head = "" title = (head + 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 "")