# -*- 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):
"""コードを等幅フォントの枠に入れる。空白は にして桁を保つ。
★1行を表の1行にする★(長いコードがページをまたいで分割できるように)。
1行がはみ出すときは枠の中で折り返す。
"""
rows = []
for ln in lines:
e = ln.replace("&", "&").replace("<", "<").replace(">", ">")
e = sym_wrap(e.replace(" ", " "))
rows.append([P(e if e else " ", S_CODE)])
if not rows:
rows = [[P(" ", 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("&", "&").replace("<", "<").replace(">", ">")
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()