"""賛否一覧PDF(新レイアウト)を読み、Markdown表を出力する。合計欄で検算する。 使い方: python .extract-final.py "<定例会名>" """ import re import sys import pymupdf sys.stdout.reconfigure(encoding="utf-8") YES, NO, ABS = set("〇○◯"), set("×✕✖"), set("-—−-・") VOTE = YES | NO | ABS DIGITS = set("01234567890123456789") 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 main(path, label): doc = pymupdf.open(path) pg = doc[0] cs = chars(pg) # 会派(上部の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) # 議員名の帯(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] 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)] # 会派ごとに区切る 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)] # 賛否 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) # 合計欄(末尾の列)の数字 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"])) # 議案名(左側) 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"])) 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(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "")