Newer
Older
client_dwatch / docs / handoff / pdf / make_code_map_pdf.py
# -*- coding: utf-8 -*-
"""
ソースコード地図(docs/code-map.md。非エンジニア向け)を PDF にする。

★作り方・体裁は docs/handoff/pdf/README.md の「体裁の決まりごと」をそのまま踏襲する★。
make_teamworks_qa11_pdf.py の写しを改造したもの。引き継いだもの:
  ・フォントは BIZ UDGothic(R / B、subfontIndex=0)。コードの枠も同じ(等幅)。
    中国語向けフォント(SimSun / Microsoft YaHei / Noto Sans SC / STSong 等)は
    使わない・フォールバックにも混ぜない(CLAUDE.md §0-3)
    ★補助フォントとして Segoe UI Symbol(seguisym.ttf)を、BIZ UDGothic に字形が無い
    記号(‹ › ≥ ⚙ ⚠ ✅ ✗ ❌ 🌙 🏢 📅 📈 📊 📋 📐 🔒 🔔 🔧 🔶)にだけ使う★(下記)
  ・A4 縦 / 余白 左右上 18mm・下 20mm / フッター(左「D-Watch / DFA Robotics」・
    右ページ番号・0.4pt の罫線)/ 配色 / 文字サイズ(st() で定義)
  ・日本語の行折り返し(cjk_frag_split / dumb_split の差し替え。ASCII の語の途中で割らない)
  ・★で囲まれた範囲を太字にし、★は出さない(resolve_stars())
  ・`コード` のバッククォートは記法として外し、中身はそのまま出す
  ・段落内の改行の規則(前の行が「。」「)」等で終わるときだけ改行。join_lines())
  ・★対応外の記法が出たら黙って落とさず、行番号つきで例外で止まる★

  原稿(既定): docs/code-map.md。第1引数で別の原稿を渡せる。
  出力: docs/handoff/pdf/DWatch_ソースコード地図_20261005.pdf
        環境変数 DWATCH_PDF_OUT で出力先ディレクトリを変えられる。

★既存スクリプトから足した対応★
  1. 見出しの対応: # → 表題(17pt)/ ## → 章見出し(12.5pt。★章ごとに改ページ★)/
     ### → 節見出し(10.5pt)/ #### → 小見出し(9.8pt)。設問の帯(qhead)は外した
  2. PDF のしおり(アウトライン): ## を1段目、### を2段目にする(DocT.afterFlowable)
  3. 章内リンク [文字](#アンカー) をクリックできるリンクにする。アンカー名は GitHub と
     同じ規則で見出し(## 〜 ####)から作る(小文字化 → Unicode 分類 P / S の文字を
     「-」と空白以外除く → 空白を「-」に。同名は -1, -2 … を付ける)。
     ★GitHub に合わせて「_」(分類 Pc)は残す★。リンク先の見出しが無ければ例外で止まる。
     PDF 内の宛先名は ASCII の h1, h2 … に置き換える(日本語の宛先名を避けるため)
  4. ファイルへのリンク [文字](パス) は文字だけを出す(PDF の中では開けないため)
  5. 入れ子の箇条書き: 親の項目の本文の桁以上に字下げされた「- 」「1. 」を1段深い
     箇条書きとして字下げを深くして出す(「1. 」の下の3桁字下げにも対応)。
     入れ子は2段(最上位を含めて3階層。原稿 548行 の「1. 」→「- 」→「- 」)まで。
     それより深いものは例外で止まる。引用(>)の中の箇条書きにも対応
  6. コードの外の <...> はエスケープして文字のまま出す(`<Pagination>` のような
     コンポーネント名のため)。<br> / <br/> だけは改行として扱う(表のセルで使用)
  7. 表: 列幅の自動計算(col_widths)の考え方はそのまま。★6列以上の表はセルを 7.6pt に
     小さくする★(見出し 7.4pt・余白も少し詰める)。セル内のパスは「/」の直後で
     折り返せるようにし、それでも列幅に収まらない長い英数字は「.」「_」「-」「:」「,」の
     直後か camelCase の大文字の直前で割る(_find_break())。それも無ければ文字で割る。
     ★ページからははみ出させない★。和文の開き括弧(「(」等)の直前でも改行できるようにし、
     行頭に「。」「)」等が来るときは直前の文字ごと次の行へ送る。
     セル内の \\| は | として出す。列数が見出し行と違う行がある表(原稿 1889行)は、
     黙って落とさないよう最も多い列数に揃えて空欄で埋め、警告を出す
  8. コードブロック: 1行を1行の表の行にして、ページをまたいで分割できるようにした。
     長い行は枠の中で折り返す(はみ出させない)
  9. Mermaid の図(```mermaid): 図にはできないので記法のままコードの枠に入れ、
     直前に小さな注記「図(Mermaid 記法)— GitHub や VS Code では図として表示されます」
     を出す。中身は1行も落とさない
  10. ~~取り消し線~~ は既存の対応のまま
  ほか: ★が1つしかない段落・セル(「★印は事故が起きやすい変更」「| ★色を変える |」)は
     ★を「印」として使っているので、太字にせず★をそのまま出す(生成時に行番号を出す)

★BIZ UDGothic に字形が無い文字★
  原稿には BIZ UDGothic に無い記号・絵文字が19種類ある(✅ ❌ ⚠ 📊 等)。
  ★置き換えも削除もせず、その文字だけを Segoe UI Symbol で原文どおり描く★
  (sym_wrap() が <font name="SYM"> で包む。段落・表のセル・コード枠のすべて)。
  Segoe UI Symbol は漢字の字形を1つも持たない(実測0個)ので、漢字が中国語の字形で
  出ることはない。太字が無いので、太字の範囲でも同じ字形を使う。
  U+FE0F(異体字セレクタ。目に見えない制御文字で、どちらのフォントにも無い)だけは取り除く。
  どちらも生成時に行番号つきで一覧を出す。両方のフォントに無い文字が出たら例外で止まる。
"""
import os
import re
import sys
import unicodedata

from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.fonts import addMapping
from reportlab.lib.textsplit import ALL_CANNOT_START, ALL_CANNOT_END, getCharWidths
from reportlab.platypus import (
    BaseDocTemplate, CondPageBreak, Frame, KeepTogether, PageBreak, PageTemplate,
    Paragraph, Spacer, Table, TableStyle,
)
from reportlab.platypus.flowables import HRFlowable
import reportlab.platypus.paragraph as rl_para

try:
    sys.stdout.reconfigure(errors="backslashreplace")  # cp932 で落ちないように
except Exception:
    pass

HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.normpath(
    sys.argv[1] if len(sys.argv) > 1 else
    os.path.join(HERE, "..", "..", "code-map.md"))

