Newer
Older
client_dwatch / docs / handoff / pdf / make_teamworks_qa13_pdf.py
# -*- coding: utf-8 -*-
"""
TeamWorks 宛 確認事項(第13回)を PDF にする。

第12回(make_teamworks_qa12_pdf.py)の写し。体裁・フォント・組版は第12回と同一。
A4 縦 / reportlab / 日本語フォント埋め込み / フッター罫線とページ番号。

フォントは BIZ UDGothic(日本語フォント)を埋め込む。
中国語向けフォント(SimSun / Microsoft YaHei / Noto Sans SC / STSong 等)は
漢字の字形が異なるため使わない。フォールバックにも混ぜないこと(CLAUDE.md §0-3)。

BIZ UDGothic は等幅(半角は 0.5em 固定)なので、コードの枠にもこのフォントを使う。
コードブロック内の空白は   に置換して桁を保持する。

★作り方は第10回・第11回と同じ(原稿の .md を読んで PDF にする)★
  原稿を直したら、このスクリプトは触らずに再実行すればよい。

  原稿(既定): docs/handoff/TeamWorks確認事項_第13回_20261019_送付版.md
  第1引数で別の原稿を渡せる。

★第12回からの変更★
  1. 原稿・出力・PDF の表題を第13回に。
  2. 設問の記号は S-1. の形(第12回は R-1.)。第12回で R- を足したのと同じ要領。★RE_QUESTION に「S-数字」を足した★。
     第11回の RE_QUESTION は「N-数字」/「第9回 Q…」だけで P-1. に当たらず、
     設問が「設問の帯」ではなく小見出し(### → 9.8pt)で出ていた(第12回で R- を足して解消)。
     第13回は設問を「設問の帯」(qhead)で出す(下の「見出しの対応」の本来の意図どおり)。

★送付版の書き方の約束★
    # = 章 / ### = 設問(6問。すべて「設問の帯」で出る)/ #### = 設問の中の小見出し
  点検スクリプトが「### の見出しが6あること」を数える。

  対応する記法は、原稿で実際に使っているものだけ:
    見出し(# / ## / ### / ####)、段落、> の引用(> だけの行は引用内の段落区切り)、
    表(| 区切り。セル内の改行なし)、箇条書き(- と 1.)、コードの枠(```)、
    水平線(---)、**太字**、`コード`、~~取り消し線~~(全文の原稿の8章の表で使用。送付版には無い)。
  ★原稿の ★ は PDF に出さず、★で囲まれた範囲を太字にする★(第2回からの作法)。
  `コード` のバッククォートは記法として外し、中身はそのまま出す(第9回と同じ見た目)。

  ★これ以外の記法(リンク・画像・HTML タグ・入れ子の箇条書き 等)が原稿に
    現れたら、黙って落とさずに例外で止まる★(parse() の末尾の検査)。

★見出しの対応★
  原稿の先頭の # → 表題(17pt)
  それ以外の #     → 章見出し(12.5pt。直前で CondPageBreak)
  ##               → 節見出し(10.5pt)
  ### / ####       → 小見出し(9.8pt)
  ただし「S-1.」「N-1.」「第9回 Q4-1.」のような★設問の見出し★は、階層にかかわらず
  第9回と同じ「設問の帯」(qhead)で出す。

★段落の改行★
  原稿は1行に1文(ないし1句)で書いてある。Markdown の規則どおり連続行は
  1段落にまとめるが、★前の行が「。」「.」「)」等で終わっているときだけ改行
  (<br/>)を入れる★。文の途中で折り返している行(「、」で終わる等)は詰めて繋ぐ。
  前の行が ASCII(英数字・「/」等)で終わるときだけ空白を1つ挟む。

★★ の解釈(入れ子と連続の区別)★
  原稿には「★A ★B★ C★」(入れ子)と「★A★ / ★B★」(連続)の両方がある。
  同じ記号なので開き・閉じを文脈で決める:
    ・★の連続(★★)は1つの記号として扱う
    ・直後が文末・空白・閉じ括弧・句読点 → 閉じ
    ・直前が行頭・空白・開き括弧・句読点 → 開き
    ・どちらでもない → 開いていれば閉じ、開いていなければ開き
  「開いている深さが1以上」の範囲を太字にする。入れ子の内側も太字のまま。
  深さが合わないまま段落が終わった箇所は、生成時に警告として行番号を出す。

★表の列幅★
  列ごとに「折り返さずに収まる幅」と「折り返してよい最小幅」を測り、
  ページの横幅(174mm)に収まるよう配分する(HTML の自動レイアウトと同じ考え方)。
  ★最小幅は、その列に出る ASCII の語(識別子・数値)の最長のもの★が
  語の途中で割れない幅にする(pdf/README.md「識別子が語の途中で折り返る」対策)。
  最小幅の合計が横幅を超える表があれば、生成時に警告を出す。

★ASCII の語の途中での改行を避ける★
  reportlab の CJK 折り返し(wordWrap="CJK")は、ASCII の語の途中でも改行する。
  標準の実装は「行の半分まで」しか区切り(空白・和文字)を遡らないためで、
  `clean_` / `status_histories` のように割れる(第2回で5箇所発生)。
  第9回までは <br/> を手で入れて避けていたが、原稿から自動生成する今回は
  それができないので、★遡る範囲を行頭までに広げた版に差し替えている★
  (cjk_frag_split()。区切りが1つも無い長い語だけは従来どおり文字で割る)。
"""
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, 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, "..", "TeamWorks確認事項_第13回_20261019_送付版.md"))

