# -*- 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)