FONT_DIR = "C:/Windows/Fonts/"
FONT_R = TTFont("JP", FONT_DIR + "BIZ-UDGothicR.ttc", subfontIndex=0)
FONT_B = TTFont("JP-B", FONT_DIR + "BIZ-UDGothicB.ttc", subfontIndex=0)
# 補助フォント: BIZ UDGothic に字形が無い記号(✅ ⚠ 📅 等)だけをこれで描く。
# ★Segoe UI Symbol は漢字の字形を1つも持たない★(実測0個)ので、漢字は必ず BIZ UDGothic で
# 描かれ、中国語の字形が混ざることはない(CLAUDE.md §0-3 の趣旨に沿う)。
FONT_SYM = TTFont("SYM", FONT_DIR + "seguisym.ttf")
pdfmetrics.registerFont(FONT_R)
pdfmetrics.registerFont(FONT_B)
pdfmetrics.registerFont(FONT_SYM)
# <b> タグを太字フォントへ対応づける。
# 見出し(もともと JP-B)の中に ★ 由来の <b> が来ても落ちないよう JP-B 側も登録する。
addMapping("JP", 0, 0, "JP")
addMapping("JP", 1, 0, "JP-B")
addMapping("JP", 0, 1, "JP")
addMapping("JP", 1, 1, "JP-B")
addMapping("JP-B", 0, 0, "JP-B")
addMapping("JP-B", 1, 0, "JP-B")
addMapping("JP-B", 0, 1, "JP-B")
addMapping("JP-B", 1, 1, "JP-B")
# Segoe UI Symbol には太字が無いので、太字の範囲でも同じ字形を使う
for _b in (0, 1):
    for _i in (0, 1):
        addMapping("SYM", _b, _i, "SYM")

# 出力先。既定はこのスクリプトと同じディレクトリ。
# 環境変数 DWATCH_PDF_OUT で変えられる(例: Downloads へ出す)。
OUT_DIR = os.environ.get("DWATCH_PDF_OUT", HERE)
OUT = os.path.join(OUT_DIR, "DWatch_ソースコード地図_20261005.pdf")

# 第1回 PDF と同一のパレット
INK = colors.HexColor("#1e293b")
SUB = colors.HexColor("#475569")
MUT = colors.HexColor("#94a3b8")
LINE = colors.HexColor("#cbd5e1")
HEAD_BG = colors.HexColor("#f1f5f9")
CODE_BG = colors.HexColor("#f8fafc")
LINK = "#1d4ed8"   # 章内リンクの文字色(この文書で新たに使う。クリックできると分かるように)

ss = getSampleStyleSheet()


def st(name, size, leading, font="JP", color=INK, space_after=0, space_before=0,
       left=0, first=0):
    """段落スタイルを作る。left/first でぶら下げインデントを表現する。"""
    s = ParagraphStyle(
        name, parent=ss["Normal"], fontName=font, fontSize=size, leading=leading,
        textColor=color, alignment=TA_LEFT, spaceAfter=space_after,
        spaceBefore=space_before, wordWrap="CJK",
        leftIndent=left, firstLineIndent=first,
    )
    s.bulletFontName = font
    return s


# ★文字サイズ・行送り・色は第9回と同一★(6列以上の表・Mermaid の注記・入れ子だけ追加)
S_TITLE = st("t", 17, 24, "JP-B", INK, 2)
S_SUB = st("s", 9.5, 15, "JP", SUB, 0)
S_H2 = st("h2", 12.5, 18, "JP-B", INK, 4, 10)
S_H3 = st("h3", 10.5, 16, "JP-B", INK, 3, 8)
S_H4 = st("h4", 9.8, 15, "JP-B", SUB, 2, 6)
# 見出しは直後の要素と同じページに置く(ページ末に見出しだけ残さない)
for _s in (S_H2, S_H3, S_H4):
    _s.keepWithNext = 1
S_BODY = st("b", 9.5, 15.5, "JP", INK, 4)
# 箇条書き(深さ 0 / 1 / 2)。「1. 」「・」のぶら下げ。1段深くなるごとに15pt 字下げする
S_LIST = [st("li%d" % d, 9.5, 15.5, "JP", INK, 2, 0, 15 + 15 * d, -15) for d in range(3)]
S_BULL = [st("bu%d" % d, 9.5, 15.5, "JP", INK, 2, 0, 14 + 15 * d, -14) for d in range(3)]
BULLET_MARK = ["・", "・", "・"]   # 深さは字下げで表す(「◦」は字幅が狭く、ぶら下げが揃わない)
S_CELL = st("c", 8.8, 13.8, "JP", INK)
S_TH = st("th", 8.4, 12, "JP-B", SUB)
S_CELL_S = st("cs", 7.6, 11.6, "JP", INK)    # 6列以上の表のセル
S_TH_S = st("ths", 7.4, 10.8, "JP-B", SUB)   # 6列以上の表の見出し
WIDE_TABLE = 6                               # この列数以上で小さい文字にする
S_CODE = st("cd", 8.2, 12.6, "JP", INK)
S_QUOTE = st("q", 9.2, 14.8, "JP", SUB)
S_QUOTE_P = st("qp", 9.2, 14.8, "JP", SUB, 4)            # 引用内の段落(2段落目以降の前に間)
S_QUOTE_B = [st("qb%d" % d, 9.2, 14.8, "JP", SUB, 1, 0, 13 + 14 * d, -13) for d in range(3)]
S_QUOTE_L = [st("ql%d" % d, 9.2, 14.8, "JP", SUB, 1, 0, 15 + 15 * d, -15) for d in range(3)]
S_MER = st("mer", 8, 12, "JP", SUB, 2)       # Mermaid の図の注記
S_MER.keepWithNext = 1
MERMAID_NOTE = "図(Mermaid 記法)— GitHub や VS Code では図として表示されます"


# ------------------------------------------------ ASCII の語の途中で割らない折り返し
def _is_break(ch):
    """この文字の直後で行を切ってよいか。

    空白・和文字(行末禁則でないもの)に加えて、★「/」の直後★も切ってよいとする
    (表のセルに入るファイルパスを「lib/judge/」「kpi.ts」のように割るため)。
    """
    return (unicodedata.category(ch) == "Zs" or ch == "/" or
            (ord(ch) >= 0x3000 and ch not in ALL_CANNOT_END))


SECOND_BREAK = set("._-:,")   # 上の区切りが1つも無いときだけ、この文字の直後で切る


def _find_break(chars, i, lineStartPos):
    """行あふれの位置 i から行頭まで遡り、直後で切ってよい文字の位置を返す。

    まず _is_break() の区切り(と、和文の開き括弧「(」「「」等の直前)を探し、
    無ければ SECOND_BREAK(「.」「_」等)の直後か camelCase の大文字の直前を探す
    (`clean_task_reports.clean_area` や `consecutiveProblemDaysAtPeriodEnd()` のような
    1語が列幅に収まらないとき、文字の途中ではなく語の切れ目で割るため)。どちらも無ければ None
    (呼び出し側が標準どおり文字で割る)。
    """
    def primary(j):
        cj = chars[j]
        if _is_break(cj):
            return True
        nx = chars[j + 1]   # j <= i - 2 なので必ずある
        return (nx and ord(nx) >= 0x3000 and nx in ALL_CANNOT_END
                and cj not in ALL_CANNOT_END)

    def second(j):
        cj, nx = chars[j], chars[j + 1]
        return (cj in SECOND_BREAK or
                (cj.isascii() and nx.isascii() and (cj.islower() or cj.isdigit())
                 and nx.isupper()))   # camelCase の大文字の直前

    for test in (primary, second):
        for j in range(i - 2, lineStartPos, -1):
            if chars[j] and test(j):
                return j
    return None