FONT_DIR = "C:/Windows/Fonts/"
pdfmetrics.registerFont(TTFont("JP", FONT_DIR + "BIZ-UDGothicR.ttc", subfontIndex=0))
pdfmetrics.registerFont(TTFont("JP-B", FONT_DIR + "BIZ-UDGothicB.ttc", subfontIndex=0))
# <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")

# 出力先。既定はこのスクリプトと同じディレクトリ。
# 環境変数 DWATCH_PDF_OUT で変えられる(例: 送付前に Downloads へ出す)。
OUT_DIR = os.environ.get("DWATCH_PDF_OUT", HERE)
OUT = os.path.join(OUT_DIR, "DWatch_確認事項_第13回_20261019.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")

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回と同一★
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)
S_LIST = st("li", 9.5, 15.5, "JP", INK, 2, 0, 15, -15)   # 「1. 」のぶら下げ
S_BULL = st("bu", 9.5, 15.5, "JP", INK, 2, 0, 14, -14)   # 「・」のぶら下げ
S_CELL = st("c", 8.8, 13.8, "JP", INK)
S_CODE = st("cd", 8.2, 12.6, "JP", INK)
S_TH = st("th", 8.4, 12, "JP-B", SUB)
S_QUOTE = st("q", 9.2, 14.8, "JP", SUB)
S_QUOTE_P = st("qp", 9.2, 14.8, "JP", SUB, 4)            # 引用内の段落(2段落目以降の前に間)
S_QH = st("qh", 10, 15.5, "JP-B", INK)     # 設問(N-1 / 第9回 Q4-1 …)の見出し帯


