1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
|
"""賛否一覧PDFから、Markdownの表を生成する。
- 上部の「会派名:正式名(議員数)」を読む(推測しない)
- 表の縦罫線で列を厳密に区切り、議員名(縦書き)と賛否記号を列へ割り当てる
- 議案ごとに区分・番号・件名・議決結果と議員の賛否をまとめ、
PDFに載っている「賛成・反対」の合計と突き合わせて検算する
使い方: python scripts/parse-sanpi-hyo.py <pdfのパス> "<定例会名>"
"""
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 = {
"原案可決": ("ok", "✓"),
"可決": ("ok", "✓"),
"認定": ("ok", "✓"),
"採択": ("ok", "✓"),
"同意": ("ok", "✓"),
"承認": ("ok", "✓"),
"否決": ("ng", "✗"),
"原案否決": ("ng", "✗"),
"不採択": ("ng", "✗"),
}
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)
# 会派(上部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)
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)
# 議員名の帯
namechars = [c for c in cs if 88 <= c["y"] <= 140 and c["x"] > 308]
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
# 議案行(賛否記号のある行)。凡例等を除くため、議員数の半分以上の記号がある行だけを採用する
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"].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"]))
# 左側(議案)の列境界: 議員列より左の罫線
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 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()
main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "")
|