# -*- 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):
"""コードを等幅フォントの枠に入れる。空白は にして桁を保つ。"""
esc = []
for ln in lines:
ln = ln.replace("&", "&").replace("<", "<").replace(">", ">")
esc.append(ln.replace(" ", " "))
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("&", "&").replace("<", "<").replace(">", ">")
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()