# ------------------------------------------------ ASCII の語の途中で割らない折り返し
def cjk_frag_split(frags, maxWidths, calcBounds, encoding="utf8"):
    """reportlab の cjkFragSplit の改変版。

    違いは1点だけ: 行あふれの位置が ASCII のとき、区切り(空白 / 和文字)を
    ★行頭まで★遡って探す(標準は行の半分まで)。和文字の直後で切るときは、
    行末に置けない文字(「(」等)の直後は避けてさらに遡る。
    区切りが1つも無い(1行に収まらない長い語)ときは標準どおり文字で割る。
    """
    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:
                    for j in range(i - 2, lineStartPos, -1):
                        uj = U[j]
                        if not uj:
                            continue
                        brk = (unicodedata.category(uj) == "Zs" or
                               (ord(uj) >= 0x3000 and uj not in ALL_CANNOT_END))
                        if brk:
                            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
                                break
                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
            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 の改変版(装飾の無い段落はこちらを通る)。

    reportlab は段落の中身が1種類の書式だけのとき(<b> 等を含まないとき)、
    cjkFragSplit ではなく wordSplit → dumbSplit で折り返す。こちらも同じく
    行の半分までしか区切りを遡らないため、「第9回 Q11-」「1」のように割れる。
    改変点は 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:
                for j in range(i - 2, lineStartPos, -1):
                    cj = word[j]
                    brk = (unicodedata.category(cj) == "Zs" or
                           (ord(cj) >= 0x3000 and cj not in ALL_CANNOT_END))
                    if brk:
                        k = j + 1
                        if k < i:
                            j = k + 1
                            extraSpace += sum(widths[j:i])
                            w = widths[k]
                            c = word[k]
                            i = j
                            break
            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]
            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


# ------------------------------------------------------------------- 部品(第9回と同一)
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),
]
CELL_PAD = 12   # 左右パディングの合計(pt)
FILL_COL = 45 * mm   # ご記入いただく列(空欄の列)に確保したい幅


def table(rows, widths):
    """rows[0] を見出し行とする表。"""
    data = [[P(c, S_TH) for c in rows[0]]]
    for r in rows[1:]:
        data.append([P(c, S_CELL) for c in r])
    t = Table(data, colWidths=widths, repeatRows=1)
    t.setStyle(TableStyle(list(TBL_STYLE)))
    return t


def code(lines, width):
    """コードを等幅フォントの枠に入れる。空白は &nbsp; にして桁を保つ。"""
    esc = []
    for ln in lines:
        ln = ln.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        esc.append(ln.replace(" ", "&nbsp;"))
    t = Table([[P("<br/>".join(esc), S_CODE)]], 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), 6),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
        ("LEFTPADDING", (0, 0), (-1, -1), 8),
        ("RIGHTPADDING", (0, 0), (-1, -1), 6),
    ]))
    return t


def quote(paras, width):
    """補足・お断りの引用。左に縦罫線を引く。paras は段落(マークアップ済み)の並び。"""
    # 段落のあいだにだけ間を空ける(最後の段落は spaceAfter 0)
    cell = [P(p, S_QUOTE_P) for p in paras[:-1]] + [P(paras[-1], S_QUOTE)]
    t = Table([[cell]], 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), 3),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
        ("LEFTPADDING", (0, 0), (-1, -1), 8),
        ("RIGHTPADDING", (0, 0), (-1, -1), 0),
    ]))
    return t


def qhead(text, width):
    """設問(N-1 / 第9回 Q4-1 …)の見出し帯。節見出しと見分けがつくようにする。"""
    t = Table([[P(text, S_QH)]], colWidths=[width])
    t.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (-1, -1), "JP"),
        ("BACKGROUND", (0, 0), (-1, -1), HEAD_BG),
        ("LINEBEFORE", (0, 0), (0, -1), 2.2, MUT),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("TOPPADDING", (0, 0), (-1, -1), 5),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
        ("LEFTPADDING", (0, 0), (-1, -1), 8),
        ("RIGHTPADDING", (0, 0), (-1, -1), 8),
    ]))
    return t


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

OPEN_BEFORE = set(" \t\u3000(「『(【〔[[、。,,::・/")
CLOSE_AFTER = set(" \t\u3000)」』)】〕]]、。,,..::;;!!??/")
BR = "\x00"   # 段落内の改行(<br/>)の目印

