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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
|
"""賛否一覧PDFから、Markdownの表を生成する。
- 上部の「会派名:正式名(議員数)」をページ内から探して読む(位置は年度で変わる)
- 議員の列は、議員名(縦書き)の文字のx座標をクラスタリングして作る
(年度によって罫線が無いため)。「賛成」「反対」の列は合計欄として使う
- 議案行は、賛否記号のある行。番号・件名・議決結果は文字列から切り出す
- PDFに載っている「賛成・反対」の合計と突き合わせて検算する
- 「一人会派」は構成員ごとに列を分ける(議員名を出すため)。安竹の列は強調する
使い方: python scripts/parse-sanpi-hyo.py <pdfのパス> "<定例会名>"
"""
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 split_row(sel):
"""行の帯の文字から、号数・件名・議決結果を切り出す。
PDF の件名はセルの中で中央揃えの複数行になるため、単純に連結すると
左の号数欄・右の議決結果欄が件名の途中に挟まる。そこで議決結果は
「いちばん右にある議決語」とし、その語だけを件名から取り除く。
"""
tch = [c["c"].translate(FULL2HALF) for c in sel]
tstr = "".join(tch)
drop = set()
no = ""
no_m = NO_RE.search(tstr)
if no_m:
no = no_m.group(0)
drop.update(range(*no_m.span()))
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)
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()
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, 74, 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))
print(f"\n### {label}\n")
print("| 議案 | 議決結果 | " + " | ".join(h for h, _ in out_cols) + " |")
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'<span class="sanpi-me">{v}</span>'
cells.append(v)
ic, mark = kekka_icon(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 "")
|