def cjk_frag_split(frags, maxWidths, calcBounds, encoding="utf8"):
    """reportlab の cjkFragSplit の改変版。

    違い: 行あふれの位置が ASCII のとき、区切り(空白 / 和文字 / 「/」)を
    ★行頭まで★遡って探す(標準は行の半分まで)。和文字の直後で切るときは、
    行末に置けない文字(「(」等)の直後は避けてさらに遡る。
    区切りが1つも無い(1行に収まらない長い語)ときは「.」「_」等の直後で、
    それも無ければ標準どおり文字で割る(_find_break())。
    """
    U = []
    for f in frags:
        text = f.text
        if rl_para.isBytes(text):
            text = text.decode(encoding)
        if text:
            U.extend([rl_para.cjkU(t, f, encoding) for t in text])
        else:
            U.append(rl_para.cjkU(text, f, encoding))
    lines = []
    i = widthUsed = lineStartPos = 0
    maxWidth = maxWidths[0]
    nU = len(U)
    while i < nU:
        u = U[i]
        i += 1
        w = u.width
        if hasattr(w, "normalizedValue"):
            w._normalizer = maxWidth
            w = w.normalizedValue(maxWidth)
        widthUsed += w
        lineBreak = hasattr(u.frag, "lineBreak")
        endLine = (widthUsed > maxWidth + rl_para._FUZZ and widthUsed > 0) or lineBreak
        if endLine:
            extraSpace = maxWidth - widthUsed
            if not lineBreak:
                if u and ord(u) < 0x3000:
                    j = _find_break(U, i, lineStartPos)
                    if j is not None:
                        k = j + 1
                        if k < i:
                            j = k + 1
                            extraSpace += sum(U[ii].width for ii in range(j, i))
                            w = U[k].width
                            u = U[k]
                            i = j
                if u not in ALL_CANNOT_START and i > lineStartPos + 1:
                    i -= 1
                    extraSpace += w
                # 行末に置けない文字(「(」等)で終わるなら、その文字を次の行へ送る
                if i - 1 > lineStartPos + 1 and U[i - 1] in ALL_CANNOT_END:
                    i -= 1
                    extraSpace += U[i].width
                # 次の行が行頭に置けない文字(「。」「)」等)で始まるなら、
                # 直前の文字ごと次の行へ送る(「…終了)」「。判定には」のように割れるのを防ぐ)
                while i < nU and U[i] in ALL_CANNOT_START and i - 1 > lineStartPos + 1:
                    i -= 1
                    extraSpace += U[i].width
            lines.append(rl_para.makeCJKParaLine(
                U[lineStartPos:i], maxWidth, widthUsed, extraSpace, lineBreak,
                calcBounds))
            try:
                maxWidth = maxWidths[len(lines)]
            except IndexError:
                maxWidth = maxWidths[-1]
            lineStartPos = i
            widthUsed = 0
    if widthUsed > 0:
        lines.append(rl_para.makeCJKParaLine(
            U[lineStartPos:], maxWidth, widthUsed, maxWidth - widthUsed, False,
            calcBounds))
    return rl_para.ParaLines(kind=1, lines=lines)


def dumb_split(word, widths, maxWidths):
    """reportlab の textsplit.dumbSplit の改変版(装飾の無い段落はこちらを通る)。

    改変点は cjk_frag_split() と同じ(行頭まで遡る / 「/」「.」「_」等の直後でも切る / 行末禁則)。
    """
    lines = []
    i = widthUsed = lineStartPos = 0
    maxWidth = maxWidths[0]
    nW = len(word)
    while i < nW:
        w = widths[i]
        c = word[i]
        widthUsed += w
        i += 1
        if widthUsed > maxWidth + rl_para._FUZZ and widthUsed > 0:
            extraSpace = maxWidth - widthUsed
            if ord(c) < 0x3000:
                j = _find_break(word, i, lineStartPos)
                if j is not None:
                    k = j + 1
                    if k < i:
                        j = k + 1
                        extraSpace += sum(widths[j:i])
                        w = widths[k]
                        c = word[k]
                        i = j
            if c not in ALL_CANNOT_START and i > lineStartPos + 1:
                i -= 1
                extraSpace += w
            if i - 1 > lineStartPos + 1 and word[i - 1] in ALL_CANNOT_END:
                i -= 1
                extraSpace += widths[i]
            while i < nW and word[i] in ALL_CANNOT_START and i - 1 > lineStartPos + 1:
                i -= 1
                extraSpace += widths[i]
            lines.append([extraSpace, word[lineStartPos:i].strip()])
            try:
                maxWidth = maxWidths[len(lines)]
            except IndexError:
                maxWidth = maxWidths[-1]
            lineStartPos = i
            widthUsed = 0
    if widthUsed > 0:
        lines.append([maxWidth - widthUsed, word[lineStartPos:]])
    return lines


def word_split(word, maxWidths, fontName, fontSize, encoding="utf8"):
    """reportlab の textsplit.wordSplit の差し替え(dumb_split を使う)。"""
    if isinstance(word, bytes):
        word = word.decode(encoding)
    return dumb_split(word, getCharWidths(word, fontName, fontSize), maxWidths)


rl_para.cjkFragSplit = cjk_frag_split
rl_para.wordSplit = word_split


# ------------------------------------------------ 絵文字の ToUnicode(文字の抜き出し用)
import reportlab.pdfbase.ttfonts as rl_ttf

_orig_make_tounicode = rl_ttf.makeToUnicodeCMap


def make_tounicode_cmap(fontname, subset):
    """reportlab の makeToUnicodeCMap の補正版。

    reportlab 5.0 は U+10000 以上の文字(📅 等の絵文字)を「<1F4C5>」のように
    5桁の16進で書き出し、PDF の規則(UTF-16BE。サロゲートペアで8桁)に合わない。
    描画は正しいが、PDF から文字を抜き出したとき(点検スクリプト・検索・コピー)に
    その文字が取れなくなるので、UTF-16BE の16進に直して書き出す。
    """
    text = _orig_make_tounicode(fontname, subset)
    if all(v <= 0xFFFF for v in subset):
        return text
    for i, v in enumerate(subset):
        if v > 0xFFFF:
            utf16 = chr(v).encode("utf-16-be").hex().upper()
            text = text.replace("<%02X> <%04X>" % (i, v), "<%02X> <%s>" % (i, utf16))
    return text


rl_ttf.makeToUnicodeCMap = make_tounicode_cmap


# ------------------------------------------------------------------- 字形の無い文字
# BIZ UDGothic に字形が無い記号(‹ › ≥ ⚙ ⚠ ✅ ✗ ❌ 🌙 🏢 📅 📈 📊 📋 📐 🔒 🔔 🔧 🔶)は、
# ★その文字だけ★ Segoe UI Symbol(SYM)で原文どおり描く(置き換え・削除はしない)。
# U+FE0F(異体字セレクタ。目に見えない制御文字。SYM にも無い)だけは取り除く。
# 両方のフォントに無い文字が出たら、黙って落とさず例外で止まる。
DROP_CHARS = {"\ufe0f": "異体字セレクタ(⚠ の後ろ等。目に見えない)"}
SYM_LOG = {}    # Segoe UI Symbol で描いた文字 → 行番号の並び
DROP_LOG = {}   # 取り除いた文字 → 行番号の並び


def has_glyph(ch):
    """BIZ UDGothic(標準・太字の両方)に字形があるか。制御文字(目印)は持っている扱い。"""
    o = ord(ch)
    return o < 32 or (o in FONT_R.face.charToGlyph and o in FONT_B.face.charToGlyph)