TOKEN = re.compile(r"(`[^`]+`|\*\*|~~|★+|\x00)")


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  … 出てきた順に開き・閉じを交互にする(入れ子を考えない)。
    """
    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 parts[j] in ("**", "~~"):
            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 parts[k] in ("**", "~~"):
            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 のマークアップにする。

    `コード` / **太字** / ~~取り消し線~~ / ★太字★ を処理する。
    ★ は出さない。バッククォートも記法として外す(中身はそのまま出す)。

    ★の開き・閉じは resolve_stars() の文脈判定で決める。釣り合わない段落
    (原稿の ★ の数が奇数、または入れ子の閉じが省かれている)は:
      ・★の数が偶数 → 出てきた順に交互に開き・閉じ(toggle)で読み直す
      ・★の数が奇数 → 文脈判定のまま(閉じ損ねた★以降は段落の終わりまで太字)
        ★強調を落とすより、太字が広がるほうを採る★
    どちらも STAR_LOG に行番号を残し、生成時に一覧を出す。
    """
    parts = [p for p in TOKEN.split(text) if p != ""]
    depths, ok = resolve_stars(parts)
    if not ok:
        n_star = sum(1 for p in parts if p[0] == "★")
        if 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
    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[0] == "★":
            continue
        if p.startswith("`") and p.endswith("`") and len(p) >= 2:
            s = esc(p[1:-1])
        else:
            s = 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)
    return "".join(out)


def plain(text):
    """記法を外した素の文字列(列幅の計測・見出しの判定用)。"""
    t = text.replace(BR, "")
    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]+$", "", out)
        head = re.sub(r"^[★*`~\s]+", "", s)
        if tail.endswith(SENT_END):
            out += BR + s
        elif tail and tail[-1].isascii():
            out += " " + s   # 「(N-10) /」のように ASCII で終わる行は空白を挟む
        else:
            out += s
    return out


# ------------------------------------------------------------------- ブロックの解析
RE_H = re.compile(r"^(#{1,6}) (.*)$")
RE_UL = re.compile(r"^- (.*)$")
RE_OL = re.compile(r"^(\d+)\. (.*)$")
RE_SEP = re.compile(r"^\|(\s*:?-{3,}:?\s*\|)+\s*$")
RE_HR = re.compile(r"^-{3,}\s*$")


def split_row(line):
    s = line.strip()
    assert s.startswith("|") and s.endswith("|"), line
    return [c.strip() for c in s[1:-1].split("|")]


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("```"):
            body = []
            i += 1
            while not md_lines[i].startswith("```"):
                body.append(md_lines[i])
                i += 1
            i += 1
            blocks.append(("code", ln, body))
            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
            ncol = len(rows[0][1])
            for rln, r in rows:
                if len(r) != ncol:
                    raise SystemExit("%d行: 表の列数が見出し行と違う(%d / %d)"
                                     % (rln, len(r), ncol))
            blocks.append(("table", first, rows))
            continue
        if line.startswith(">"):
            paras = []
            cur = []
            while i < n and md_lines[i].startswith(">"):
                body = md_lines[i][1:]
                body = body[1:] if body.startswith(" ") else body
                if body.strip() == "":
                    if cur:
                        paras.append(cur)
                    cur = []
                else:
                    cur.append((i + 1, body))
                i += 1
            if cur:
                paras.append(cur)
            blocks.append(("quote", ln, paras))
            continue
        mu = RE_UL.match(line)
        mo = RE_OL.match(line)
        if mu or mo:
            kind = "ul" if mu else "ol"
            items = []
            while i < n:
                cl = md_lines[i]
                mu2 = RE_UL.match(cl) if kind == "ul" else None
                mo2 = RE_OL.match(cl) if kind == "ol" else None
                if mu2:
                    items.append(["・", [(i + 1, mu2.group(1))]])
                elif mo2:
                    items.append([mo2.group(1) + ". ", [(i + 1, mo2.group(2))]])
                elif cl.startswith((" ", "\t")) and cl.strip() and items:
                    items[-1][1].append((i + 1, cl.strip()))   # 項目の続きの行
                else:
                    break
                i += 1
            blocks.append((kind, 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))

    # 対応外の記法が紛れていないか(黙って落とさない)
    for idx, ml in enumerate(md_lines, 1):
        if re.search(r"!?\[[^\]]*\]\([^)]*\)", ml):
            raise SystemExit("%d行: リンク/画像の記法は対応外" % idx)
        if re.search(r"<[A-Za-z/][^>]*>", ml):
            raise SystemExit("%d行: HTML タグは対応外" % idx)
        if re.match(r"^\s{2,}[-*] ", ml) or re.match(r"^\s{2,}\d+\. ", ml):
            raise SystemExit("%d行: 入れ子の箇条書きは対応外" % idx)
        if re.match(r"^\* ", ml) or re.match(r"^\+ ", ml):
            raise SystemExit("%d行: - 以外の箇条書き記号は対応外" % idx)
    return blocks


