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
|
"""賛否一覧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 = {
"原案可決": ("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'<span class="sanpi-kekka {ic}" title="{d["kekka"]}">{mark}</span>' 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 "")
|