# -*- coding: utf-8 -*-
"""PDF「ソースコード地図」(docs/code-map.md から作る PDF)の自己点検。
原稿の文字がすべて PDF に出ているか、フォントが日本語フォントだけか、
しおりと章内リンクが揃っているかを見る。
PDF のパスは第1引数で渡せる。省略時はこのスクリプトと同じディレクトリの
DWatch_ソースコード地図_20261005.pdf。原稿は第2引数で渡せる。省略時は
docs/code-map.md。終了コードは OK なら 0、NG なら 1。
土台は check_teamworks_qa11_pdf.py(写して改造した)。
★このスクリプトは生成スクリプト(make_code_map_pdf.py)を import しない★
見るのは「PDF から読んだもの(テキスト・フォント・しおり・リンク注釈)」と
「原稿の .md」だけ。原稿の読み方(記法の外し方)もこのファイルの中で独自に書いてある。
生成側の実装が変わっても、この点検の結果は変わらない。
★期待する数は原稿から数える★(見出し・コードブロック・Mermaid・しおり・章内リンク)。
スクリプトに数字を直書きしないので、原稿が変わってもこのまま使える。
(2026-10-05 の原稿では 見出し 1+23+137+71=232 / コードブロック 37(うち Mermaid 9)/
しおり 23+137=160 / 章内リンク 56)
点検の項目
0. ページ数とサイズ(参考)
1. ★フォント★ — 埋め込まれているフォントが BIZ UDGothic と Segoe UI Symbol だけであること。
Segoe UI Symbol は「BIZ UDGothic に字の無い記号(✅ ❌ ⚠ 📋 ≥ ‹ › ✗ など)」だけを描く
補助フォント(2026-10-05 決定。置き換え ✅→✓ 等はやめ、原文の文字のまま出す)。
中国語向けフォント(SimSun / Microsoft YaHei / Noto Sans SC / STSong / SimHei 等)が
1つでも混ざっていたら NG(CLAUDE.md §0-3)。
ページのリソースを辿ってフォントを集め、本文で実際に文字を描いているフォントも見る。
★日本語の文字(CJK 統合漢字・ひらがな・カタカナ)を描いているフォントが BIZ UDGothic だけ
であること★も見る。ページの描画命令から「どのフォントでどの文字を描いたか」を取って確かめ、
あわせて補助フォントの埋め込みサブセット(ToUnicode)に日本語の文字が無いことも確かめる。
(Segoe UI Symbol には漢字の字形が無いので、reportlab が漢字を Segoe UI Symbol で組むと
.notdef(抽出テキストの \\x00)になる。その形は 7. の豆腐の検査で NG になる。
SimSun のように漢字を持つフォントで描いた場合は、この 1. で NG になる。どちらも試験済み)
2. ★見出し★ — 原稿の # 〜 ###### の見出しの文字が、PDF のテキストに原稿の順で全部あること
3. ★本文★ — 原稿の段落・表のセル・箇条書き・引用の文字が、PDF のテキストに原稿の順であること。
見つからない行は原稿の行番号つきで出す(表は、どのセルが無いかも出す)
4. ★コードブロック★ — 中身(Mermaid も記法のまま)が PDF のテキストに入っていること
5. ★しおり★ — PDF のアウトラインの数が、原稿の ## と ### の数と一致すること
(題名の食い違い・階層の食い違いは ★注意★ として出す。宛先のページが無いものは NG)
6. ★章内リンク★ — 文書内へ飛ぶリンク注釈(GoTo / 名前付き宛先)が原稿の [..](#..) の数以上あり、
宛先がすべて実在すること。あわせて、原稿の #.. が原稿の見出しに実在するか(GitHub の
アンカーの付け方で)も見る(原稿側で切れているリンクは PDF でも飛べない)
7. 豆腐(グリフ欠け)と、字の無い文字 — 土台から引き継ぎ、次を足した:
- 描いた文字ごとに、描いたフォント(BIZ UDGothic R / B か Segoe UI Symbol)に字があること
- 抽出テキストの \\x00(.notdef = 豆腐として描かれた字)があれば NG
- 原稿にあって BIZ UDGothic に字の無い文字(2026-10-05 の原稿で19種)が、PDF のテキストに
原文の文字のまま、原稿と同じ数以上出ていること。どのフォントで描いたかも出す
8. 空白ページ — 土台から引き継いだ
★字の無い文字の照合★
置き換えはしない約束なので、字の無い文字も含めて原文どおりに照合する(行頭・行末も外さない)。
絵文字の異体字セレクタ(U+FE0F。目に見えない)だけは、比べる前に両方から取り除く
(生成側が取り除くため)。
★照合の前の正規化★
原稿側: ★ を消す / ** と ~~ を消す / ` を消す / リンク [文字](…) は 文字 だけにする /
<br> 類を消す / HTML の実体参照を戻す / 表のセルの \\| は | に戻す /
行頭の箇条書きの印(- * 1.)と引用の > を外す。
★コードスパン(`…`)の中の ** / ~~ / <br> は記法ではなく文字なので外さない★
(`app/api/**/route.ts` の ** は PDF にもそのまま出る)
両方: 空白・改行をすべて取り除く(全角空白・NBSP・ゼロ幅を含む)。PDF は行の折り返しで
改行や空白が入るため。
★ も両方から落とす(「★印は事故が起きやすい変更」のように ★ そのものを指す文では
生成側が ★ を残すため)
コードブロックは記法を外さず、そのまま照合する。ただし Mermaid のラベルの <br/> は
生成側が外すこともありうるので、外した形でも見つかれば可とする(どの形で見つかったかは出す)。
ページを跨ぐ文はフッター(D-Watch / DFA Robotics とページ番号)で分断されるので、
各ページからフッターを落としてから繋いだテキストで照合する。
★この点検があっても、全ページの目視確認は省かないこと★(pdf/README.md)。
枠のはみ出し・改ページ位置・識別子の語中改行は文字の有無では見られない。
必要なもの: pypdf。reportlab は 7.(豆腐の検査)にだけ使い、
無ければその項目を飛ばす(他の検査は走る)。
"""
import html
import os
import re
import sys
import unicodedata
try:
sys.stdout.reconfigure(errors="backslashreplace") # cp932 で落ちないように
except Exception:
pass
try:
from pypdf import PdfReader
from pypdf.generic import ArrayObject, DictionaryObject, IndirectObject
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, "..", "..", ".."))
PDF = (sys.argv[1] if len(sys.argv) > 1 else
os.path.join(HERE, "DWatch_ソースコード地図_20261005.pdf"))
SRC = (sys.argv[2] if len(sys.argv) > 2 else
os.path.join(REPO, "docs", "code-map.md"))
if not os.path.exists(PDF):
print("★NG★ PDF が見つからない: %s" % PDF)
print("先に make_code_map_pdf.py を実行するか、PDF のパスを第1引数で渡すこと。")
sys.exit(1)
if not os.path.exists(SRC):
print("★NG★ 原稿が見つからない: %s" % SRC)
sys.exit(1)
# 一覧の出しすぎで肝心の結果が流れないように、各項目の詳細は最大この件数まで出す
MAX_LIST = 300
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・ゼロ幅を含む)、絵文字の異体字セレクタ・ZWJ、★ を落とす。
★ は原稿・PDF の両方から落とす。原稿の ★ は強調の印として外すのが原則だが、
「★印は事故が起きやすい変更」のように★そのものを指す文では、生成側が ★ を残す。
どちらでも照合が通るように、PDF 側からも落としてから比べる。
"""
for c in (0xFE0E, 0xFE0F, 0x200D):
s = s.replace(chr(c), "")
s = s.replace("★", "")
return re.sub(r"[\s\u00a0\u3000\u200b\ufeff]+", "", s)
# ---------------------------------------------------------------- 字の無い文字
# 原稿には BIZ UDGothic に字(グリフ)の無い文字がある(✅ ❌ ⚠ 📋 ≥ ‹ › ✗ など。
# 2026-10-05 の原稿で19種)。生成側はこれらだけを補助フォント Segoe UI Symbol で描く
# (置き換えはしない)。点検では、
# - 照合は原文どおり(置き換えを許さない)
# - 描いた文字ごとに、描いたフォントに字があることを見る(7.)
# - 字の無い文字が PDF のテキストに原文のまま出ていることを見る(7.)
BIZ_R = "C:/Windows/Fonts/BIZ-UDGothicR.ttc"
BIZ_B = "C:/Windows/Fonts/BIZ-UDGothicB.ttc"
SYM = "C:/Windows/Fonts/seguisym.ttf"
GLYPH_FONTS = [] # (名前, charToGlyph) — BIZ UDGothic の R と B
SYM_C2G = None # Segoe UI Symbol の charToGlyph
GLYPH_ERR = None
try:
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
for fname, fpath in (("JP", BIZ_R), ("JP-B", BIZ_B)):
pdfmetrics.registerFont(TTFont(fname, fpath, subfontIndex=0))
GLYPH_FONTS.append((fname, pdfmetrics.getFont(fname).face.charToGlyph))
pdfmetrics.registerFont(TTFont("SYM", SYM))
SYM_C2G = pdfmetrics.getFont("SYM").face.charToGlyph
except Exception as e: # reportlab かフォントが無い
GLYPH_ERR = e
GLYPHLESS = set()
if GLYPH_FONTS:
GLYPHLESS = set(ch for ch in set("".join(md_lines))
if sq(ch) and any(ord(ch) not in c2g for _, c2g in GLYPH_FONTS))
def locate(s, hay, pos=0):
"""s を(空白を詰めて)hay の pos 以降で探す。見つかれば (開始, 終了) を返す。"""
t = sq(s)
if not t:
return None
k = hay.find(t, pos)
return None if k < 0 else (k, k + len(t))
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):
return bool(locate(s, flat_nofoot) or locate(s, flat))
RE_LINK = re.compile(r"\[([^\]]*)\]\(([^)]*)\)")
RE_BR = re.compile(r"<br\s*/?>", re.I)
def md_plain(s):
"""原稿の行内記法を外す(リンク → 文字 / ★ / ** / ~~ / ` / <br> / 実体参照)。
★コードスパン(`…`)の中は文字どおり★に扱い、** や ~~ を外さない。
`app/api/**/route.ts` の ** は記法ではなく文字で、PDF にもそのまま出るため
(2026-10-05、生成した PDF で実際にこれを誤検出した)。
バッククォートの数が奇数の行は、コードスパンが閉じていないとみなして全体を外側として扱う。
"""
segs = s.split("`")
if len(segs) % 2 == 0:
segs = ["".join(segs)]
out = []
for j, seg in enumerate(segs):
if j % 2 == 1: # コードスパンの中
out.append(seg.replace("★", ""))
continue
seg = RE_LINK.sub(lambda m: m.group(1), seg)
seg = seg.replace("★", "").replace("**", "").replace("~~", "")
seg = RE_BR.sub("", seg)
out.append(html.unescape(seg))
return "".join(out)
def split_row(line):
"""表の行をセルに分ける。\\| はセルの区切りではなく文字の | として扱う。"""
s = line.strip()
if s.startswith("|"):
s = s[1:]
if s.endswith("|") and not s.endswith("\\|"):
s = s[:-1]
cells = re.split(r"(?<!\\)\|", s)
return [c.strip().replace("\\|", "|") for c in cells]
def find_in_order(items, hay, key=lambda x: x[-1]):
"""items を hay の中で順に探す。
見つかったものは位置を進める。見つからなかったものは位置を進めずに記録する
(1件の欠落で後続が全部 NG にならないように)。
"""
pos = 0
missing = []
found = 0
for it in items:
if not sq(key(it)):
continue
loc = locate(key(it), hay, pos)
if loc is None:
missing.append(it)
else:
found += 1
pos = loc[1]
return found, missing
def gh_slug(s):
"""GitHub の見出しアンカーの付け方(小文字化・記号を落とす・空白を - に)。"""
s = md_plain(s).strip().lower()
out = []
for ch in s:
cat = unicodedata.category(ch)
if ch in " -" or cat[0] in "LMN" or cat == "Pc":
out.append(ch)
return "".join(out).replace(" ", "-")
# ---------------------------------------------------------------- 原稿を読む
RE_SEP = re.compile(r"^\|?(\s*:?-{3,}:?\s*\|)+\s*(:?-{3,}:?\s*)?$")
headings = [] # (行番号, レベル, 原文, 記法を外した文字列)
codes = [] # {"ln": 開始行, "lang": 言語, "lines": [(行番号, 原文), …]}
body = [] # (行番号, 種類, [セル…] または None, 記法を外した文字列)
md_links = [] # (行番号, リンクの文字, アンカー)
in_code = False
cur_code = None
for i, line in enumerate(md_lines, 1):
if line.startswith("```"):
if not in_code:
cur_code = {"ln": i, "lang": line[3:].strip().lower(), "lines": []}
codes.append(cur_code)
in_code = not in_code
continue
if in_code:
cur_code["lines"].append((i, line))
continue
for m in RE_LINK.finditer(line):
if m.group(2).startswith("#"):
md_links.append((i, m.group(1), m.group(2)[1:]))
s = line.strip()
if not s or re.match(r"^(-{3,}|\*{3,}|_{3,})$", s):
continue
m = re.match(r"^(#{1,6})\s+(.*?)\s*#*\s*$", s)
if m:
headings.append((i, len(m.group(1)), m.group(2), md_plain(m.group(2))))
continue
if s.startswith("|"):
if RE_SEP.match(s):
continue
cells = [md_plain(c) for c in split_row(s)]
body.append((i, "表", cells, "".join(cells)))
continue
kind = "段落"
if s.startswith(">"):
kind = "引用"
s = re.sub(r"^(>\s?)+", "", s)
if re.match(r"^([-*+]|\d+[.)])\s+", s):
kind = "箇条書き" if kind == "段落" else kind
s = re.sub(r"^([-*+]|\d+[.)])\s+", "", s) # PDF では「・」や番号に置き換わりうる
if s.startswith("|"): # 引用の中の表
if RE_SEP.match(s):
continue
cells = [md_plain(c) for c in split_row(s)]
body.append((i, "表", cells, "".join(cells)))
continue
body.append((i, kind, None, md_plain(s)))
if in_code:
print("★注意★ 原稿のコードブロックが閉じていない(%d行から)" % cur_code["ln"])
lv_count = {lv: sum(1 for h in headings if h[1] == lv) for lv in range(1, 7)}
mermaids = [c for c in codes if c["lang"] == "mermaid"]
print("原稿:", SRC)
print("PDF :", PDF)
print(" %d 行 / 見出し %d(%s)/ コードブロック %d(うち Mermaid %d)/ "
"本文の行 %d / 章内リンク %d"
% (len(md_lines), len(headings),
" + ".join("%s:%d" % ("#" * lv, n) for lv, n in lv_count.items() if n),
len(codes), len(mermaids), len(body), len(md_links)))
# ---------------------------------------------------------------- 0.
print("\n== 0. ページ数とサイズ(参考) ==")
print("ページ数:", len(reader.pages), " サイズ:", len(raw), "bytes",
"(%.1f KB)" % (len(raw) / 1024))
print("抽出文字数:", len(text), " 空白を詰めた文字数:", len(flat))
if len(flat) < 2000:
print(" ★NG★ 抽出できた文字が少なすぎる。テキストが図形化されている疑い")
ng.append("抽出文字数が少なすぎる")
# ---------------------------------------------------------------- 1.
print("\n== 1. フォント(★最重要★) ==")
CN = ["SimSun", "NSimSun", "SimHei", "MicrosoftYaHei", "YaHei", "NotoSansSC",
"NotoSerifSC", "NotoSansCJKsc", "NotoSerifCJKsc", "NotoSansHans",
"SourceHanSansSC", "SourceHanSansCN", "SourceHanSerifSC", "SourceHanSerifCN",
"FangSong", "KaiTi", "STSong", "STHeiti", "STKaiti", "STFangsong",
"STXihei", "PingFang", "DengXian", "MingLiU", "PMingLiU", "DFKai",
"YouYuan", "LiSu", "HiraginoSansGB", "WenQuanYi"]
def font_info(fobj):
"""フォント辞書から (BaseFont 名, 埋め込みの有無) を返す。Type0 は子孫フォントを見る。"""
f = fobj.get_object()
name = str(f.get("/BaseFont", "?")).lstrip("/")
descs = []
if f.get("/Subtype") == "/Type0":
for d in f.get("/DescendantFonts", []):
descs.append(d.get_object())
else:
descs.append(f)
embedded = False
for d in descs:
fd = d.get("/FontDescriptor")
if fd is not None:
fd = fd.get_object()
if any(k in fd for k in ("/FontFile", "/FontFile2", "/FontFile3")):
embedded = True
return name, embedded
RE_SHOW = re.compile(rb"(\((?:[^)\\]|\\.)+\)|<[0-9A-Fa-f\s]+>)\s*(Tj|'|\")"
rb"|\[[^\]]*[(<][^\]]*\]\s*TJ", re.S)
fonts = {} # BaseFont 名 → 埋め込みの有無
used = set() # 本文で Tf で選ばれているフォントの BaseFont 名
def walk_resources(res, content_data, seen):
"""リソース辞書のフォントを集め、content_data の Tf で選ばれたものを used に入れる。
Form XObject の中も辿る。
"""
if res is None:
return
res = res.get_object()
fmap = {}
fd = res.get("/Font")
if fd is not None:
for rname, fobj in fd.get_object().items():
name, emb = font_info(fobj)
fonts[name] = fonts.get(name, False) or emb
fmap[str(rname)] = name
# Tf で選んでから次の Tf までの間に、文字を描く命令(空でない Tj / TJ / ' / ")が
# あるものだけを「使っている」とみなす。reportlab はページの頭で Helvetica を選ぶだけの
# 「BT /F1 12 Tf 14.4 TL ET」を必ず書くが、これは文字を描かないので数えない。
data = content_data or b""
marks = list(re.finditer(rb"(/[^\s/\[\]<>(){}%]+)\s+[-\d.]+\s+Tf", data))
for j, m in enumerate(marks):
seg = data[m.end():marks[j + 1].start() if j + 1 < len(marks) else len(data)]
if RE_SHOW.search(seg):
rn = m.group(1).decode("latin-1")
if rn in fmap:
used.add(fmap[rn])
xo = res.get("/XObject")
if xo is not None:
for _, x in xo.get_object().items():
ref = x.idnum if isinstance(x, IndirectObject) else id(x)
if ref in seen:
continue
seen.add(ref)
x = x.get_object()
if x.get("/Subtype") == "/Form":
try:
data = x.get_data()
except Exception:
data = b""
walk_resources(x.get("/Resources"), data, seen)
for p in reader.pages:
try:
c = p.get_contents()
data = c.get_data() if c is not None else b""
except Exception:
data = b""
walk_resources(p.get("/Resources"), data, set())
embedded = sorted(n for n, e in fonts.items() if e)
not_embedded = sorted(n for n, e in fonts.items() if not e)
def is_biz(name):
return "BIZ" in name
def is_sym(name):
return "SEGOEUISYMBOL" in re.sub(r"[-_ ]|#20", "", name).upper()
def allowed(name):
return is_biz(name) or is_sym(name)
print("/FontFile2 出現回数(生の PDF):", raw.count(b"/FontFile2"))
print("埋め込みフォント:", embedded or "なし")
print("非埋め込みのフォント:", not_embedded or "なし",
"(reportlab は使わない Helvetica を1つ書き出す。本文で使っていなければ可)")
print("本文で使っているフォント:", sorted(used) or "(Tf を見つけられなかった)")
print("許可するフォント: BIZ UDGothic(本文)/ Segoe UI Symbol(BIZ UDGothic に字の無い記号だけ)")
if not [f for f in embedded if is_biz(f)]:
print(" ★NG★ BIZ UDGothic が埋め込まれていない")
ng.append("BIZ UDGothic が埋め込まれていない")
bad_emb = [f for f in embedded if not allowed(f)]
if bad_emb:
print(" ★NG★ BIZ UDGothic・Segoe UI Symbol 以外のフォントが埋め込まれている:", bad_emb)
ng.append("許可していない埋め込みフォント %s" % bad_emb)
bad_used = sorted(f for f in used if not allowed(f))
if bad_used:
print(" ★NG★ 本文で BIZ UDGothic・Segoe UI Symbol 以外のフォントを使っている:", bad_used)
ng.append("本文で許可していないフォント %s" % bad_used)
# 生の PDF の /BaseFont・/FontName・/FontFamily も見る(リソースを辿れなかった
# フォントの取りこぼしの保険)。生のバイト列全体を探すと、埋め込んだフォントの中身に
# 偶然含まれる文字列("LiSu" など)で誤検出するので、名前の位置に限る。
# 名前の中の空白は PDF では #20 になるので、#20 を詰めて比べる。
raw_names = set(m.decode("latin-1").replace("#20", "") for m in re.findall(
rb"/(?:BaseFont|FontName|FontFamily)\s*[/(]([^\s/\[\]<>()]+)", raw))
all_names = raw_names | set(fonts)
cn_hit = sorted(set(n for n in CN for f in all_names if n.lower() in f.lower()))
print("中国語向けフォント:", cn_hit if cn_hit else "混入なし")
if cn_hit:
print(" ★NG★ 中国語向けフォントが混ざっている(CLAUDE.md §0-3)")
ng.append("中国語向けフォント %s" % cn_hit)
print("BIZ UDGothic のうち Bold:", [f for f in embedded if is_biz(f) and "Bold" in f] or "なし",
" Regular:", [f for f in embedded if is_biz(f) and "Bold" not in f] or "なし")
print("Segoe UI Symbol:", [f for f in embedded if is_sym(f)] or "なし")
# ---- 1-b. 日本語の文字を描いているフォントが BIZ UDGothic だけであること
def is_ja(ch):
"""CJK 統合漢字(拡張A・互換漢字・拡張B 以降を含む)・ひらがな・カタカナ。"""
o = ord(ch)
return (0x4E00 <= o <= 0x9FFF or 0x3400 <= o <= 0x4DBF or 0xF900 <= o <= 0xFAFF
or 0x20000 <= o <= 0x3FFFF or 0x3040 <= o <= 0x30FF or 0x31F0 <= o <= 0x31FF
or 0xFF66 <= o <= 0xFF9F)
drawn = {} # BaseFont 名 → {描いた文字: 回数}(ページの描画命令から)
def _visit(txt, cm, tm, font_dict, font_size):
if not txt or font_dict is None:
return
try:
name = str(font_dict.get("/BaseFont", "?")).lstrip("/")
except Exception:
name = "?"
d = drawn.setdefault(name, {})
for ch in txt:
if ch not in "\n\r\t ":
d[ch] = d.get(ch, 0) + 1
visit_err = None
for p in reader.pages:
try:
p.extract_text(visitor_text=_visit)
except Exception as e:
visit_err = e
if visit_err is not None:
print(" ★注意★ 描画命令の読み取りで例外:", visit_err)
print("描画命令から見た、フォントごとの描いた文字種:",
", ".join("%s %d種" % (n, len(d)) for n, d in sorted(drawn.items())) or "(取れなかった)")
if not drawn:
print(" ★NG★ 描画命令からフォントと文字の対応を取れなかった(1-b を確かめられない)")
ng.append("描画命令からフォントと文字の対応を取れない")
ja_by_other = {}
for n, d in drawn.items():
if is_biz(n):
continue
js = sorted(c for c in d if is_ja(c))
if js:
ja_by_other[n] = js
if ja_by_other:
for n, js in ja_by_other.items():
print(" ★NG★ %s が日本語の文字を描いている(%d種): %s" % (n, len(js), "".join(js[:40])))
ng.append("BIZ UDGothic 以外のフォントが日本語の文字を描いている %s" % sorted(ja_by_other))
else:
print("日本語の文字(漢字・かな)を描いているフォント:",
sorted(n for n, d in drawn.items() if any(is_ja(c) for c in d)) or "なし",
"→ BIZ UDGothic だけ")
# 補助フォントの埋め込みサブセットの ToUnicode に、日本語の文字が無いこと(保険)
RE_HEX = re.compile(rb"<([0-9A-Fa-f]+)>")
def tounicode_chars(fobj):
"""フォントの ToUnicode CMap が対応づけている Unicode の文字の集合。"""
f = fobj.get_object()
tu = f.get("/ToUnicode")
if tu is None:
return set()
data = tu.get_object().get_data()
out = set()
def u(hexs):
try:
return bytes.fromhex(hexs.decode()).decode("utf-16-be", errors="ignore")
except Exception:
return ""
for blk in re.findall(rb"beginbfchar(.*?)endbfchar", data, re.S):
hx = RE_HEX.findall(blk)
for j in range(1, len(hx), 2):
out.update(u(hx[j]))
for blk in re.findall(rb"beginbfrange(.*?)endbfrange", data, re.S):
for line in blk.splitlines():
hx = RE_HEX.findall(line)
if len(hx) >= 3 and b"[" not in line:
lo, hi, dst = int(hx[0], 16), int(hx[1], 16), u(hx[2])
if dst and hi - lo < 0x10000:
base = ord(dst[-1])
for k in range(hi - lo + 1):
out.add(chr(base + k))
elif b"[" in line:
for h in RE_HEX.findall(line[line.index(b"["):]):
out.update(u(h))
return out
sym_subset = {}
seen_fonts = set()
for p in reader.pages:
res = p.get("/Resources")
fd = res.get_object().get("/Font") if res is not None else None
if fd is None:
continue
for _, fobj in fd.get_object().items():
ref = fobj.idnum if isinstance(fobj, IndirectObject) else id(fobj)
if ref in seen_fonts:
continue
seen_fonts.add(ref)
name, _ = font_info(fobj)
if not is_biz(name):
sym_subset.setdefault(name, set()).update(tounicode_chars(fobj))
for n, cs in sorted(sym_subset.items()):
js = sorted(c for c in cs if is_ja(c))
print("BIZ 以外のフォント %s のサブセット: %d 字 / うち日本語の文字 %d"
% (n, len(cs), len(js)))
if js:
print(" ★NG★ サブセットに日本語の文字がある:", "".join(js[:40]))
ng.append("%s のサブセットに日本語の文字 %d 字" % (n, len(js)))
# ---------------------------------------------------------------- 2.
print("\n== 2. 見出しがすべて入っていること ==")
h_found, h_missing = find_in_order(headings, flat_nofoot)
print("見出し: 原稿 %d(%s)/ PDF で原稿の順に見つかった %d"
% (len(headings),
" + ".join("%s:%d" % ("#" * lv, n) for lv, n in lv_count.items() if n),
h_found))
for ln, lv, _, s in h_missing[:MAX_LIST]:
if has(s):
print(" ★NG★ %d行の見出し(%s)は PDF にあるが、順序が原稿と違う: %s"
% (ln, "#" * lv, s))
else:
print(" ★NG★ %d行の見出し(%s)が PDF に無い: %s" % (ln, "#" * lv, s))
if len(h_missing) > MAX_LIST:
print(" …ほか %d 件" % (len(h_missing) - MAX_LIST))
if h_missing:
ng.append("見出しの欠落・順序違い %d 件(原稿 %d)" % (len(h_missing), len(headings)))
# ---------------------------------------------------------------- 3.
print("\n== 3. 本文の取りこぼしが無いこと(段落・表のセル・箇条書き・引用) ==")
b_found, b_missing = find_in_order(body, flat_nofoot)
by_kind = {}
for _, kind, _, s in body:
if sq(s):
by_kind[kind] = by_kind.get(kind, 0) + 1
print("照合した行: %d(%s)/ 見つかった %d / 見つからない %d"
% (sum(by_kind.values()),
" / ".join("%s %d" % kv for kv in sorted(by_kind.items())),
b_found, len(b_missing)))
for ln, kind, cells, s in b_missing[:MAX_LIST]:
where = "順序違い" if has(s) else "欠落"
print(" ★NG★ %d行(%s・%s): %s" % (ln, kind, where, s[:90]))
if cells is not None and where == "欠落":
lost_cells = [(ci, c) for ci, c in enumerate(cells, 1) if sq(c) and not has(c)]
for ci, c in lost_cells:
print(" %d列目のセルが PDF に無い: %s" % (ci, c[:80]))
if not lost_cells:
print(" (セルは1つずつなら全部 PDF にある。並び・繋がりが原稿と違う)")
if len(b_missing) > MAX_LIST:
print(" …ほか %d 行" % (len(b_missing) - MAX_LIST))
if b_missing:
ng.append("本文の欠落・順序違い %d 行" % len(b_missing))
md_chars = len(sq("".join(s for _, _, _, s in body)))
print("記法を外した原稿の本文の文字数 %d / PDF(フッター除く)の文字数 %d"
"(PDF には見出し・コード・箇条書きの印・繰り返した表の見出し行も入る)"
% (md_chars, len(flat_nofoot)))
# ---------------------------------------------------------------- 4.
print("\n== 4. コードブロック(Mermaid を含む)の中身 ==")
variants_used = {"そのまま": 0, "<br/> を外した形": 0}
def code_forms(s):
yield "そのまま", s
yield "<br/> を外した形", RE_BR.sub("", s)
pos = 0
c_bad = []
n_code_lines = 0
for bi, blk in enumerate(codes, 1):
miss = []
found = 0
for ln, line in blk["lines"]:
if not sq(line):
continue
n_code_lines += 1
hit = None
for label, form in code_forms(line):
loc = locate(form, flat_nofoot, pos)
if loc is not None:
hit = (label, loc)
break
if hit:
variants_used[hit[0]] += 1
pos = hit[1][1]
found += 1
else:
miss.append((ln, line))
total = len([1 for _, l in blk["lines"] if sq(l)])
mark = "OK" if not miss else "★NG★"
print(" コード%-3d %4d行目 %-8s 原稿 %3d 行 / PDF %3d 行 %s"
% (bi, blk["ln"], blk["lang"] or "(なし)", total, found, mark))
for ln, line in miss[:20]:
where = "順序違い" if any(has(f) for _, f in code_forms(line)) else "欠落"
print(" ★NG★ %d行(%s): %s" % (ln, where, line.strip()[:90]))
if len(miss) > 20:
print(" …ほか %d 行" % (len(miss) - 20))
if miss:
c_bad.append(bi)
print("コードブロック: 原稿 %d(うち Mermaid %d)/ 中身が全部 PDF にあった %d"
% (len(codes), len(mermaids), len(codes) - len(c_bad)))
print("Mermaid: 原稿 %d / 中身が全部 PDF にあった %d"
% (len(mermaids), len([1 for bi, b in enumerate(codes, 1)
if b["lang"] == "mermaid" and bi not in c_bad])))
print("照合したコードの行 %d。見つかった形: %s"
% (n_code_lines, " / ".join("%s %d" % kv for kv in variants_used.items())))
if c_bad:
ng.append("中身が欠けたコードブロック %d 個(コード%s)"
% (len(c_bad), ",".join(map(str, c_bad))))
# ---------------------------------------------------------------- 5.
print("\n== 5. しおり(PDF のアウトライン) ==")
exp_bm = [h for h in headings if h[1] in (2, 3)]
bms = [] # (深さ, 題名, ページ番号 or None)
def walk_outline(items, depth):
for it in items:
if isinstance(it, list):
walk_outline(it, depth + 1)
continue
try:
pn = reader.get_destination_page_number(it)
except Exception:
pn = None
if pn is not None and pn < 0:
pn = None
bms.append((depth, str(getattr(it, "title", "") or ""), pn))
try:
walk_outline(reader.outline, 1)
except Exception as e:
print(" ★NG★ しおりを読めなかった:", e)
ng.append("しおりを読めない")
print("しおり: PDF %d / 期待 %d(原稿の ## %d + ### %d)"
% (len(bms), len(exp_bm), lv_count[2], lv_count[3]))
depths = sorted(set(d for d, _, _ in bms))
print("しおりの階層:", ", ".join("深さ%d: %d 個" % (d, sum(1 for x in bms if x[0] == d))
for d in depths) or "なし")
if len(bms) != len(exp_bm):
print(" ★NG★ しおりの数が ## と ### の数と合わない")
ng.append("しおりの数 %d(期待 %d)" % (len(bms), len(exp_bm)))
no_page = [(i, t) for i, (_, t, pn) in enumerate(bms, 1) if pn is None]
for i, t in no_page[:MAX_LIST]:
print(" ★NG★ しおり%d の宛先ページが無い: %s" % (i, t[:60]))
if no_page:
ng.append("宛先ページの無いしおり %d 個" % len(no_page))
# 題名・階層の突き合わせ(参考。生成側が題名を縮めることもあるので ★注意★ に留める)
mism = 0
base = min(depths) if depths else 1
for (ln, lv, _, s), (d, t, _) in zip(exp_bm, bms):
if sq(t) != sq(s) or (d - base) != (lv - 2):
mism += 1
if mism <= 20:
print(" ★注意★ %d行の %s「%s」⇔ しおり(深さ%d)「%s」"
% (ln, "#" * lv, s[:40], d, t[:40]))
if mism > 20:
print(" …ほか %d 件" % (mism - 20))
print("題名・階層が原稿と食い違うしおり:", mism if mism else "なし")
# しおりのページが原稿の順に並んでいること
pns = [pn for _, _, pn in bms if pn is not None]
back = sum(1 for a, b in zip(pns, pns[1:]) if b < a)
if back:
print(" ★注意★ しおりのページが前に戻る箇所が %d 個ある" % back)
# ---------------------------------------------------------------- 6.
print("\n== 6. 章内リンク ==")
# 6-a. 原稿側 — #.. が原稿の見出しに実在するか(GitHub のアンカーの付け方)
slugs = {}
for ln, lv, rawh, _ in headings:
base_slug = gh_slug(rawh)
sl = base_slug
n = 0
while sl in slugs:
n += 1
sl = "%s-%d" % (base_slug, n)
slugs[sl] = ln
bad_md = [(ln, t, a) for ln, t, a in md_links if a not in slugs]
print("原稿の章内リンク [..](#..): %d 個(宛先の見出し %d 種)"
% (len(md_links), len(set(a for _, _, a in md_links))))
for ln, t, a in bad_md:
print(" ★NG★ %d行のリンク「%s」の宛先 #%s が原稿の見出しに無い" % (ln, md_plain(t), a))
if bad_md:
ng.append("原稿の章内リンクの宛先が見出しに無い %d 個" % len(bad_md))
# 6-b. PDF 側 — リンク注釈を集める
page_ids = {}
for i, p in enumerate(reader.pages):
if p.indirect_reference is not None:
page_ids[p.indirect_reference.idnum] = i
def dest_page(arr):
"""宛先の配列 [ページ /XYZ …] からページ番号(0始まり)を返す。無ければ None。"""
arr = arr.get_object() if isinstance(arr, IndirectObject) else arr
if isinstance(arr, DictionaryObject):
arr = arr.get("/D")
arr = arr.get_object() if isinstance(arr, IndirectObject) else arr
if not isinstance(arr, (list, ArrayObject)) or not arr:
return None
first = arr[0]
if isinstance(first, IndirectObject):
return page_ids.get(first.idnum)
try:
n = int(first)
return n if 0 <= n < len(reader.pages) else None
except Exception:
return None
def dkey(v):
return str(v.get_object() if isinstance(v, IndirectObject) else v).lstrip("/")
named = {} # 宛先の名前 → ページ番号 or None
root = reader.trailer["/Root"].get_object()
if "/Dests" in root: # 古い形(カタログの /Dests 辞書)
for k, v in root["/Dests"].get_object().items():
named[dkey(k)] = dest_page(v)
def walk_name_tree(node):
node = node.get_object()
if "/Names" in node:
arr = node["/Names"]
for j in range(0, len(arr) - 1, 2):
named[dkey(arr[j])] = dest_page(arr[j + 1])
for kid in node.get("/Kids", []):
walk_name_tree(kid)
if "/Names" in root and "/Dests" in root["/Names"].get_object():
walk_name_tree(root["/Names"].get_object()["/Dests"])
print("PDF の名前付き宛先: %d 個" % len(named))
internal = [] # (ページ番号, 宛先の表示, 飛び先ページ or None)
external = 0
other = 0
for pi, p in enumerate(reader.pages):
for a in (p.get("/Annots") or []):
a = a.get_object()
if a.get("/Subtype") != "/Link":
continue
dest = a.get("/Dest")
act = a.get("/A")
act = act.get_object() if act is not None else None
if dest is None and act is not None:
s = act.get("/S")
if s == "/GoTo":
dest = act.get("/D")
elif s == "/URI":
external += 1
continue
else:
other += 1
continue
if dest is None:
other += 1
continue
d = dest.get_object() if isinstance(dest, IndirectObject) else dest
if isinstance(d, (list, ArrayObject)):
internal.append((pi + 1, "(ページ直指定)", dest_page(d)))
else:
name = dkey(d)
internal.append((pi + 1, name, named.get(name) if name in named else None))
print("リンク注釈: 文書内(GoTo / 名前付き宛先)%d / 外部(URI)%d / その他 %d"
% (len(internal), external, other))
print("文書内リンク: PDF %d / 期待 %d 以上(原稿の [..](#..) の数)"
% (len(internal), len(md_links)))
if len(internal) < len(md_links):
print(" ★NG★ 文書内リンクが原稿の章内リンクより少ない")
ng.append("文書内リンク %d(期待 %d 以上)" % (len(internal), len(md_links)))
broken = [(pg, nm) for pg, nm, tp in internal if tp is None]
for pg, nm in broken[:MAX_LIST]:
print(" ★NG★ p%d のリンクの宛先が実在しない: %s" % (pg, nm))
if broken:
ng.append("宛先が実在しない文書内リンク %d 個" % len(broken))
else:
print("宛先: 全部実在する(%d 個)" % len(internal))
# ---------------------------------------------------------------- 7.
print("\n== 7. 豆腐(グリフ欠け)と、字の無い文字 ==")
if GLYPH_FONTS and SYM_C2G is not None:
# 描いた文字ごとに、描いたフォントに字があること
c2g_r = dict(GLYPH_FONTS)["JP"]
c2g_b = dict(GLYPH_FONTS)["JP-B"]
for n, d in sorted(drawn.items()):
if is_biz(n):
c2g = c2g_b if "Bold" in n else c2g_r
label = "BIZ UDGothic %s" % ("B" if "Bold" in n else "R")
elif is_sym(n):
c2g, label = SYM_C2G, "Segoe UI Symbol"
else:
continue # 許可していないフォントは 1. で NG 済み
miss = sorted(c for c in d if c != "\x00" and ord(c) not in c2g)
print("%-32s %4d 字種 / %s に字が無い文字: %s"
% (n, len(d), label, miss if miss else "なし"))
if miss:
ng.append("%s で描いた文字のうち字が無い %s" % (n, miss))
else:
print(" ★この検査は飛ばした★(reportlab かフォントが無い):", GLYPH_ERR)
# 字の無い文字をそのまま組むと、抽出テキストでは \x00(.notdef)になる
n_nul = text.count("\x00")
tofu = sorted(set(c for c in text if c in "\ufffd□"))
print("豆腐(.notdef。抽出テキストの \\x00): %s" % (("%d 個" % n_nul) if n_nul else "なし"))
for i, t in enumerate(pages, 1):
if "\x00" in t:
k = t.index("\x00")
print(" ★NG★ p%d に豆腐: …%s…" % (i, sq(t[max(0, k - 15):k + 15]).replace("\x00", "□")))
if n_nul:
ng.append("豆腐(.notdef)%d 個" % n_nul)
print("置換文字・豆腐そのものの混入:", tofu if tofu else "なし")
if tofu and not set(tofu) <= set("".join(md_lines)):
ng.append("豆腐 %s" % tofu)
md_chars_set = set(md_plain("".join(
l for l in md_lines if not l.startswith("```")))) - set(" \t#>|-\\")
lost = sorted(md_chars_set - set(text) - set("\u3000\u00a0\ufe0e\ufe0f\u200d"))
print("原稿にあって PDF に1度も出ない文字:", lost if lost else "なし")
if lost:
ng.append("PDF に出ない文字 %s" % lost)
# 字の無い文字(原稿にあって BIZ UDGothic に字が無い文字)が原文のまま出ていること
print("原稿にあって BIZ UDGothic に字が無い文字: %d 種(PDF には原文のまま出ること)"
% len(GLYPHLESS))
by_font = {}
for n, d in drawn.items():
for c in d:
by_font.setdefault(c, set()).add(n)
g_bad = []
for ch in sorted(GLYPHLESS):
n_md = sum(l.count(ch) for l in md_lines)
n_pdf = text.count(ch)
fnames = sorted(by_font.get(ch, []))
kinds = sorted(set("Segoe UI Symbol" if is_sym(f) else
("BIZ UDGothic" if is_biz(f) else f) for f in fnames))
in_sym = SYM_C2G is not None and ord(ch) in SYM_C2G
mark = "OK" if n_pdf >= n_md else "★NG★"
print(" U+%04X %s %-30s 原稿 %2d / PDF %2d 描いたフォント: %s%s %s"
% (ord(ch), ch, unicodedata.name(ch, "?")[:30], n_md, n_pdf,
"・".join(kinds) or "(無し)",
"" if in_sym else "(Segoe UI Symbol にも字が無い)", mark))
if n_pdf < n_md:
g_bad.append(ch)
if g_bad:
ng.append("字の無い文字が原文のまま出ていない %s" % g_bad)
# ---------------------------------------------------------------- 8.
print("\n== 8. 空白ページ ==")
blank = [i for i, t in enumerate(pages, 1) if len(sq(strip_footer(t, i))) < 20]
print("空白ページ:", blank if blank else "なし")
if blank:
ng.append("空白ページ %s" % blank)
# ---------------------------------------------------------------- 結果
print("\n== 結果 ==")
print("ページ数: %d / サイズ: %.1f KB" % (len(reader.pages), len(raw) / 1024))
print("期待値(原稿から数えた): 見出し %d / コードブロック %d(Mermaid %d)/ "
"しおり %d / 章内リンク %d" % (len(headings), len(codes), len(mermaids),
len(exp_bm), len(md_links)))
if ng:
print("★NG★")
for x in ng:
print(" -", x)
else:
print("OK(全項目)")
sys.exit(0 if not ng else 1)