# ------------------------------------------------------------------- 表の列幅
ASCII_WORD = re.compile(r"[!-~]+")


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


def col_widths(rows, total, lineno):
    """列幅を内容に応じて配分する。

    nat[c] … その列の最長のセルを1行で出す幅
    low[c] … 折り返してよい最小幅。ASCII の語の最長のものが割れない幅と、
             見出し(短いものに限る)が割れない幅と、中身が全角7文字以内の列は
             その幅と、全角4文字分のうち最大。ご記入いただく列(本文の半分以上が
             空欄)は nat を 45mm 以上にする
    合計が total に収まるなら nat を基準に total まで比例で広げ、
    収まらないなら low を確保した残りを (nat - low) に比例して配る。
    """
    ncol = len(rows[0])
    nat = [0.0] * ncol
    low = [0.0] * ncol
    for r_i, r in enumerate(rows):
        size = S_TH.fontSize if r_i == 0 else S_CELL.fontSize
        for c, cell in enumerate(r):
            t = plain(cell)
            nat[c] = max(nat[c], text_width(t, size) + CELL_PAD)
            words = ASCII_WORD.findall(t)
            if words:
                wmax = max(text_width(w, size) for w in words)
                low[c] = max(low[c], wmax + CELL_PAD + 1)
            if r_i == 0:
                hw = text_width(t, size) + CELL_PAD
                if hw <= 72:
                    low[c] = max(low[c], hw)
    body = rows[1:]
    for c in range(ncol):
        # 中身が短い列(最長でも全角7文字程度)は折り返さない
        low[c] = max(low[c], min(nat[c], 7 * S_CELL.fontSize + CELL_PAD))
        low[c] = max(low[c], 4 * S_CELL.fontSize + CELL_PAD)
        # ご記入いただく列(本文の半分以上が空欄)は、書ける幅を取る
        if body and sum(1 for r in body if not plain(r[c]).strip()) * 2 >= len(body):
            nat[c] = max(nat[c], FILL_COL)
            low[c] = max(low[c], min(nat[c], FILL_COL * 0.75))
        nat[c] = max(nat[c], low[c])
    if sum(low) > total:
        WARN.append("%d行の表: 最小幅の合計 %.0fpt が横幅 %.0fpt を超える"
                    "(ASCII の語が割れる)" % (lineno, sum(low), total))
        scale = total / sum(low)
        return [w * scale for w in low]
    if sum(nat) <= total:
        extra = total - sum(nat)
        return [w + extra * w / sum(nat) for w in nat]
    room = total - sum(low)
    flex = [nat[c] - low[c] for c in range(ncol)]
    return [low[c] + room * flex[c] / sum(flex) for c in range(ncol)]