def has_sym(ch):
    return ord(ch) in FONT_SYM.face.charToGlyph


def fix_glyphs(text, lineno):
    """字形を確かめる。U+FE0F は取り除き、BIZ に無い文字は SYM にあるかを確かめて記録する。

    どちらのフォントにも無い文字は例外で止まる。文字の中身は変えない
    (SYM で描く指定は、マークアップを組むときに sym_wrap() が付ける)。
    """
    if all(has_glyph(c) for c in text):
        return text
    out = []
    for c in text:
        if has_glyph(c):
            out.append(c)
        elif c in DROP_CHARS:
            DROP_LOG.setdefault(c, []).append(lineno)
        elif has_sym(c):
            out.append(c)
            SYM_LOG.setdefault(c, []).append(lineno)
        else:
            raise SystemExit("%d行: BIZ UDGothic にも Segoe UI Symbol にも字形が無い文字 "
                             "%r(U+%04X %s)" % (lineno, c, ord(c), unicodedata.name(c, "?")))
    return "".join(out)


def sym_wrap(markup):
    """エスケープ済みの文字列のうち、BIZ に字形が無い文字の並びを <font name="SYM"> で包む。"""
    if all(has_glyph(c) for c in markup):
        return markup
    out = []
    run = []
    for c in markup:
        if has_glyph(c):
            if run:
                out.append('<font name="SYM">%s</font>' % "".join(run))
                run = []
            out.append(c)
        else:
            run.append(c)
    if run:
        out.append('<font name="SYM">%s</font>' % "".join(run))
    return "".join(out)


# ------------------------------------------------------------------- 部品
def P(t, s=S_CELL):
    return Paragraph(t, s)


def on_page(canvas, doc):
    """全ページ共通のフッター罫線・ページ番号。第1回の PDF と同一。"""
    canvas.saveState()
    canvas.setFont("JP", 8)
    canvas.setFillColor(MUT)
    canvas.drawString(18 * mm, 12 * mm, "D-Watch / DFA Robotics")
    canvas.drawRightString(A4[0] - 18 * mm, 12 * mm, "%d" % doc.page)
    canvas.setStrokeColor(LINE)
    canvas.setLineWidth(0.4)
    canvas.line(18 * mm, 15.5 * mm, A4[0] - 18 * mm, 15.5 * mm)
    canvas.restoreState()


TBL_STYLE = [
    ("FONTNAME", (0, 0), (-1, -1), "JP"),
    ("BACKGROUND", (0, 0), (-1, 0), HEAD_BG),
    ("LINEBELOW", (0, 0), (-1, 0), 0.6, LINE),
    ("LINEBELOW", (0, 1), (-1, -2), 0.3, LINE),
    ("VALIGN", (0, 0), (-1, -1), "TOP"),
    ("TOPPADDING", (0, 0), (-1, -1), 5),
    ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
    ("LEFTPADDING", (0, 0), (-1, -1), 6),
    ("RIGHTPADDING", (0, 0), (-1, -1), 6),
]
TBL_STYLE_S = TBL_STYLE[:5] + [   # 6列以上の表は上下左右の余白を少し詰める
    ("TOPPADDING", (0, 0), (-1, -1), 4),
    ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
    ("LEFTPADDING", (0, 0), (-1, -1), 4),
    ("RIGHTPADDING", (0, 0), (-1, -1), 4),
]
CELL_PAD = 12     # 左右パディングの合計(pt)
CELL_PAD_S = 8    # 6列以上の表の左右パディングの合計(pt)


def table(rows, widths, small):
    """rows[0] を見出し行とする表。small なら 6列以上の表の文字サイズ。"""
    sth, scell = (S_TH_S, S_CELL_S) if small else (S_TH, S_CELL)
    data = [[P(c, sth) for c in rows[0]]]
    for r in rows[1:]:
        data.append([P(c, scell) for c in r])
    t = Table(data, colWidths=widths, repeatRows=1)
    t.setStyle(TableStyle(list(TBL_STYLE_S if small else TBL_STYLE)))
    return t


def code(lines, width):
    """コードを等幅フォントの枠に入れる。空白は &nbsp; にして桁を保つ。

    ★1行を表の1行にする★(長いコードがページをまたいで分割できるように)。
    1行がはみ出すときは枠の中で折り返す。
    """
    rows = []
    for ln in lines:
        e = ln.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        e = sym_wrap(e.replace(" ", "&nbsp;"))
        rows.append([P(e if e else "&nbsp;", S_CODE)])
    if not rows:
        rows = [[P("&nbsp;", S_CODE)]]
    t = Table(rows, colWidths=[width])
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), "JP"),
        ("BACKGROUND", (0, 0), (-1, -1), CODE_BG),
        ("LINEBEFORE", (0, 0), (0, -1), 1.8, LINE),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("TOPPADDING", (0, 0), (-1, -1), 0),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
        ("TOPPADDING", (0, 0), (-1, 0), 6),
        ("BOTTOMPADDING", (0, -1), (-1, -1), 6),
        ("LEFTPADDING", (0, 0), (-1, -1), 8),
        ("RIGHTPADDING", (0, 0), (-1, -1), 6),
    ]))
    return t


def quote(cells, width):
    """補足・お断りの引用。左に縦罫線を引く。cells は Paragraph の並び(1つ1行)。"""
    t = Table([[c] for c in cells], colWidths=[width])
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), "JP"),
        ("LINEBEFORE", (0, 0), (0, -1), 1.8, MUT),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("TOPPADDING", (0, 0), (-1, -1), 0),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
        ("TOPPADDING", (0, 0), (-1, 0), 3),
        ("BOTTOMPADDING", (0, -1), (-1, -1), 3),
        ("LEFTPADDING", (0, 0), (-1, -1), 8),
        ("RIGHTPADDING", (0, 0), (-1, -1), 0),
    ]))
    return t


# ------------------------------------------------------------------- 見出しのアンカー
def github_slug(text):
    """GitHub と同じ規則で見出しからアンカー名を作る(text は記法を外した見出し)。

    小文字にし、Unicode の分類が P(句読点)または S(記号)の文字を、
    「-」と空白以外すべて除き、空白を「-」にする。
    ★GitHub(github-slugger)は「_」(分類 Pc)を残すので、それに合わせて残す★。
    """
    out = []
    for ch in text.lower():
        if ch in "-_ ":
            out.append(ch)
        elif unicodedata.category(ch).startswith(("P", "S")):
            continue
        else:
            out.append(ch)
    return "".join(out).replace(" ", "-")


ANCHORS = {}   # GitHub のアンカー名 → PDF 内の宛先名(h1, h2 …)


# ------------------------------------------------------------------- 行内の記法
WARN = []   # 生成時の警告(行番号つき)

OPEN_BEFORE = set(" \t\u3000(「『(【〔[[、。,,::・/")
CLOSE_AFTER = set(" \t\u3000)」』)】〕]]、。,,..::;;!!??/")
BR = "\x00"      # 段落内の改行(<br/>)の目印
L_OPEN = "\x01"  # リンクの開き(直後に宛先名、\x03 で終わる。宛先名が空ならファイルへのリンク)
L_KEYEND = "\x03"
L_CLOSE = "\x02"  # リンクの閉じ

