# -*- coding: utf-8 -*-
"""PDF「ステージングサーバーへの D-Watch の設置(ご依頼)」の自己点検。
原稿の文字がすべて PDF に出ているか、フォントが日本語フォントで埋め込まれているか等を見る。
PDF のパスは第1引数で渡せる。省略時はこのスクリプトと同じディレクトリの
DWatch_ステージング設置依頼_20261007.pdf。原稿は第2引数で渡せる。省略時は
docs/handoff/TeamWorks依頼_ステージングへのD-Watch設置_20261007.md。
終了コードは OK なら 0、NG なら 1。
★第12回(check_teamworks_qa12_pdf.py)の写し。変えたのは次だけ★
1. 既定の PDF・原稿を本依頼書に。生成スクリプトは make_teamworks_deploy_request_pdf.py
2. 設問の数 EXPECTED_Q を 5 に(Q1〜Q5。2026-10-07 の改訂で Docker の設問を Q1 に足した)
3. 7. の .env.local の照合に、★DB のホスト名・DB名・開発用の AUTH_TOKEN・
更新用アカウント(DATABASE_WRITE_USER / DATABASE_WRITE_PASSWORD)★を足した
(本依頼書は環境変数の名前だけを書き、値は1つも書かない約束。
接続先は「現在のステージングDB」とだけ書く)
4. FORBIDDEN に開発用の初期値 changeme を足した(それ以外は第12回と同じ)
★このスクリプトは生成スクリプト(make_teamworks_qa12_pdf.py)を import しない★
見るのは「PDF から抽出したテキスト」と「原稿の .md」だけ。
原稿の読み方(記法の外し方)もこのファイルの中で独自に書いてある。
生成側の実装が変わっても、この点検の結果は変わらない。
★第9回からの変更★
第9回は原稿の文言を must に書き写して照合していた(約1,600行)。
第11回は生成側が原稿の .md を読むようになったので、点検側も★原稿の .md を
読んで期待値を作る★。原稿を直しても点検スクリプトは直さなくてよい。
点検の項目
1. ページ数とサイズ
2. フォント埋め込み — BIZ UDGothic だけであること(中国語向けフォントが無いこと)
3. ★ が PDF のテキストに1つも無いこと
4. 原稿の見出しが全部 PDF に出ていること(取りこぼしの検出)
★設問(### の見出し)が5問あること★
5. 原稿の表の行数と、PDF に出た表の行数が一致すること
(表ごとに、行のセルを左から繋いだ文字列を、PDF の抽出テキストの中で
原稿の順に探す。見つかった行数を原稿の行数と突き合わせる)
6. 原稿の全文が PDF に出ていること(★取りこぼし禁止★)
(原稿の各行から記法を外した文字列を、原稿の順に探す)
7. 出てはいけない文字列(FORBIDDEN)が無いこと
★.env.local の DB のユーザー名・パスワードは、このスクリプトにも書かず、
実行時に .env.local から読んで照合する。値は出力にも出さない★
8. 豆腐(グリフ欠け)
9. 空白ページ
★照合は空白を詰めて行う★(全角空白・NBSP・ゼロ幅を含む)。
PDF ではバッククォートを記法として外しているので、照合の前に両方から落とす。
ページを跨ぐ文はフッター(D-Watch / DFA Robotics とページ番号)で分断されるので、
各ページからフッターを落としてから繋いだテキストで照合する。
★この点検があっても、全ページの目視確認は省かないこと★(pdf/README.md)。
枠のはみ出し・改ページ位置・識別子の語中改行は文字の有無では見られない。
必要なもの: pypdf。reportlab は 8.(豆腐の検査)にだけ使い、
無ければその項目を飛ばす(他の検査は走る)。
"""
import os
import re
import sys
from urllib.parse import unquote
try:
sys.stdout.reconfigure(errors="backslashreplace") # cp932 で落ちないように
except Exception:
pass
try:
from pypdf import PdfReader
except ImportError:
print("★NG★ pypdf が入っていない。`pip install pypdf` が必要。")
sys.exit(1)
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.normpath(os.path.join(HERE, "..", "..", ".."))
# ★既定は送付版★(2026-10-01 の改訂。PDF は送付版から作る)。第2引数で原稿を変えられる。
SRC = (sys.argv[2] if len(sys.argv) > 2 else
os.path.join(REPO, "docs", "handoff", "TeamWorks依頼_ステージングへのD-Watch設置_20261007.md"))
# 設問の数(Q1〜Q5)
EXPECTED_Q = 5
ENV = os.path.join(REPO, ".env.local")
PDF = (sys.argv[1] if len(sys.argv) > 1 else
os.path.join(HERE, "DWatch_ステージング設置依頼_20261007.pdf"))
if not os.path.exists(PDF):
print("★NG★ PDF が見つからない: %s" % PDF)
print("先に make_teamworks_deploy_request_pdf.py を実行するか、"
"PDF のパスを第1引数で渡すこと。")
sys.exit(1)
if not os.path.exists(SRC):
print("★NG★ 原稿が見つからない: %s" % SRC)
sys.exit(1)
raw = open(PDF, "rb").read()
reader = PdfReader(PDF)
pages = [p.extract_text() or "" for p in reader.pages]
text = "\n".join(pages)
md_lines = open(SRC, encoding="utf-8").read().splitlines()
ng = []
def sq(s):
"""空白(全角・NBSP・ゼロ幅を含む)とバッククォートを落とす。"""
return re.sub(r"[\s\u00a0\u3000\u200b\ufeff]+", "", s).replace("`", "")
def strip_footer(p, num):
"""ページのフッター(D-Watch / DFA Robotics とそのページの番号)を落とす。
ページ番号はそのページの番号に限って落とす(本文の数字を食わないように)。
"""
return re.sub(r"D-Watch\s*/\s*DFA\s*Robotics\s*%d(?!\d)" % num, "", p, count=1)
flat = sq(text)
flat_nofoot = sq("".join(strip_footer(p, i) for i, p in enumerate(pages, 1)))
def has(s):
t = sq(s)
return t in flat or t in flat_nofoot
def md_plain(s):
"""原稿の行内記法(★ / ** / ~~ / `)を外す。"""
return s.replace("★", "").replace("**", "").replace("~~", "").replace("`", "")
def find_in_order(items, hay):
"""items((行番号, 文字列) の並び)を hay の中で順に探す。
見つかったものは位置を進める。見つからなかったものは位置を進めずに記録する
(1件の欠落で後続が全部 NG にならないように)。
"""
pos = 0
missing = []
found = 0
for ln, s in items:
t = sq(s)
if not t:
continue
k = hay.find(t, pos)
if k < 0:
missing.append((ln, s))
else:
found += 1
pos = k + len(t)
return found, missing
# ---------------------------------------------------------------- 原稿を読む
RE_SEP = re.compile(r"^\|(\s*:?-{3,}:?\s*\|)+\s*$")
headings = [] # (行番号, 見出しの文字列)
tables = [] # [(行番号, [セル…]), …] の並び
body_lines = [] # (行番号, 記法を外した文字列) — 全文照合用(表の行も含む)
in_code = False
cur_table = None
for i, line in enumerate(md_lines, 1):
if line.startswith("```"):
in_code = not in_code
cur_table = None
continue
if in_code:
body_lines.append((i, line))
continue
if line.startswith("|"):
if cur_table is None:
cur_table = []
tables.append(cur_table)
if RE_SEP.match(line):
continue
cells = [md_plain(c.strip()) for c in line.strip()[1:-1].split("|")]
cur_table.append((i, cells))
body_lines.append((i, "".join(cells)))
continue
cur_table = None
s = line.strip()
if not s or re.match(r"^-{3,}$", s):
continue
m = re.match(r"^(#{1,6})\s+(.*)$", s)
if m:
headings.append((i, md_plain(m.group(2))))
body_lines.append((i, md_plain(m.group(2))))
continue
s = re.sub(r"^>\s?", "", s)
s = re.sub(r"^- ", "", s) # PDF では「・」に置き換わる
body_lines.append((i, md_plain(s)))
print("原稿:", SRC)
print(" %d 行 / 見出し %d / 表 %d(見出し行を含め %d 行) / 照合する行 %d"
% (len(md_lines), len(headings), len(tables),
sum(len(t) for t in tables), len(body_lines)))
# ---------------------------------------------------------------- 1.
print("\n== 1. ページ数とサイズ ==")
print("ページ数:", len(reader.pages), " サイズ:", len(raw), "bytes",
"(%.1f KB)" % (len(raw) / 1024))
print("抽出文字数:", len(text), " 空白を詰めた文字数:", len(flat))
if len(flat) < 800:
print(" ★NG★ 抽出できた文字が少なすぎる。テキストが図形化されている疑い")
ng.append("抽出文字数が少なすぎる")
# ---------------------------------------------------------------- 2.
print("\n== 2. フォント埋め込み(★最重要★) ==")
print("/FontFile2 出現回数:", raw.count(b"/FontFile2"))
# 埋め込まれているフォントが BIZ UDGothic だけであること。
# サブセット接頭辞(AAAAAA+)が付いているものが埋め込みフォント。
# 接頭辞の無いものは reportlab が必ず1つ書き出す標準14フォント(Helvetica)で、
# フォントファイルを持たず本文にも使われていない(第7回〜第9回の PDF にも同じ1件がある)。
allfonts = sorted(set(re.findall(rb"/BaseFont\s*/([A-Za-z0-9+\-,._]+)", raw)))
embedded = [f.decode() for f in allfonts if re.match(rb"^[A-Z]{6}\+", f)]
standard = [f.decode() for f in allfonts if not re.match(rb"^[A-Z]{6}\+", f)]
print("埋め込みフォント:", embedded)
print("非埋め込みの標準フォント(reportlab の既定・未使用):", standard)
if not [f for f in embedded if "BIZ" in f]:
print(" ★NG★ BIZ UDGothic が埋め込まれていない")
ng.append("BIZ UDGothic が埋め込まれていない")
notbiz = [f for f in embedded if "BIZ" not in f]
if notbiz:
print(" ★NG★ BIZ UDGothic 以外のフォントが埋め込まれている:", notbiz)
ng.append("BIZ UDGothic 以外の埋め込みフォント %s" % notbiz)
if len(embedded) != raw.count(b"/FontFile2"):
print(" ★注意★ 埋め込み名の数と /FontFile2 の数が合わない")
print("うち Bold:", [f for f in embedded if "Bold" in f] or "なし",
" Regular:", [f for f in embedded if "Bold" not in f] or "なし")
# 中国語向けフォントが混ざっていないこと(CLAUDE.md §0-3)
CN = [b"SimSun", b"NSimSun", b"SimHei", b"MicrosoftYaHei", b"MSYaHei",
b"MSGothic", b"NotoSansSC", b"NotoSerifSC", b"NotoSansCJKsc",
b"SourceHanSansSC", b"SourceHanSerifSC", b"FangSong", b"KaiTi",
b"STSong", b"STHeiti", b"STKaiti", b"STFangsong", b"PingFang",
b"DengXian", b"MingLiU", b"PMingLiU", b"DFKai", b"YouYuan", b"LiSu"]
cn_hit = [n.decode() for n in CN if n in raw]
print("中国語向けフォント:", cn_hit if cn_hit else "混入なし")
if cn_hit:
print(" ★NG★ 中国語向けフォントが混ざっている")
ng.append("中国語向けフォント %s" % cn_hit)
# ---------------------------------------------------------------- 3.
print("\n== 3. ★ が PDF に出ていないこと ==")
md_star = sum(l.count("★") for l in md_lines)
pdf_star = text.count("★")
print("原稿の ★: %d 個 / PDF の ★: %d 個" % (md_star, pdf_star))
if pdf_star:
print(" ★NG★ PDF に ★ が残っている")
ng.append("PDF に ★ が %d 個" % pdf_star)
# 記法の残りかすも見ておく(** / ~~ / バッククォート)
leftover = {k: text.count(k) for k in ("**", "~~", "`")}
print("記法の残り(** / ~~ / `):", leftover)
if any(leftover.values()):
print(" ★NG★ 記法が外れずに残っている")
ng.append("記法の残り %s" % leftover)
# ---------------------------------------------------------------- 4.
print("\n== 4. 原稿の見出しが全部 PDF に出ていること ==")
h_found, h_missing = find_in_order(headings, flat_nofoot)
print("見出し: 原稿 %d / PDF で見つかった %d" % (len(headings), h_found))
for ln, s in h_missing:
if has(s):
print(" ★注意★ %d行の見出しは PDF にあるが、順序が原稿と違う: %s" % (ln, s))
else:
print(" ★NG★ %d行の見出しが PDF に無い: %s" % (ln, s))
if h_missing:
ng.append("見出しの欠落・順序違い %d 件" % len(h_missing))
# ★設問の数★ — ### を設問にだけ使う約束(make_teamworks_deploy_request_pdf.py の冒頭)
questions = [(ln, md_plain(l[4:].strip())) for ln, l in enumerate(md_lines, 1)
if l.startswith("### ")]
q_found, q_missing = find_in_order(questions, flat_nofoot)
print("設問(### の見出し): 原稿 %d 問 / PDF で見つかった %d 問 / 期待 %d 問"
% (len(questions), q_found, EXPECTED_Q))
for ln, s in questions:
print(" %4d行 %s" % (ln, s[:60]))
if len(questions) != EXPECTED_Q or q_found != EXPECTED_Q:
print(" ★NG★ 設問の数が %d 問ではない" % EXPECTED_Q)
ng.append("設問の数 原稿 %d / PDF %d(期待 %d)"
% (len(questions), q_found, EXPECTED_Q))
# ---------------------------------------------------------------- 5.
print("\n== 5. 表の行数(原稿 / PDF) ==")
# 表の見出し行は改ページ時に PDF で繰り返されるが、順に探すので余分は読み飛ばす。
pos = 0
t_bad = 0
total_md = total_pdf = 0
for ti, tbl in enumerate(tables, 1):
found = 0
miss = []
for ln, cells in tbl:
t = sq("".join(cells))
k = flat_nofoot.find(t, pos)
if k < 0:
miss.append(ln)
else:
found += 1
pos = k + len(t)
total_md += len(tbl)
total_pdf += found
mark = "OK" if found == len(tbl) else "★NG★"
print(" 表%-3d %4d行目 原稿 %2d 行 / PDF %2d 行 %s%s"
% (ti, tbl[0][0], len(tbl), found, mark,
(" 欠落: %s行目" % miss) if miss else ""))
if miss:
t_bad += 1
print("合計: 原稿 %d 行 / PDF %d 行(表 %d 個中、不一致 %d 個)"
% (total_md, total_pdf, len(tables), t_bad))
if t_bad:
ng.append("表の行数の不一致 %d 個" % t_bad)
# ---------------------------------------------------------------- 6.
print("\n== 6. 原稿の全文が PDF に出ていること(取りこぼし禁止) ==")
b_found, b_missing = find_in_order(body_lines, flat_nofoot)
print("照合した行: %d / 見つかった %d / 欠落 %d"
% (len([1 for _, s in body_lines if sq(s)]), b_found, len(b_missing)))
for ln, s in b_missing[:30]:
where = "順序違い" if has(s) else "欠落"
print(" ★NG★ %d行(%s): %s" % (ln, where, s[:80]))
if b_missing:
ng.append("本文の欠落・順序違い %d 行" % len(b_missing))
# 文字数でも突き合わせる(記法を外した原稿の文字数 ≒ PDF の文字数)
md_chars = len(sq("".join(s for _, s in body_lines)))
print("記法を外した原稿の文字数 %d / PDF(フッター除く)の文字数 %d"
% (md_chars, len(flat_nofoot)))
print(" 差 %d は、箇条書きの「・」・改ページで繰り返した表の見出し行の分"
% (len(flat_nofoot) - md_chars))
# ---------------------------------------------------------------- 7.
print("\n== 7. 出てはいけない文字列(FORBIDDEN) ==")
FORBIDDEN = [
# ---- 社内の呼び名・社内資料への参照(TeamWorks は知らない) ----
"指示書",
"docs/",
"lib/",
"decisions.md",
"CLAUDE.md",
"本番DBそのもの",
"IDENTIFIED BY",
# ---- 開発用の初期値(本依頼書では合言葉の値を一切書かない) ----
"changeme",
]
# D-番号は下で正規表現で見る。
ALLOWED_CONTEXT = {}
f_hit = []
import re as _re
if _re.search(r"D-\d{3}", flat_nofoot):
print(" ★NG★ D-番号(社内の決定番号)が出ている")
f_hit.append("D-番号")
for w in FORBIDDEN:
t = sq(w)
n_all = flat_nofoot.count(t)
n_ok = sum(flat_nofoot.count(sq(c)) for c in ALLOWED_CONTEXT.get(w, []))
n_bad = n_all - n_ok
if ALLOWED_CONTEXT.get(w):
n_md = sum(sq(md_plain("\n".join(md_lines))).count(sq(c))
for c in ALLOWED_CONTEXT[w])
print(" %-14s 出現 %d(うち許可した形 %s: %d / 原稿 %d) → 禁止の形 %d"
% (w, n_all, "・".join(ALLOWED_CONTEXT[w]), n_ok, n_md, n_bad))
if n_ok != n_md:
print(" ★NG★ 許可した形の数が原稿と合わない")
f_hit.append(w + "(許可した形の数)")
else:
print(" %-14s 出現 %d" % (w, n_all))
if n_bad:
f_hit.append(w)
# .env.local の DB 認証情報(値はこのスクリプトに書かない・出力にも出さない)
secrets = []
if os.path.exists(ENV):
env = {}
for l in open(ENV, encoding="utf-8"):
m = re.match(r"^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$", l)
if m:
env[m.group(1)] = m.group(2).strip().strip('"').strip("'")
# ★本依頼書で足したもの★: ホスト名・DB名・開発用の AUTH_TOKEN・更新用アカウント
for k in ("DATABASE_USER", "DATABASE_PASSWORD", "DATABASE_HOST", "DATABASE_NAME",
"AUTH_TOKEN", "DATABASE_WRITE_USER", "DATABASE_WRITE_PASSWORD"):
if env.get(k):
secrets.append((k, env[k]))
mu = re.match(r"^[a-z]+://([^:@/]*):([^@/]*)@", env.get("DATABASE_URL", ""))
if mu:
secrets.append(("DATABASE_URL のユーザー名", unquote(mu.group(1))))
secrets.append(("DATABASE_URL のパスワード", unquote(mu.group(2))))
mh = re.match(r"^[a-z]+://[^@/]*@([^:/?]+)(?::\d+)?/([^?]*)", env.get("DATABASE_URL", ""))
if mh:
secrets.append(("DATABASE_URL のホスト", mh.group(1)))
secrets.append(("DATABASE_URL のDB名", unquote(mh.group(2))))
secrets = [(k, v) for k, v in secrets if v]
s_hit = []
for k, v in secrets:
if (v in text or sq(v) in flat or sq(v) in flat_nofoot
or v.encode("utf-8") in raw):
s_hit.append(k)
print(" .env.local の認証情報・接続先: %d 個の値を照合(値は表示しない) → %s"
% (len(secrets), ("★検出★ " + ", ".join(s_hit)) if s_hit else "検出なし"))
short = [k for k, v in secrets if len(v) < 4]
if short:
print(" ★注意★ 4文字未満の値があり、偶然の一致で誤検出しうる:", short)
if s_hit:
f_hit.append(".env.local の認証情報(%s)" % ", ".join(s_hit))
if not secrets:
print(" ★NG★ .env.local から DB の認証情報を読めなかった(照合できていない)")
ng.append(".env.local の認証情報を読めない")
else:
print(" ★NG★ .env.local が無い(認証情報の照合ができない):", ENV)
ng.append(".env.local が無い")
print("FORBIDDEN の検出:", f_hit if f_hit else "なし")
if f_hit:
ng.append("FORBIDDEN %s" % f_hit)
# ---------------------------------------------------------------- 8.
print("\n== 8. 豆腐(グリフ欠け) ==")
# 抽出テキストの全文字を BIZ UDGothic の cmap に引き当てる。
# 字が無い文字は PDF 上で □ になる。㎡ / Σ / ÷ / ⇔ / ①〜⑨ / 〜 / = 等の確認。
try:
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
pdfmetrics.registerFont(TTFont("JP", "C:/Windows/Fonts/BIZ-UDGothicR.ttc",
subfontIndex=0))
pdfmetrics.registerFont(TTFont("JP-B", "C:/Windows/Fonts/BIZ-UDGothicB.ttc",
subfontIndex=0))
c2g_r = pdfmetrics.getFont("JP").face.charToGlyph
c2g_b = pdfmetrics.getFont("JP-B").face.charToGlyph
chars = sorted(set(text) - set("\n\r\t "))
missing_r = [c for c in chars if ord(c) not in c2g_r]
missing_b = [c for c in chars if ord(c) not in c2g_b]
print("本文に出る文字種:", len(chars))
print("BIZ UDGothic R に字が無い文字:", missing_r if missing_r else "なし")
print("BIZ UDGothic B に字が無い文字:", missing_b if missing_b else "なし")
if missing_r or missing_b:
ng.append("グリフ欠け R=%s B=%s" % (missing_r, missing_b))
except Exception as e:
print(" ★この検査は飛ばした★(reportlab かフォントが無い):", e)
tofu = sorted(set(c for c in text if c in "\ufffd□"))
print("置換文字・豆腐そのものの混入:", tofu if tofu else "なし")
if tofu:
ng.append("豆腐 %s" % tofu)
# 原稿にある文字が、PDF の文字種から抜けていないか(記法の文字を除く)
md_chars_set = set(md_plain("".join(md_lines))) - set(" \t#>|-")
lost = sorted(md_chars_set - set(text) - set("\u3000"))
print("原稿にあって PDF に1度も出ない文字:", lost if lost else "なし")
if lost:
ng.append("PDF に出ない文字 %s" % lost)
# ---------------------------------------------------------------- 9.
print("\n== 9. 空白ページ ==")
blank = []
for i, t in enumerate(pages, 1):
body = sq(strip_footer(t, i))
print(" p%-3d 抽出 %5d 文字 / 本文 %5d 文字" % (i, len(t), len(body)))
if len(body) < 20:
blank.append(i)
print("空白ページ:", blank if blank else "なし")
if blank:
ng.append("空白ページ %s" % blank)
# ---------------------------------------------------------------- 結果
print("\n== 結果 ==")
print("ページ数: %d / サイズ: %.1f KB" % (len(reader.pages), len(raw) / 1024))
if ng:
print("★NG★")
for x in ng:
print(" -", x)
else:
print("OK(全項目)")
sys.exit(0 if not ng else 1)