aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/parse-sanpi-hyo.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/parse-sanpi-hyo.py')
-rw-r--r--scripts/parse-sanpi-hyo.py162
1 files changed, 110 insertions, 52 deletions
diff --git a/scripts/parse-sanpi-hyo.py b/scripts/parse-sanpi-hyo.py
index 457cac0..f04b0d0 100644
--- a/scripts/parse-sanpi-hyo.py
+++ b/scripts/parse-sanpi-hyo.py
@@ -1,6 +1,11 @@
-"""賛否一覧PDF(新レイアウト)を読み、Markdown表を出力する。合計欄で検算する。
+"""賛否一覧PDFから、Markdownの表を生成する。
-使い方: python .extract-final.py <pdf> "<定例会名>"
+- 上部の「会派名:正式名(議員数)」を読む(推測しない)
+- 表の縦罫線で列を厳密に区切り、議員名(縦書き)と賛否記号を列へ割り当てる
+- 議案ごとに区分・番号・件名・議決結果と議員の賛否をまとめ、
+ PDFに載っている「賛成・反対」の合計と突き合わせて検算する
+
+使い方: python scripts/parse-sanpi-hyo.py <pdfのパス> "<定例会名>"
"""
import re
import sys
@@ -11,7 +16,20 @@ sys.stdout.reconfigure(encoding="utf-8")
YES, NO, ABS = set("〇○◯"), set("×✕✖"), set("-—−-・")
VOTE = YES | NO | ABS
-DIGITS = set("01234567890123456789")
+FULL2HALF = str.maketrans("0123456789ABC", "0123456789ABC")
+
+# 議決結果 → アイコン表示
+KEKKA_ICON = {
+ "原案可決": ("ok", "✓"),
+ "可決": ("ok", "✓"),
+ "認定": ("ok", "✓"),
+ "採択": ("ok", "✓"),
+ "同意": ("ok", "✓"),
+ "承認": ("ok", "✓"),
+ "否決": ("ng", "✗"),
+ "原案否決": ("ng", "✗"),
+ "不採択": ("ng", "✗"),
+}
def col_of(x, bounds):
@@ -33,94 +51,134 @@ def chars(pg):
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)
- # 会派(上部の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)
+ # 会派(上部2行)
+ top = "".join(c["c"] for c in sorted(
+ [c for c in cs if 45 <= c["y"] <= 70], key=lambda c: (round(c["y"] / 6), c["x"])))
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 = {}
+ ylines = []
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)
+ xs[k] = max(xs.get(k, 0), a.y, b.y)
+ elif abs(a.y - b.y) < 0.8:
+ ylines.append((round(a.y, 1), round(min(a.x, b.x), 1), round(max(a.x, b.x), 1)))
+ grid = sorted(x for x, y in xs.items() if y > 200 and 30 < x < 780)
- # 議員名の帯(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)
+ 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 <= nx1 + 15]
+ 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)]
-
- # 会派ごとに区切る
- 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)]
+ 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
- # 賛否
+ # 議案行(賛否記号のある行)。凡例等を除くため、議員数の半分以上の記号がある行だけを採用する
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)
+ ys = sorted(y for y, v in rows.items()
+ if sum(1 for c in v if col_of(c["x"], bounds) in mcols) >= max(4, total // 2))
- # 合計欄(末尾の列)の数字
+ # 合計欄(議員列より右)の数字
nums = {}
for c in cs:
- if c["c"] in DIGITS:
+ if c["c"].isdigit() or c["c"] in "0123456789":
i = col_of(c["x"], bounds)
- if i is not None and i >= len(member_only):
+ if i is not None and i >= total:
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):
+ # 左側(議案)の列境界: 議員列より左の罫線
+ lb = [x for x in grid if x < bounds[0]]
+ # 議案行の組み立て(前後の中点を境界にして、行のテキストを拾う)
+ data = []
+ 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
+ no = text_of(cs, 78, 118, lo, hi).translate(FULL2HALF)
+ name = text_of(cs, 120, 268, lo, hi).translate(FULL2HALF)
+ kekka = text_of(cs, 270, 312, lo, hi).translate(FULL2HALF)
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 "不一致あり")
+ if i in mcols:
+ cells[i] = c["c"]
+ data.append({"no": no, "name": name, "kekka": kekka, "votes": cells})
+ # 検算
+ 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.get(d["kekka"], ("", d["kekka"]))
+ kekka = f'<span class="sanpi-kekka {ic}" title="{d["kekka"]}">{mark}</span>' 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()