TOKEN = re.compile(r"(`[^`]+`|\*\*|~~|★+|\x00|<br\s*/?>|\x01[^\x03]*\x03|\x02)")
RE_LINK = re.compile(r"(`[^`]+`)|(!?)\[((?:`[^`]*`|[^\]`])*)\]\(([^)\s]*)\)")
TRANSPARENT = ("**", "~~", L_CLOSE)   # ★の前後を見るときに読み飛ばすトークン

N_LINKS = [0, 0]   # [章内リンク, ファイルへのリンク]


def mark_links(text, lineno, count=True):
    """[文字](宛先) を目印に置き換える(`コード` の中は触らない)。"""
    def rep(m):
        if m.group(1):
            return m.group(1)
        if m.group(2):
            raise SystemExit("%d行: 画像の記法は対応外" % lineno)
        label, href = m.group(3), m.group(4)
        if href.startswith("#"):
            key = ANCHORS.get(href[1:])
            if key is None:
                raise SystemExit("%d行: リンク先の見出しが無い(%s)" % (lineno, href))
            if count:
                N_LINKS[0] += 1
            return L_OPEN + key + L_KEYEND + label + L_CLOSE
        if count:
            N_LINKS[1] += 1
        return L_OPEN + L_KEYEND + label + L_CLOSE
    return RE_LINK.sub(rep, text)


def esc(s):
    return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


def resolve_stars(parts, toggle=False):
    """★ の各記号が開きか閉じかを決め、(各トークンの深さ, 釣り合ったか) を返す。

    toggle=False … 文脈で決める。
      1. 閉じた直後の ** に続く ★(「**★」)は閉じ
      2. 開く ** の直前の ★(「★**」)は開き
      3. 直後が文末・空白・閉じ括弧・句読点なら閉じ
      4. 直前が行頭・空白・開き括弧・句読点なら開き
      5. どれでもなければ、開いていれば閉じ、開いていなければ開き
    toggle=True  … 出てきた順に開き・閉じを交互にする(入れ子を考えない)。
    リンクの目印は読み飛ばす(★の前後の文字を見るとき)。
    """
    def transparent(p):
        return p in TRANSPARENT or p.startswith(L_OPEN)

    depths = []
    depth = 0
    md_bold = False
    ok = True
    for idx, p in enumerate(parts):
        if p == "**":
            md_bold = not md_bold
            depths.append(depth)
            continue
        if p[0] != "★":
            depths.append(depth)
            continue
        if toggle:
            depth = 0 if depth else 1
            depths.append(depth)
            continue
        prev_tok = parts[idx - 1] if idx > 0 else ""
        next_tok = parts[idx + 1] if idx + 1 < len(parts) else ""
        j = idx - 1
        while j >= 0 and transparent(parts[j]):
            j -= 1
        prev = parts[j][-1] if j >= 0 else ""
        if j >= 0 and parts[j].startswith("`"):
            prev = "a"
        k = idx + 1
        while k < len(parts) and transparent(parts[k]):
            k += 1
        nxt = parts[k][0] if k < len(parts) else ""
        if k < len(parts) and parts[k].startswith("`"):
            nxt = "a"
        if prev_tok == "**" and not md_bold:
            is_close = True
        elif next_tok == "**" and not md_bold:
            is_close = False
        elif nxt in ("", BR) or nxt in CLOSE_AFTER:
            is_close = True
        elif prev in ("", BR) or prev in OPEN_BEFORE:
            is_close = False
        else:
            is_close = depth > 0
        if is_close:
            if depth == 0:
                ok = False
            else:
                depth -= 1
        else:
            depth += 1
        depths.append(depth)
    return depths, ok and depth == 0


STAR_LOG = []   # ★の解釈で文脈判定が釣り合わなかった箇所(行番号, ★の数, 採った方法)


def inline(text, lineno):
    """原稿の行内記法を reportlab のマークアップにする。

    `コード` / **太字** / ~~取り消し線~~ / ★太字★ / [文字](#アンカー) /
    [文字](パス) / <br> を処理する。★ は出さない。バッククォートも記法として外す。
    コードの外の <...>(<br> 以外)はエスケープして文字のまま出す。

    ★の数が釣り合わない段落の扱いは make_teamworks_qa11_pdf.py と同じ
    (偶数 → 交互に開き・閉じ / 奇数 → 閉じ損ねた★以降を段落末まで太字)。
    """
    text = fix_glyphs(mark_links(text, lineno), lineno)
    parts = [p for p in TOKEN.split(text) if p != ""]
    parts = [BR if re.fullmatch(r"<br\s*/?>", p) else p for p in parts]
    depths, ok = resolve_stars(parts)
    if not ok:
        n_star = sum(1 for p in parts if p[0] == "★")
        if n_star == 1:
            # ★が1つだけの段落・セルは、★を「印」として使っている
            # (「★印は事故が起きやすい変更」「| ★色を変える |」等)。太字にせず★をそのまま出す
            depths = [0] * len(parts)
            parts = [p if p[0] != "★" else "\x04" + p for p in parts]
            STAR_LOG.append((lineno, n_star, "1個 → 印として★をそのまま出す"))
        elif n_star % 2 == 0:
            depths, _ = resolve_stars(parts, toggle=True)
            STAR_LOG.append((lineno, n_star, "偶数 → 交互に開き・閉じ"))
        else:
            STAR_LOG.append((lineno, n_star, "奇数 → 閉じ損ねた★以降を段落末まで太字"))
    out = []
    md_bold = False
    strike = False
    link_stack = []
    for idx, p in enumerate(parts):
        if p == "**":
            md_bold = not md_bold
            continue
        if p == "~~":
            strike = not strike
            continue
        if p == BR:
            out.append("<br/>")
            continue
        if p.startswith(L_OPEN):
            key = p[1:-1]
            link_stack.append(key)
            if key:
                out.append('<a href="#%s" color="%s">' % (key, LINK))
            continue
        if p == L_CLOSE:
            if link_stack and link_stack.pop():
                out.append("</a>")
            continue
        if p[0] == "★":
            continue
        if p[0] == "\x04":   # 印として出す★
            out.append(p[1:])
            continue
        if p.startswith("`") and p.endswith("`") and len(p) >= 2:
            s = sym_wrap(esc(p[1:-1]))
        else:
            s = sym_wrap(esc(p))
        if md_bold or depths[idx] > 0:
            s = "<b>%s</b>" % s
        if strike:
            s = "<strike>%s</strike>" % s
        out.append(s)
    if md_bold:
        WARN.append("%d行: ** が閉じていない" % lineno)
    if strike:
        WARN.append("%d行: ~~ が閉じていない" % lineno)
    if link_stack:
        raise SystemExit("%d行: リンクの記法が閉じていない" % lineno)
    return "".join(out)


RE_LINK_PLAIN = re.compile(r"!?\[((?:`[^`]*`|[^\]`])*)\]\([^)\s]*\)")


def plain(text):
    """記法を外した素の文字列(列幅の計測・しおり・アンカー名用)。"""
    t = text.replace(BR, "")
    t = RE_LINK_PLAIN.sub(r"\1", t)
    t = re.sub(r"<br\s*/?>", "\n", t)
    t = re.sub(r"`([^`]+)`", r"\1", t)
    return t.replace("**", "").replace("~~", "").replace("★", "")


SENT_END = ("。", ".", ".", "!", "?", "!", "?", ")", ")")