# ------------------------------------------------------------------- 組版
RE_QUESTION = re.compile(r"^(S-\d+|R-\d+|N-\d+|第9回 Q\d+-\d+|第9回\s*\d+章)")


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

    doc = BaseDocTemplate(
        OUT, pagesize=A4,
        leftMargin=18 * mm, rightMargin=18 * mm,
        topMargin=18 * mm, bottomMargin=20 * mm,
        title="D-Watch 確認事項(第13回)",
        author="DFA Robotics",
        subject="事前チェックのエラーの予定ID・清掃結果の書き込みの遅れ・データ投入完了のご連絡",
    )
    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 = 0
    n_rows = 0

    for b in blocks:
        kind, ln = b[0], b[1]
        if kind == "h":
            level, text = b[2], b[3]
            mark = inline(text, ln)
            if level == 1 and not seen_title:
                story.append(Paragraph(mark, S_TITLE))
                seen_title = True
                after_title = True
                continue
            after_title = False
            if RE_QUESTION.match(plain(text)):
                # ★送付版は短く収めたいので、改ページの閾値は小さめにし、
                #   代わりに設問の帯を直後の段落・表と同じページに置く(keepWithNext)★
                # 章見出し(とその直後の導入文)に続く設問は、改ページの判定をせず
                # 章見出しから設問の最初の段落までを keepWithNext で1塊にする
                # (章見出しだけがページ末に残るのを防ぐ)
                # ★第13回で追加★: 章見出しの直後にいきなり設問の帯が来るときは、
                #   章見出しを帯と同じページに置く(第13回の初版で「2. …」の章見出しだけが
                #   1ページ目の末尾に残った。第11回は P-1. が帯にならなかったので起きなかった)
                if (story and isinstance(story[-1], Paragraph)
                        and story[-1].style is S_H2):
                    story[-1].keepWithNext = 1
                if not (story and getattr(story[-1], "keepWithNext", 0)):
                    story.append(CondPageBreak(30 * mm))
                sp = Spacer(1, 6)
                sp.keepWithNext = 1
                story.append(sp)
                qh = qhead(mark, W)
                qh.keepWithNext = 1
                story.append(qh)
                gap = Spacer(1, 6)
                gap.keepWithNext = 1
                story.append(gap)
            elif level == 1:
                story.append(CondPageBreak(25 * mm))
                story.append(Paragraph(mark, S_H2))
            elif level == 2:
                story.append(CondPageBreak(45 * mm))
                story.append(Paragraph(mark, S_H3))
            else:
                story.append(CondPageBreak(35 * mm))
                story.append(Paragraph(mark, S_H4))
        elif kind == "p":
            mark = inline(join_lines(b[2]), ln)
            para = Paragraph(mark, S_SUB if after_title else S_BODY)
            # 章見出しの直後の導入文は、続く設問の帯と同じページに置く
            if (story and isinstance(story[-1], Paragraph)
                    and story[-1].style is S_H2):
                para.keepWithNext = 1
            story.append(para)
            if after_title:
                story.append(Spacer(1, 5))
            after_title = False
        elif kind == "quote":
            paras = [inline(join_lines(p), p[0][0]) for p in b[2]]
            story.append(quote(paras, CW))
            story.append(Spacer(1, 6))
            after_title = False
        elif kind == "table":
            rows = [r for _, r in b[2]]
            mrows = [[inline(c, rln) for c in r] for rln, r in b[2]]
            widths = col_widths([[c for c in r] for r in rows], W, ln)
            story.append(Spacer(1, 2))
            story.append(table(mrows, widths))
            story.append(Spacer(1, 7))
            n_tables += 1
            n_rows += len(rows)
            after_title = False
        elif kind in ("ul", "ol"):
            style = S_BULL if kind == "ul" else S_LIST
            items = [Paragraph(esc(marker) + inline(join_lines(lines), lines[0][0]),
                               style) for marker, lines in b[2]]
            # 直前が導入の段落なら、段落(2つまで)と箇条書きを同じページに置く
            # (「次の2つ」だけが前のページに残るのを防ぐ。末尾の「以上です。」も同様)
            lead = []
            while (len(items) <= 6 and len(lead) < 2 and story
                   and isinstance(story[-1], Paragraph)):
                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":
            story.append(code(b[2], CW))
            story.append(Spacer(1, 7))
            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("ブロック:", len(blocks), " 表:", n_tables, "(見出し行を含め %d 行)" % n_rows)
    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()