def join_lines(items):
    """連続行を1段落にまとめる。items は (行番号, 本文)。

    前の行が文末(。 . ) 等)で終わるときは改行の目印を挟み、
    文の途中で折り返している行は詰めて繋ぐ。前の行が ASCII で終わるときだけ空白を挟む。
    行末のリンク [文字](パス) は文字で終わっているものとして判定する。
    """
    out = ""
    for n, (ln, s) in enumerate(items):
        s = s.strip()
        if n == 0:
            out = s
            continue
        tail = re.sub(r"[★*`~\s]+$", "", RE_LINK_PLAIN.sub(r"\1", out))
        if tail.endswith(SENT_END):
            out += BR + s
        elif tail and tail[-1].isascii():
            out += " " + s
        else:
            out += s
    return out


# ------------------------------------------------------------------- ブロックの解析
RE_H = re.compile(r"^(#{1,6}) (.*)$")
RE_UL = re.compile(r"^- (.*)$")
RE_OL = re.compile(r"^(\d+)\. (.*)$")
RE_LI = re.compile(r"^( *)(- |\d+\. )(.*)$")
RE_SEP = re.compile(r"^\|(\s*:?-{3,}:?\s*\|)+\s*$")
RE_HR = re.compile(r"^-{3,}\s*$")
MAX_DEPTH = 2   # 入れ子は深さ 0 / 1 / 2 まで(「1. 」の下の「- 」のさらに下の「- 」まで。原稿 548行 等)


def split_row(line):
    s = line.strip()
    assert s.startswith("|") and s.endswith("|"), line
    return [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", s[1:-1])]


def li_marker(m):
    mk = m.group(2)
    return ("ul", None) if mk == "- " else ("ol", mk.strip())


def parse_list(md_lines, i, n):
    """箇条書きを読む。項目は [深さ, 種類, 番号, [(行番号, 本文)...]]。

    親の項目の本文の桁(「- 」なら字下げ+2、「1. 」なら字下げ+3)以上に字下げされた
    「- 」「1. 」を、1段深い項目とする。それ以外の字下げされた行は直前の項目の続き。
    """
    items = []
    stack = []   # 開いている項目の本文の桁
    while i < n:
        cl = md_lines[i]
        if not cl.strip():
            # 空行の次も字下げされた行なら、同じ箇条書きの続き
            k = i + 1
            while k < n and not md_lines[k].strip():
                k += 1
            if k < n and md_lines[k].startswith(" ") and items:
                i = k
                continue
            break
        m = RE_LI.match(cl)
        if m:
            ind = len(m.group(1))
            if not items and ind > 0:
                raise SystemExit("%d行: 字下げされた箇条書きが親の項目なしに始まる" % (i + 1))
            while stack and ind < stack[-1]:
                stack.pop()
            depth = len(stack)
            if depth > MAX_DEPTH:
                raise SystemExit("%d行: 深さ3以上の入れ子の箇条書きは対応外" % (i + 1))
            stack.append(ind + len(m.group(2)))
            kind, num = li_marker(m)
            items.append([depth, kind, num, [(i + 1, m.group(3))]])
        elif cl.startswith((" ", "\t")) and items:
            items[-1][3].append((i + 1, cl.strip()))   # 項目の続きの行
        else:
            break
        i += 1
    return items, i


def parse(md_lines):
    """原稿をブロックの並びにする。対応外の記法は例外で止める(取りこぼし防止)。"""
    blocks = []
    i = 0
    n = len(md_lines)
    while i < n:
        line = md_lines[i]
        ln = i + 1
        if not line.strip():
            i += 1
            continue
        if line.startswith("```"):
            lang = line[3:].strip()
            body = []
            i += 1
            while i < n and not md_lines[i].startswith("```"):
                body.append(md_lines[i])
                i += 1
            if i >= n:
                raise SystemExit("%d行: コードブロックが閉じていない" % ln)
            i += 1
            blocks.append(("code", ln, body, lang))
            continue
        m = RE_H.match(line)
        if m:
            level = len(m.group(1))
            if level > 4:
                raise SystemExit("%d行: 対応外の見出し(%s)" % (ln, m.group(1)))
            blocks.append(("h", ln, level, m.group(2).strip()))
            i += 1
            continue
        if RE_HR.match(line):
            blocks.append(("hr", ln))
            i += 1
            continue
        if line.startswith("|"):
            rows = []
            first = ln
            while i < n and md_lines[i].startswith("|"):
                if not RE_SEP.match(md_lines[i]):
                    rows.append((i + 1, split_row(md_lines[i])))
                i += 1
            # 列数が見出し行と違う行がある表(原稿 1889行の表は見出し5列・本文6列)。
            # GitHub は見出しより多いセルを表示しないが、★黙って落とさない★ため、
            # 最も多い列数に揃えて足りないセルを空欄で埋め、警告を出す
            ncol = max(len(r) for _, r in rows)
            for rln, r in rows:
                if len(r) != ncol:
                    WARN.append("%d行: 表の列数が %d(この表の最大 %d)。空欄で埋めて出す"
                                % (rln, len(r), ncol))
                    r.extend([""] * (ncol - len(r)))
            blocks.append(("table", first, rows))
            continue
        if line.startswith(">"):
            body = []
            while i < n and md_lines[i].startswith(">"):
                b = md_lines[i][1:]
                body.append(b[1:] if b.startswith(" ") else b)
                i += 1
            elems = []   # ("p", lines) / ("li", 深さ, 種類, 番号, lines)
            j = 0
            cur = None
            while j < len(body):
                b = body[j]
                bln = ln + j
                if b.strip() == "":
                    cur = None
                    j += 1
                    continue
                if b.startswith(("```", "|", "#", ">")):
                    raise SystemExit("%d行: 引用の中のこの記法は対応外(%s)" % (bln, b[:10]))
                if RE_LI.match(b):
                    items, k = parse_list(body, j, len(body))
                    for depth, kind, num, lines in items:
                        elems.append(("li", depth, kind, num,
                                      [(ln + li - 1, t) for li, t in lines]))
                    cur = None
                    j = k
                    continue
                if cur is None:
                    cur = ("p", [])
                    elems.append(cur)
                cur[1].append((bln, b))
                j += 1
            blocks.append(("quote", ln, elems))
            continue
        if RE_LI.match(line):
            items, i = parse_list(md_lines, i, n)
            blocks.append(("list", ln, items))
            continue
        # 段落: 空行・他のブロックの開始まで
        buf = []
        while i < n:
            cl = md_lines[i]
            if (not cl.strip() or cl.startswith(("```", "|", ">", "#"))
                    or RE_UL.match(cl) or RE_OL.match(cl) or RE_HR.match(cl)):
                break
            buf.append((i + 1, cl))
            i += 1
        blocks.append(("p", ln, buf))

    # 対応外の記法が紛れていないか(黙って落とさない)。コードブロックの中は見ない
    in_code = False
    for idx, ml in enumerate(md_lines, 1):
        if ml.startswith("```"):
            in_code = not in_code
            continue
        if in_code:
            continue
        nocode = re.sub(r"`[^`]+`", "", ml)
        if re.search(r"!\[[^\]]*\]\([^)]*\)", nocode):
            raise SystemExit("%d行: 画像の記法は対応外" % idx)
        if re.match(r"^\s*[*+] ", ml):
            raise SystemExit("%d行: - 以外の箇条書き記号は対応外" % idx)
        if re.search(r"(?<![*\\])\*(?![*])", nocode.replace("**", "")):
            raise SystemExit("%d行: * 1つの強調(斜体)は対応外" % idx)
        if re.search(r"(^|\s)_[^_\s][^_]*_(\s|$)", nocode):
            raise SystemExit("%d行: _ の強調(斜体)は対応外" % idx)
        if re.match(r"^\[[^\]]+\]:\s", ml):
            raise SystemExit("%d行: 参照形式のリンクは対応外" % idx)
    return blocks


def collect_anchors(blocks):
    """## 〜 #### の見出しからアンカー名を作り、PDF 内の宛先名を割り当てる。

    GitHub と同じく、同名の見出しには -1, -2 … を付ける(表題の # も数に入れる)。
    """
    seen = {}
    keys = {}
    for b in blocks:
        if b[0] != "h":
            continue
        slug = github_slug(plain(b[3]))
        if slug in seen:
            seen[slug] += 1
            slug = "%s-%d" % (slug, seen[slug])
        else:
            seen[slug] = 0
        if b[2] >= 2:
            key = "h%d" % (len(ANCHORS) + 1)
            ANCHORS[slug] = key
            keys[b[1]] = key
    return keys


# ------------------------------------------------------------------- 表の列幅
ASCII_WORD = re.compile(r"[!-~]+")
PATH_PIECE = re.compile(r"[^/]*/|[^/]+")                       # 「/」の直後で区切った1片
FINE_PIECE = re.compile(r"[^/._\-:,]*[/._\-:,]|[^/._\-:,]+")    # 「.」「_」等でも区切った1片
CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")                  # camelCase の大文字の直前


def fine_pieces(w):
    """語を「/」「.」「_」「-」「:」「,」の直後と、camelCase の大文字の直前で区切る。"""
    return [q for p in FINE_PIECE.findall(w) for q in CAMEL.split(p) if q]
N_SPLIT = {"fine": [], "char": []}   # 語が「.」「_」等の直後 / 文字の途中で割れうる表(行番号)


def text_width(s, size):
    return pdfmetrics.stringWidth(s, "JP", size)


def col_widths(rows, total, lineno, small):
    """列幅を内容に応じて配分する(make_teamworks_qa11_pdf.py と同じ考え方を段階にしたもの)。

    nat[c]  … その列の最長のセル(<br> で区切った1行)を1行で出す幅
    low[c]  … 折り返してよい最小幅。ASCII の語の最長のものが割れない幅
              (★「/」の直後では割れてよいので、パスは「/」で区切った1片で測る★)と、
              見出し(短いものに限る)が割れない幅と、中身が全角7文字以内の列は
              その幅と、全角4文字分のうち最大
    low2[c] … low と同じだが、語を「.」「_」「-」「:」「,」の直後と camelCase の
              大文字の直前でも区切って測ったもの(fine_pieces())
    low3[c] … low2 から「見出しが割れない」「全角7文字以内なら折り返さない」を外したもの
    ・nat の合計が total に収まる → nat を基準に total まで比例で広げる
    ・low の合計が収まる → low を確保した残りを (nat - low) に比例して配る
    ・low2 の合計が収まる → low2 を確保した残りを (low - low2) に比例して配る
      (長い語は「.」「_」等の直後で割れる)
    ・low3 の合計が収まる → low3 を確保した残りを (low2 - low3) に比例して配る
      (短い列や見出しも折り返す。語は「.」「_」等の直後で割れる)
    ・どれも収まらない → low3 を比例で縮める(長い語は文字の途中で割れる。警告を出す)
    どの場合も★ページからははみ出させない★。
    """
    sth, scell = (S_TH_S, S_CELL_S) if small else (S_TH, S_CELL)
    pad = CELL_PAD_S if small else CELL_PAD
    ncol = len(rows[0])
    nat = [0.0] * ncol
    w1 = [0.0] * ncol    # 「/」で区切った語の最長
    w2 = [0.0] * ncol    # 「.」「_」等でも区切った語の最長
    hdr = [0.0] * ncol   # 短い見出しの幅
    for r_i, r in enumerate(rows):
        size = sth.fontSize if r_i == 0 else scell.fontSize
        font = "JP-B" if r_i == 0 else "JP"
        for c, cell in enumerate(r):
            for t in plain(cell).split("\n"):
                nat[c] = max(nat[c], pdfmetrics.stringWidth(t, font, size) + pad)
                words = ASCII_WORD.findall(t)
                for lo, split in ((w1, PATH_PIECE.findall), (w2, fine_pieces)):
                    pieces = [p for w in words for p in split(w)]
                    if pieces:
                        wmax = max(pdfmetrics.stringWidth(p, font, size) for p in pieces)
                        lo[c] = max(lo[c], wmax + pad + 1)
                if r_i == 0:
                    hw = pdfmetrics.stringWidth(t, font, size) + pad
                    if hw <= 72:
                        hdr[c] = max(hdr[c], hw)
    floor = 4 * scell.fontSize + pad
    short = [min(nat[c], 7 * scell.fontSize + pad) for c in range(ncol)]
    low = [max(w1[c], hdr[c], short[c], floor) for c in range(ncol)]
    low2 = [max(w2[c], hdr[c], short[c], floor) for c in range(ncol)]
    low3 = [max(w2[c], floor) for c in range(ncol)]
    nat = [max(nat[c], low[c]) for c in range(ncol)]

    def spread(base, upper):
        room = total - sum(base)
        need = [upper[c] - base[c] for c in range(ncol)]
        if sum(need) <= 0:
            return [base[c] + room / ncol for c in range(ncol)]
        return [base[c] + room * need[c] / sum(need) for c in range(ncol)]

    if sum(nat) <= total:
        extra = total - sum(nat)
        return [w + extra * w / sum(nat) for w in nat]
    if sum(low) <= total:
        return spread(low, nat)
    if sum(low2) <= total:
        N_SPLIT["fine"].append(lineno)
        return spread(low2, low)
    if sum(low3) <= total:
        N_SPLIT["fine"].append(lineno)
        return spread(low3, low2)
    N_SPLIT["char"].append(lineno)
    WARN.append("%d行の表(%d列): 最小幅の合計 %.0fpt が横幅 %.0fpt を超える"
                "(長い英数字が文字の途中で割れる)" % (lineno, ncol, sum(low3), total))
    scale = total / sum(low3)
    return [w * scale for w in low3]


# ------------------------------------------------------------------- 組版
class DocT(BaseDocTemplate):
    """見出しの段落に付けた _outline を PDF のしおりにする。"""
    n_outline = 0

    def afterFlowable(self, flowable):
        o = getattr(flowable, "_outline", None)
        if o:
            level, title, key = o
            self.canv.addOutlineEntry(title, key, level=level, closed=(level == 0))
            DocT.n_outline += 1


def build():
    md_lines = open(SRC, encoding="utf-8").read().split("\n")
    blocks = parse(md_lines)
    head_keys = collect_anchors(blocks)

    doc = DocT(
        OUT, pagesize=A4,
        leftMargin=18 * mm, rightMargin=18 * mm,
        topMargin=18 * mm, bottomMargin=20 * mm,
        title="D-Watch ソースコード地図",
        author="DFA Robotics",
        subject="何がどこで決まっているか(非エンジニア向け)",
    )
    frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="f")
    doc.addPageTemplates([PageTemplate(id="p", frames=[frame], onPage=on_page)])

    W = doc.width          # 174mm
    CW = W - 6 * mm        # 引用・コード枠の幅
    story = []
    seen_title = False
    after_title = False
    n_tables = n_rows = n_wide = n_code = n_mermaid = 0
    n_head = {1: 0, 2: 0, 3: 0, 4: 0}

    for b in blocks:
        kind, ln = b[0], b[1]
        if kind == "h":
            level, text = b[2], b[3]
            n_head[level] += 1
            mark = inline(text, ln)
            if level == 1:
                if seen_title:
                    raise SystemExit("%d行: 表題(#)が2つ目" % ln)
                story.append(Paragraph(mark, S_TITLE))
                seen_title = True
                after_title = True
                continue
            after_title = False
            key = head_keys[ln]
            para = Paragraph('<a name="%s"/>' % key + mark,
                             {2: S_H2, 3: S_H3, 4: S_H4}[level])
            if level in (2, 3):
                title = fix_glyphs(plain(text), ln).strip()
                para._outline = (level - 2, title, key)
            if level == 2:
                # ★改ページするのは番号の付いた章(「0.」〜「14.」「5-B.」「付録A.」等)だけ★。
                #   冒頭の「第4版で新しくなったところ」「前提の変遷」「目次」は短いので続けて流す
                #   (全部で改ページすると1〜2ページ目がほぼ白紙になる)。
                if story and re.match(r"(\d+(-[A-Z])?\.|付録)", text.strip()):
                    story.append(PageBreak())   # ★章ごとに改ページ★
                else:
                    story.append(CondPageBreak(45 * mm))
            elif level == 3:
                story.append(CondPageBreak(45 * mm))
            else:
                story.append(CondPageBreak(35 * mm))
            story.append(para)
        elif kind == "p":
            mark = inline(join_lines(b[2]), ln)
            story.append(Paragraph(mark, S_SUB if after_title else S_BODY))
            if after_title:
                story.append(Spacer(1, 5))
            after_title = False
        elif kind == "quote":
            cells = []
            n_p = sum(1 for e in b[2] if e[0] == "p")
            for e in b[2]:
                if e[0] == "p":
                    s = S_QUOTE_P if n_p > 1 else S_QUOTE
                    cells.append(P(inline(join_lines(e[1]), e[1][0][0]), s))
                else:
                    _, depth, lkind, num, lines = e
                    if lkind == "ul":
                        cells.append(P(esc(BULLET_MARK[depth]) +
                                       inline(join_lines(lines), lines[0][0]),
                                       S_QUOTE_B[depth]))
                    else:
                        cells.append(P(esc(num + " ") +
                                       inline(join_lines(lines), lines[0][0]),
                                       S_QUOTE_L[depth]))
            story.append(quote(cells, CW))
            story.append(Spacer(1, 6))
            after_title = False
        elif kind == "table":
            rows = [r for _, r in b[2]]
            small = len(rows[0]) >= WIDE_TABLE
            mrows = [[inline(c, rln) for c in r] for rln, r in b[2]]
            widths = col_widths(rows, W, ln, small)
            story.append(Spacer(1, 2))
            story.append(table(mrows, widths, small))
            story.append(Spacer(1, 7))
            n_tables += 1
            n_rows += len(rows)
            n_wide += small
            after_title = False
        elif kind == "list":
            items = []
            for depth, lkind, num, lines in b[2]:
                body = inline(join_lines(lines), lines[0][0])
                if lkind == "ul":
                    items.append(Paragraph(esc(BULLET_MARK[depth]) + body, S_BULL[depth]))
                else:
                    items.append(Paragraph(esc(num + " ") + body, S_LIST[depth]))
            # 直前が導入の段落なら、段落(2つまで)と箇条書きを同じページに置く
            lead = []
            while (len(items) <= 6 and len(lead) < 2 and story
                   and isinstance(story[-1], Paragraph)
                   and not getattr(story[-1], "_outline", None)
                   and story[-1].style not in (S_H2, S_H3, S_H4, S_TITLE)):
                lead.insert(0, story.pop())
            if lead:
                story.append(KeepTogether(lead + items))
            else:
                story.extend(items)
            story.append(Spacer(1, 4))
            after_title = False
        elif kind == "code":
            body, lang = b[2], b[3]
            lines = [fix_glyphs(x, ln + 1 + k) for k, x in enumerate(body)]
            if lang == "mermaid":
                story.append(Paragraph(esc(MERMAID_NOTE), S_MER))
                n_mermaid += 1
            elif lang:
                pass   # 言語名(ts / sql 等)は出さない(既存と同じ)
            story.append(code(lines, CW))
            story.append(Spacer(1, 7))
            n_code += 1
            after_title = False
        elif kind == "hr":
            story.append(Spacer(1, 4))
            story.append(HRFlowable(width="100%", thickness=0.4, color=LINE,
                                    spaceBefore=2, spaceAfter=8))
            after_title = False
        else:
            raise SystemExit("未知のブロック: %r" % (b,))

    doc.build(story)
    print("出力:", OUT)
    print("原稿:", SRC, "(%d 行)" % len(open(SRC, encoding="utf-8").read().splitlines()))
    print("ページ数:", doc.page)
    print("見出し: # %d / ## %d / ### %d / #### %d"
          % (n_head[1], n_head[2], n_head[3], n_head[4]))
    print("しおり:", DocT.n_outline, "(## と ### の合計 %d)" % (n_head[2] + n_head[3]))
    print("章内リンク:", N_LINKS[0], " ファイルへのリンク(文字だけ出す):", N_LINKS[1])
    print("ブロック:", len(blocks), " 表:", n_tables, "(見出し行を含め %d 行。6列以上 %d)"
          % (n_rows, n_wide), " コード枠:", n_code, "(うち Mermaid %d)" % n_mermaid)
    if N_SPLIT["fine"]:
        print("列幅が足りず、長い英数字が語の切れ目(「.」「_」・camelCase 等)で割れうる表: %d(%s)"
              % (len(N_SPLIT["fine"]), ", ".join("%d行" % x for x in N_SPLIT["fine"])))
    if SYM_LOG:
        print("BIZ UDGothic に字形が無いため Segoe UI Symbol で描いた文字: %d 種類" % len(SYM_LOG))
        for c, lns in SYM_LOG.items():
            print("   U+%04X %s %s(%d 箇所: %s)"
                  % (ord(c), c, unicodedata.name(c, "?"), len(lns),
                     ", ".join("%d行" % x for x in sorted(set(lns)))))
    if DROP_LOG:
        print("取り除いた文字:")
        for c, lns in DROP_LOG.items():
            print("   U+%04X %s(%d 箇所: %s)"
                  % (ord(c), DROP_CHARS[c], len(lns),
                     ", ".join("%d行" % x for x in sorted(set(lns)))))
    if STAR_LOG:
        print("★の数が釣り合わない段落(原稿の書き方。解釈を下に示す): %d 件" % len(STAR_LOG))
        for lnum, ns, how in STAR_LOG:
            print("   %4d行 ★%d個: %s" % (lnum, ns, how))
    if WARN:
        print("★警告★")
        for w in WARN:
            print("  ", w)
    else:
        print("警告: なし")


if __name__ == "__main__":
    build()