text-compositor 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- text_compositor/__init__.py +0 -0
- text_compositor/build.py +2680 -0
- text_compositor/templates/slide.typ +173 -0
- text_compositor/templates/template.typ +187 -0
- text_compositor-0.2.0.dist-info/METADATA +161 -0
- text_compositor-0.2.0.dist-info/RECORD +10 -0
- text_compositor-0.2.0.dist-info/WHEEL +5 -0
- text_compositor-0.2.0.dist-info/entry_points.txt +2 -0
- text_compositor-0.2.0.dist-info/licenses/LICENSE +21 -0
- text_compositor-0.2.0.dist-info/top_level.txt +1 -0
text_compositor/build.py
ADDED
|
@@ -0,0 +1,2680 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
import csv
|
|
5
|
+
import json
|
|
6
|
+
import io
|
|
7
|
+
import bisect
|
|
8
|
+
import subprocess
|
|
9
|
+
import hashlib
|
|
10
|
+
import shutil
|
|
11
|
+
import argparse
|
|
12
|
+
import urllib.request
|
|
13
|
+
import zipfile
|
|
14
|
+
import tarfile
|
|
15
|
+
import time
|
|
16
|
+
import tempfile
|
|
17
|
+
import platform
|
|
18
|
+
import importlib.metadata
|
|
19
|
+
import platformdirs
|
|
20
|
+
from collections import namedtuple
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from markdown_it import MarkdownIt
|
|
24
|
+
# タスクリスト(- [ ]/- [x])はGFM拡張のためcommonmarkプリセットに含まれず、mdit-py-pluginsの
|
|
25
|
+
# プラグインとして追加する(#48)。
|
|
26
|
+
from mdit_py_plugins.tasklists import tasklists_plugin
|
|
27
|
+
# 文字色指定(#46)。[text]{color=red}というPandoc由来のブラケット+属性記法をパースする
|
|
28
|
+
# (spans=Trueでspan_open/span_closeトークンとして出力される。既定では無効なので明示的に有効化)。
|
|
29
|
+
from mdit_py_plugins.attrs import attrs_plugin
|
|
30
|
+
# PyPIの typst パッケージ(typst-py)はコンパイラ本体をプラットフォーム別ホイールに同梱しているため、
|
|
31
|
+
# tools/typst.exe のような実行バイナリをリポジトリに持たずに済む(pipがOSごとに正しい版を入れてくれる)
|
|
32
|
+
import typst as typst_lib
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import yaml
|
|
36
|
+
except ImportError:
|
|
37
|
+
yaml = None
|
|
38
|
+
|
|
39
|
+
# ローカル環境やGitHub Actionsランナー(ubuntu-latest)に標準搭載されているChrome/Edgeの
|
|
40
|
+
# インストール先候補。見つかればmermaidレンダリング用にそのまま起動して再利用し、
|
|
41
|
+
# ブラウザの自動ダウンロード(実測699MB。#34)を回避する(仕様書11章、#35)。
|
|
42
|
+
SYSTEM_BROWSER_PATHS = {
|
|
43
|
+
"win32": [
|
|
44
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
45
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
46
|
+
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
|
47
|
+
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
|
48
|
+
],
|
|
49
|
+
"darwin": [
|
|
50
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
51
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
52
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
53
|
+
],
|
|
54
|
+
"linux": [
|
|
55
|
+
"/usr/bin/google-chrome",
|
|
56
|
+
"/usr/bin/google-chrome-stable",
|
|
57
|
+
"/usr/bin/chromium-browser",
|
|
58
|
+
"/usr/bin/chromium",
|
|
59
|
+
"/usr/bin/microsoft-edge",
|
|
60
|
+
"/usr/bin/microsoft-edge-stable",
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
SYSTEM_BROWSER_COMMANDS = [
|
|
64
|
+
"google-chrome", "google-chrome-stable", "chromium-browser", "chromium",
|
|
65
|
+
"msedge", "microsoft-edge", "microsoft-edge-stable",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# ログの詳細度(#52)。CLIの-q/-vで一度だけ設定するプロセスグローバルな状態。config.yamlに
|
|
69
|
+
# 書くべき文書内容ではなく実行時の振る舞いのため、CLIオプションのみで制御する(config.yaml側の
|
|
70
|
+
# 設定項目は設けない)。--config-listで複数ビルドをまとめて実行する場合もCLI全体で1つの
|
|
71
|
+
# 詳細度に統一される。既定は[Info]まで表示、[Warning]/[Error]/[Success]は常に表示する。
|
|
72
|
+
_QUIET = False
|
|
73
|
+
_VERBOSE = False
|
|
74
|
+
|
|
75
|
+
def _log_info(msg):
|
|
76
|
+
if not _QUIET:
|
|
77
|
+
print(f"[Info] {msg}")
|
|
78
|
+
|
|
79
|
+
def _log_verbose(msg):
|
|
80
|
+
if _VERBOSE:
|
|
81
|
+
print(f"[Verbose] {msg}")
|
|
82
|
+
|
|
83
|
+
def _user_cache_dir():
|
|
84
|
+
"""フォント/JRE/PlantUMLの取得物を置くアプリ専用のキャッシュディレクトリを返す。
|
|
85
|
+
tool_dir(インストール場所)ではなくOS標準のユーザー領域(Windows:
|
|
86
|
+
%LOCALAPPDATA%\\text-compositor\\Cache、Linux: ~/.cache/text-compositor、
|
|
87
|
+
macOS: ~/Library/Caches/text-compositor)を使うことで、「クローンして直接叩く」
|
|
88
|
+
でも「pipインストール」でも同じ場所にキャッシュが置ける(#50、#110)。"""
|
|
89
|
+
return platformdirs.user_cache_dir("text-compositor", appauthor=False)
|
|
90
|
+
|
|
91
|
+
def find_system_browser():
|
|
92
|
+
"""既存のChrome/Edgeの実行ファイルパスを探す。見つからなければNone。"""
|
|
93
|
+
platform_key = "darwin" if sys.platform == "darwin" else ("linux" if sys.platform.startswith("linux") else "win32")
|
|
94
|
+
for path in SYSTEM_BROWSER_PATHS.get(platform_key, []):
|
|
95
|
+
if os.path.exists(path):
|
|
96
|
+
return path
|
|
97
|
+
for cmd in SYSTEM_BROWSER_COMMANDS:
|
|
98
|
+
found = shutil.which(cmd)
|
|
99
|
+
if found:
|
|
100
|
+
return found
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def find_system_java():
|
|
104
|
+
"""PATH上のjavaコマンドを探し、PlantUML(最新版はJava 11+要求)を実行できるバージョンか
|
|
105
|
+
確認する。見つからない、またはバージョンが古い場合はNoneを返す(#22)。
|
|
106
|
+
見つかった場合はensure_temurin_jre()によるJRE取得を回避できる(2章の最小限のダウンロード)。"""
|
|
107
|
+
java_path = shutil.which("java")
|
|
108
|
+
if not java_path:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
result = subprocess.run([java_path, "-version"], capture_output=True, text=True, timeout=10)
|
|
112
|
+
except OSError:
|
|
113
|
+
return None
|
|
114
|
+
# java -version は慣習的にstderrへ出力される(stdoutは空のことが多い)
|
|
115
|
+
output = result.stderr or result.stdout
|
|
116
|
+
m = re.search(r'version "([\d.]+)', output)
|
|
117
|
+
if not m:
|
|
118
|
+
return None
|
|
119
|
+
parts = m.group(1).split('.')
|
|
120
|
+
major = int(parts[0])
|
|
121
|
+
if major == 1 and len(parts) > 1:
|
|
122
|
+
# 旧来の "1.8.0_xxx" 形式(Java 8以前)。実質バージョンは2つ目の要素。
|
|
123
|
+
major = int(parts[1])
|
|
124
|
+
return java_path if major >= 11 else None
|
|
125
|
+
|
|
126
|
+
def find_system_d2():
|
|
127
|
+
"""PATH上のdコマンドを探す。PlantUMLのJava 11+のようなバージョン下限は無いため、
|
|
128
|
+
存在確認のみ行う(#90)。見つかった場合はensure_d2_binary()によるダウンロードを回避できる
|
|
129
|
+
(2章の最小限のダウンロード)。"""
|
|
130
|
+
return shutil.which("d2")
|
|
131
|
+
|
|
132
|
+
def _typst_version_info(repo_root):
|
|
133
|
+
"""typstのインストール済みバージョンと、requirements.txtでピン留めされたバージョンを返す
|
|
134
|
+
(#49のビルド時チェックと#37の--check-envで共用する)。requirements.txtが無い/`typst`の
|
|
135
|
+
行が無い場合、pinned側はNoneになる。repo_rootは「クローンして直接叩く」場合のリポジトリ
|
|
136
|
+
ルート(pipインストール後は同梱されないため、requirements.txtは見つからず自然にスキップ
|
|
137
|
+
される、#111)。"""
|
|
138
|
+
try:
|
|
139
|
+
installed_version = importlib.metadata.version("typst")
|
|
140
|
+
except importlib.metadata.PackageNotFoundError:
|
|
141
|
+
installed_version = None
|
|
142
|
+
|
|
143
|
+
requirements_path = os.path.join(repo_root, "requirements.txt")
|
|
144
|
+
pinned_version = None
|
|
145
|
+
if os.path.exists(requirements_path):
|
|
146
|
+
with open(requirements_path, "r", encoding="utf-8") as f:
|
|
147
|
+
requirements_text = f.read()
|
|
148
|
+
m = re.search(r'^typst==([\w.]+)', requirements_text, re.MULTILINE)
|
|
149
|
+
if m:
|
|
150
|
+
pinned_version = m.group(1)
|
|
151
|
+
return installed_version, pinned_version
|
|
152
|
+
|
|
153
|
+
def check_typst_version(repo_root):
|
|
154
|
+
"""requirements.txtでピン留めされたtypstのバージョンと、実際にインストールされている
|
|
155
|
+
バージョンが一致するかを確認する(#49)。9章の決定論的出力の前提が崩れていないかの
|
|
156
|
+
簡易チェック。不一致でも警告のみでビルドは継続する(Fail-fastにはしない)。
|
|
157
|
+
毎回のビルド時に自動実行される。requirements.txtが見つからない、または`typst`の行が
|
|
158
|
+
無い場合は何もしない(将来pipインストール化された場合等を想定)。"""
|
|
159
|
+
installed_version, pinned_version = _typst_version_info(repo_root)
|
|
160
|
+
if installed_version and pinned_version and installed_version != pinned_version:
|
|
161
|
+
print(f"[Warning] Installed typst version ({installed_version}) does not match "
|
|
162
|
+
f"the version pinned in requirements.txt ({pinned_version}). Output may differ "
|
|
163
|
+
f"from what's expected (see spec ch.9, deterministic output). "
|
|
164
|
+
f"Run: pip install typst=={pinned_version}")
|
|
165
|
+
|
|
166
|
+
# 実行環境の前提を事前確認する`--check-env`(#37)。status: "OK"/"WARN"/"NG"。
|
|
167
|
+
# NGはこのままではビルドが失敗する状態、WARNは動作はするが何か(自動ダウンロード等)が
|
|
168
|
+
# 起きる状態、を表す。ビルド失敗時の原因切り分け(該当項目だけの再チェック)にも使う。
|
|
169
|
+
CheckResult = namedtuple("CheckResult", ["name", "status", "message"])
|
|
170
|
+
|
|
171
|
+
def _is_store_python_base(base_prefix):
|
|
172
|
+
"""base_prefixがMicrosoft Store版Pythonのインストール先かどうかを判定する(#138)。
|
|
173
|
+
Store版Pythonは`C:\\Program Files\\WindowsApps\\<Publisher>.<Name>_...`配下にインストール
|
|
174
|
+
される。venv/pipxが作る`python.exe`はCPython本体のコピーではなく別物の小さなランチャー
|
|
175
|
+
(venvlauncher)で、`pyvenv.cfg`の`home`を通じて実行時にこのインストール先を参照しにいく
|
|
176
|
+
ため、venv/pipx環境の内部であってもここが判定対象になる(実機検証済み)。"""
|
|
177
|
+
return sys.platform == "win32" and "\\windowsapps\\" in base_prefix.lower()
|
|
178
|
+
|
|
179
|
+
def _check_isolated_env():
|
|
180
|
+
"""Windows + Microsoft Store版Pythonでは、ファイルシステムの透過的なリダイレクトにより、
|
|
181
|
+
サブプロセス(PlantUML用のJava等)がキャッシュファイルを見失う既知の問題がある(実機で
|
|
182
|
+
確認済み、#113)。venv/pipxで作った`python.exe`(venvlauncher)も、`pyvenv.cfg`の`home`を
|
|
183
|
+
通じて結局Store版Pythonのインストール先を参照し続けるため、venv/pipxで隔離してもこの問題を
|
|
184
|
+
回避できないことが判明した(実機検証済み、#138)。そのため「隔離環境を使っているか」では
|
|
185
|
+
なく「ベースのPythonがStore版かどうか」を直接判定し、Store版なら隔離環境の有無に関わらず
|
|
186
|
+
NGとする。隔離環境の使用自体は(Store版でなければ)引き続き一般的なベストプラクティスとして
|
|
187
|
+
WARNで推奨する。"""
|
|
188
|
+
base_prefix = sys.base_prefix
|
|
189
|
+
if _is_store_python_base(base_prefix):
|
|
190
|
+
return CheckResult("isolated environment", "NG",
|
|
191
|
+
f"the underlying Python ({base_prefix}) is the Microsoft Store "
|
|
192
|
+
"distribution. This tool cannot be used with it -- a venv/pipx "
|
|
193
|
+
"environment created from it does NOT avoid the problem, since its "
|
|
194
|
+
"`python.exe` still refers back to this same Store installation at "
|
|
195
|
+
"runtime (verified; see issue #138). Install Python from "
|
|
196
|
+
"https://www.python.org/downloads/ (or e.g. `winget install "
|
|
197
|
+
"Python.Python.3.12`), then recreate your venv/pipx environment "
|
|
198
|
+
"using that Python instead.")
|
|
199
|
+
if sys.prefix != sys.base_prefix:
|
|
200
|
+
return CheckResult("isolated environment", "OK", "running inside a venv/pipx-managed environment")
|
|
201
|
+
return CheckResult("isolated environment", "WARN",
|
|
202
|
+
"not running inside an isolated environment (venv/pipx). Recommended: "
|
|
203
|
+
"`pipx install text-compositor` (end users) or a venv + "
|
|
204
|
+
"`pip install -e .` (developers)")
|
|
205
|
+
|
|
206
|
+
def _check_pyyaml(config_path):
|
|
207
|
+
"""PyYAML(`import yaml`、ファイル冒頭でオプショナルインポート)の導入状況を確認する。
|
|
208
|
+
YAML形式のconfigを使う場合のみ必須(JSON設定なら不要)。config未指定時は既定の
|
|
209
|
+
探索対象がYAMLのため、YAML想定として扱う。"""
|
|
210
|
+
uses_yaml = config_path is None or config_path.lower().endswith((".yaml", ".yml"))
|
|
211
|
+
if yaml is not None:
|
|
212
|
+
return CheckResult("PyYAML", "OK", "installed")
|
|
213
|
+
if uses_yaml:
|
|
214
|
+
return CheckResult("PyYAML", "NG", "not installed but a YAML config is used. Run: pip install PyYAML==6.0.2")
|
|
215
|
+
return CheckResult("PyYAML", "OK", "not installed, but not needed for a JSON config")
|
|
216
|
+
|
|
217
|
+
def _check_typst_env(repo_root):
|
|
218
|
+
installed_version, pinned_version = _typst_version_info(repo_root)
|
|
219
|
+
if pinned_version is None:
|
|
220
|
+
return CheckResult("typst", "OK", f"{installed_version} installed (no pinned version in requirements.txt to compare)")
|
|
221
|
+
if installed_version == pinned_version:
|
|
222
|
+
return CheckResult("typst", "OK", f"{installed_version} (matches requirements.txt)")
|
|
223
|
+
return CheckResult("typst", "WARN", f"{installed_version} installed but requirements.txt pins {pinned_version}")
|
|
224
|
+
|
|
225
|
+
def _check_font_cache():
|
|
226
|
+
font_dir = os.path.join(_user_cache_dir(), "fonts", "NotoSansJP")
|
|
227
|
+
missing = [name for name in NOTO_SANS_JP_FILES if not os.path.exists(os.path.join(font_dir, name))]
|
|
228
|
+
if not missing:
|
|
229
|
+
return CheckResult("Noto Sans JP font", "OK", f"cached under {font_dir}")
|
|
230
|
+
return CheckResult("Noto Sans JP font", "WARN", "not cached yet; will be downloaded (one-time) on first build")
|
|
231
|
+
|
|
232
|
+
def _check_mermaid(mermaid_enabled, mermaid_auto_download):
|
|
233
|
+
if not mermaid_enabled:
|
|
234
|
+
return CheckResult("mermaid", "OK", "disabled (plugins.mermaid: false)")
|
|
235
|
+
try:
|
|
236
|
+
import playwright.sync_api # noqa: F401
|
|
237
|
+
except ImportError:
|
|
238
|
+
return CheckResult("mermaid", "NG", "the 'playwright' package is not installed. Run: pip install playwright==1.62.0")
|
|
239
|
+
browser_path = find_system_browser()
|
|
240
|
+
if browser_path:
|
|
241
|
+
return CheckResult("mermaid", "OK", f"system browser found: {browser_path}")
|
|
242
|
+
if mermaid_auto_download:
|
|
243
|
+
return CheckResult("mermaid", "WARN",
|
|
244
|
+
"no system Chrome/Edge found; Playwright will download its own Chromium "
|
|
245
|
+
"(one-time; approx. 700MB) on first mermaid render")
|
|
246
|
+
return CheckResult("mermaid", "NG",
|
|
247
|
+
"no system Chrome/Edge found and plugins.mermaid_auto_download is false. "
|
|
248
|
+
"Install Google Chrome or Microsoft Edge, or set plugins.mermaid_auto_download: true")
|
|
249
|
+
|
|
250
|
+
def _check_plantuml(plantuml_enabled, plantuml_auto_download):
|
|
251
|
+
if not plantuml_enabled:
|
|
252
|
+
return CheckResult("plantuml", "OK", "disabled (plugins.plantuml: false)")
|
|
253
|
+
java_path = find_system_java()
|
|
254
|
+
if java_path:
|
|
255
|
+
return CheckResult("plantuml", "OK", f"system Java 11+ found: {java_path}")
|
|
256
|
+
key = _temurin_platform_key()
|
|
257
|
+
asset = TEMURIN_JRE_ASSETS.get(key)
|
|
258
|
+
if asset:
|
|
259
|
+
_, _, _, java_rel_parts = asset
|
|
260
|
+
java_bin_path = os.path.join(_jre_cache_root(), TEMURIN_JRE_TOP_DIR, *java_rel_parts)
|
|
261
|
+
if os.path.exists(java_bin_path):
|
|
262
|
+
return CheckResult("plantuml", "OK", f"no local Java 11+, but Eclipse Temurin JRE already cached under {_jre_cache_root()}")
|
|
263
|
+
if plantuml_auto_download:
|
|
264
|
+
return CheckResult("plantuml", "WARN",
|
|
265
|
+
"no local Java 11+ found; Eclipse Temurin JRE will be downloaded "
|
|
266
|
+
"(one-time; approx. 50MB) on first plantuml render")
|
|
267
|
+
return CheckResult("plantuml", "NG",
|
|
268
|
+
"no local Java 11+ found and plugins.plantuml_auto_download is false. "
|
|
269
|
+
"Install Java 11+, or set plugins.plantuml_auto_download: true")
|
|
270
|
+
|
|
271
|
+
def _check_d2(d2_enabled, d2_auto_download):
|
|
272
|
+
if not d2_enabled:
|
|
273
|
+
return CheckResult("d2", "OK", "disabled (plugins.d2: false)")
|
|
274
|
+
d2_path = find_system_d2()
|
|
275
|
+
if d2_path:
|
|
276
|
+
return CheckResult("d2", "OK", f"system D2 found: {d2_path}")
|
|
277
|
+
key = _temurin_platform_key()
|
|
278
|
+
asset = D2_ASSETS.get(key)
|
|
279
|
+
if asset:
|
|
280
|
+
d2_bin_path = _d2_bin_path(_d2_cache_root(), key[0])
|
|
281
|
+
if os.path.exists(d2_bin_path):
|
|
282
|
+
return CheckResult("d2", "OK", f"no local D2, but the D2 CLI is already cached under {_d2_cache_root()}")
|
|
283
|
+
if d2_auto_download:
|
|
284
|
+
return CheckResult("d2", "WARN",
|
|
285
|
+
"no local D2 found; the D2 CLI binary will be downloaded "
|
|
286
|
+
"(one-time; approx. 13MB) on first d2 render")
|
|
287
|
+
return CheckResult("d2", "NG",
|
|
288
|
+
"no local D2 found and plugins.d2_auto_download is false. "
|
|
289
|
+
"Install D2 (https://d2lang.com), or set plugins.d2_auto_download: true")
|
|
290
|
+
|
|
291
|
+
def run_env_check(repo_root, config_path):
|
|
292
|
+
"""`--check-env`本体。configを指定すればそのplugins設定を反映し、未指定なら全項目を
|
|
293
|
+
既定値(すべて有効)でチェックする。実際のビルドは行わない。戻り値はexit code
|
|
294
|
+
(NGが1件でもあれば1、無ければ0。WARNのみ・全部OKなら0)。"""
|
|
295
|
+
if config_path is not None:
|
|
296
|
+
_project_dir, config, _chapters = _load_project_config(config_path)
|
|
297
|
+
plugins_config = config.get("plugins") or {}
|
|
298
|
+
else:
|
|
299
|
+
plugins_config = {}
|
|
300
|
+
mermaid_enabled = bool(plugins_config.get("mermaid", True))
|
|
301
|
+
mermaid_auto_download = bool(plugins_config.get("mermaid_auto_download", False))
|
|
302
|
+
plantuml_enabled = bool(plugins_config.get("plantuml", True))
|
|
303
|
+
plantuml_auto_download = bool(plugins_config.get("plantuml_auto_download", True))
|
|
304
|
+
d2_enabled = bool(plugins_config.get("d2", True))
|
|
305
|
+
d2_auto_download = bool(plugins_config.get("d2_auto_download", True))
|
|
306
|
+
|
|
307
|
+
results = [
|
|
308
|
+
_check_isolated_env(),
|
|
309
|
+
_check_pyyaml(config_path),
|
|
310
|
+
_check_typst_env(repo_root),
|
|
311
|
+
_check_font_cache(),
|
|
312
|
+
_check_mermaid(mermaid_enabled, mermaid_auto_download),
|
|
313
|
+
_check_plantuml(plantuml_enabled, plantuml_auto_download),
|
|
314
|
+
_check_d2(d2_enabled, d2_auto_download),
|
|
315
|
+
]
|
|
316
|
+
_print_check_results(results)
|
|
317
|
+
return 1 if any(r.status == "NG" for r in results) else 0
|
|
318
|
+
|
|
319
|
+
def _print_check_results(results):
|
|
320
|
+
for r in results:
|
|
321
|
+
print(f"[{r.status}] {r.name}: {r.message}")
|
|
322
|
+
counts = {"OK": 0, "WARN": 0, "NG": 0}
|
|
323
|
+
for r in results:
|
|
324
|
+
counts[r.status] += 1
|
|
325
|
+
print(f"\nSummary: {counts['OK']} OK, {counts['WARN']} WARN, {counts['NG']} NG")
|
|
326
|
+
|
|
327
|
+
class TypstRenderer:
|
|
328
|
+
"""
|
|
329
|
+
markdown-it-py が生成したAST(構文木)を走査し、
|
|
330
|
+
安全かつ正確にTypst構文へ変換するカスタムレンダラー
|
|
331
|
+
"""
|
|
332
|
+
# 行頭に来るとTypstのブロック記法(見出し/リスト/用語リスト)として解釈される記号
|
|
333
|
+
BLOCK_HEAD_RE = re.compile(r'^([ \t]*)(=+|[-+/]|[0-9]+[.)])(?=\s|$)')
|
|
334
|
+
|
|
335
|
+
# 冒頭のfront-matter(Marp/Jekyll形式)。CommonMarkでは水平線+段落に見えてしまうため先に切り離す
|
|
336
|
+
FRONT_MATTER_RE = re.compile(
|
|
337
|
+
r'\A?---[ \t]*\r?\n(.*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
|
338
|
+
|
|
339
|
+
# front-matter のうちMarp固有で本ツールでは意味を持たないキー。
|
|
340
|
+
# header/footer/paginateは#42でlandscape/paper_sizeと同じ弱い優先順位で適用する対象に昇格した
|
|
341
|
+
# (chapters[]の明示指定が無い場合のみ使われる)ため、ここには含めない。
|
|
342
|
+
MARP_ONLY_KEYS = {'marp', 'theme', 'size', 'class', 'style', 'backgroundColor'}
|
|
343
|
+
|
|
344
|
+
# ::: layout-right / layout-left / layout-compare / layout-feature / layout-columns /
|
|
345
|
+
# layout-takahashi / align ... ::: ブロック。ブロック名の後ろに`{key=value ...}`という
|
|
346
|
+
# Pandoc風の中括弧属性を書ける(#83)。
|
|
347
|
+
# - layout-right: 中の図(mermaid/plantuml/dot/graphvizフェンス、または単独行のMarkdown画像)
|
|
348
|
+
# を右、それ以外のテキストを左に配置する。`{left=30 right=70}`のように左:右の比率
|
|
349
|
+
# (Typstのfr単位。合計100である必要はない)を指定できる。省略時は35:65(#81)。
|
|
350
|
+
# - layout-left: layout-rightの左右反転版(図を左、テキストを右)。比率記法は同じで、省略時は
|
|
351
|
+
# 65:35(#81)。
|
|
352
|
+
# - layout-compare: 中の2つの図を左右に並べる(横長の図同士の比較用)。図の種類は混在可(例:
|
|
353
|
+
# 片方mermaid・もう片方は写真)。
|
|
354
|
+
# - layout-feature: 写真(または図)をフルブリードで敷き、下部にキャッチコピーを重ねる(#78)。
|
|
355
|
+
# - layout-columns: 中身(任意のMarkdown)を`{n=N}`で指定した列数(省略時2列)のcolumns()に
|
|
356
|
+
# 流し込む(#78)。
|
|
357
|
+
# - layout-takahashi: 中身(任意のMarkdown)を画面の上下左右中央・大きな文字で表示する
|
|
358
|
+
# (高橋メソッド、#95)。`{size=...}`で既定の文字サイズ(TAKAHASHI_DEFAULT_SIZE)を
|
|
359
|
+
# 上書きできる。
|
|
360
|
+
# - align: 中身(任意のMarkdown、複数段落可)を`{align=center}`/`{align=right}`で指定した
|
|
361
|
+
# 寄せでラップする。画像のalign属性(#75)と同じく、指定しない場合の既定の見た目(左寄せ)
|
|
362
|
+
# は変わらない(#87)。
|
|
363
|
+
# markdown-it の通常のASTフローでは「直前・直後のテキストと図をまとめて2カラム化する」表現が
|
|
364
|
+
# 難しいため、通常のトークン処理に入る前の生テキスト段階で切り出して個別に処理する(#11)。
|
|
365
|
+
# 対応する図の種類をmermaidだけに限らず一般化したもの(#77)。
|
|
366
|
+
LAYOUT_BLOCK_RE = re.compile(
|
|
367
|
+
r'^::: *(layout-right|layout-left|layout-compare|layout-feature|layout-columns|'
|
|
368
|
+
r'layout-takahashi|align)'
|
|
369
|
+
r'(?: +\{([^}\r\n]*)\})? *\r?\n(.*?)\r?\n::: *\r?$',
|
|
370
|
+
re.MULTILINE | re.DOTALL)
|
|
371
|
+
# フェンス(mermaid/plantuml/dot/graphviz/svg/d2)か、単独行のMarkdown画像(``のみの行)の
|
|
372
|
+
# いずれかにマッチする。画像側は行全体にアンカーし、文中に埋め込まれたインライン画像を誤って
|
|
373
|
+
# 抜き出さないようにする(テキストの前後を単純に連結する都合上、行の一部だけを抜くと文が壊れる)。
|
|
374
|
+
# 言語名の後ろに`{width=50%}`のようなPandoc風のサイズ指定属性を書ける(#82)。
|
|
375
|
+
# svgはmermaid/plantumlと異なりレンダリング不要(コードそのものが既に完成した画像)だが、
|
|
376
|
+
# 「図/画像を1つ含む」という抽出対象としては同列に扱える(#91)。
|
|
377
|
+
DIAGRAM_OR_IMAGE_RE = re.compile(
|
|
378
|
+
r'```(?P<lang>mermaid|plantuml|dot|graphviz|svg|d2)(?P<attrs>[ \t]+\{[^}\r\n]*\})?[ \t]*\r?\n(?P<code>.*?)\r?\n```'
|
|
379
|
+
r'|^[ \t]*(?P<image>!\[[^\]]*\]\([^)\n]+\))[ \t]*\r?$',
|
|
380
|
+
re.MULTILINE | re.DOTALL)
|
|
381
|
+
# フェンスのinfo string('mermaid'や'{width=50% height=8cm}'の中身)からwidth=/height=を
|
|
382
|
+
# 取り出す。値に空白は使えない前提(Typstの寸法値・パーセントはいずれも空白を含まないため)。
|
|
383
|
+
FENCE_ATTR_RE = re.compile(r'(\w+)=([^\s{}]+)')
|
|
384
|
+
|
|
385
|
+
def _fenced_char_ranges(self, text):
|
|
386
|
+
"""LAYOUT_BLOCK_RE/DIAGRAM_OR_IMAGE_REは、markdown-itの通常のASTフローを経由しない、生テキスト
|
|
387
|
+
段階での正規表現マッチである(#11、#77)。そのため、使い方説明用のサンプルコードのように
|
|
388
|
+
外側の```/````フェンスで囲まれた範囲の中にたまたま`:::`ブロックや図表フェンス・画像参照と
|
|
389
|
+
同じ見た目の文字列があると、本物のレイアウトブロック/図表として誤検出してしまう(#85)。
|
|
390
|
+
textをmarkdown-itで一度パースし、最上位のfenceトークンが占める行範囲を文字オフセット範囲へ
|
|
391
|
+
変換して返す。ネストした```はCommonMarkの仕様上、外側フェンスの中身の一部として扱われ、
|
|
392
|
+
個別のfenceトークンとしては現れないため、この範囲を「保護区間」として使える。"""
|
|
393
|
+
tokens = self.md.parse(text)
|
|
394
|
+
fence_line_maps = [t.map for t in tokens if t.type == 'fence' and t.map]
|
|
395
|
+
if not fence_line_maps:
|
|
396
|
+
return []
|
|
397
|
+
line_starts = [0]
|
|
398
|
+
for line in text.splitlines(keepends=True):
|
|
399
|
+
line_starts.append(line_starts[-1] + len(line))
|
|
400
|
+
ranges = []
|
|
401
|
+
for start_line, end_line in fence_line_maps:
|
|
402
|
+
start_off = line_starts[start_line] if start_line < len(line_starts) else len(text)
|
|
403
|
+
end_off = line_starts[end_line] if end_line < len(line_starts) else len(text)
|
|
404
|
+
ranges.append((start_off, end_off))
|
|
405
|
+
return ranges
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _in_ranges(offset, ranges):
|
|
409
|
+
# 厳密な不等号(start <)にしているのは、探している対象そのもの(layout-right等の中に
|
|
410
|
+
# 実際に置かれた図表フェンス自身)と、外側フェンスに包まれた説明用サンプルの中に
|
|
411
|
+
# ネストして現れる同じ見た目の文字列とを区別するため(#127)。ネストした場合、実際の
|
|
412
|
+
# マッチ開始位置は必ず外側フェンス(保護区間)の開始位置より後ろに来る。一方、探している
|
|
413
|
+
# 対象自身が最上位のfenceトークンである場合、マッチ開始位置は保護区間の開始位置と
|
|
414
|
+
# 完全に一致する。start<=だと後者まで誤って除外してしまい、layout-right等の中に本物の
|
|
415
|
+
# 図表フェンスを置くという主目的そのものが常に失敗していた。
|
|
416
|
+
return any(start < offset < end for start, end in ranges)
|
|
417
|
+
|
|
418
|
+
def _finditer_outside_fences(self, regex, text):
|
|
419
|
+
"""regex.finditer(text)のうち、_fenced_char_rangesで求めた保護区間(外側フェンスの中)に
|
|
420
|
+
あるマッチを除外して返す(#85)。"""
|
|
421
|
+
ranges = self._fenced_char_ranges(text)
|
|
422
|
+
return [m for m in regex.finditer(text) if not self._in_ranges(m.start(), ranges)]
|
|
423
|
+
|
|
424
|
+
def _search_outside_fences(self, regex, text):
|
|
425
|
+
"""_finditer_outside_fencesの最初の1件版(.search()相当、#85)。"""
|
|
426
|
+
matches = self._finditer_outside_fences(regex, text)
|
|
427
|
+
return matches[0] if matches else None
|
|
428
|
+
|
|
429
|
+
# Marpディレクティブコメント。7章の要件(Marp原稿との共用)を満たすため認識はするが、
|
|
430
|
+
# 何も反映しない(#41、_handle_html_tokenを参照)。#42でheader/footer/paginateがfront-matter/
|
|
431
|
+
# chapters[]経由では適用対象になったが、このインラインHTMLコメント形式は意図的に対象外のまま
|
|
432
|
+
# (ファイル内の任意の位置から「以降に持続する」という#16と同種の危険な性質を持つため)。
|
|
433
|
+
DIRECTIVE_RE = re.compile(r'^<!--\s*(header|footer|paginate)\s*:.*-->\s*$')
|
|
434
|
+
|
|
435
|
+
# 改ページを明示する記法(#92)。document.marp_compat: falseのとき、hr(---等)が単なる
|
|
436
|
+
# 水平線になる代わりに使う。header/footer/paginateと違い「その場1回だけ効くアクション」で
|
|
437
|
+
# 状態を持ち越さないため、#41の懸念(章をまたいで持続する設計は並べ替えと衝突する)には
|
|
438
|
+
# 抵触しない。marp_compatの値に関わらず常に有効(hrの挙動と独立した明示的な記法のため)。
|
|
439
|
+
PAGEBREAK_DIRECTIVE_RE = re.compile(r'^<!--\s*pagebreak\s*-->\s*$')
|
|
440
|
+
|
|
441
|
+
# GitHub Wiki拡張の用語索引記法(#47、#48)。[[用語]]の素の形のみ対応し、区切り記法
|
|
442
|
+
# ([[表示|ページ]])は使い方が分かりにくいとして不採用(#48)。[[/]]は空にならないよう
|
|
443
|
+
# 中身を1文字以上必須にし、ネストした角括弧(通常の[link]記法との衝突)は対象外にする。
|
|
444
|
+
WIKILINK_RE = re.compile(r'\[\[([^\[\]]+)\]\]')
|
|
445
|
+
|
|
446
|
+
# 文字色指定(#46)。<span style="color:...">は「閉じた許可リスト」への1パターン追加として
|
|
447
|
+
# 狭く特別扱いする(それ以外のHTMLタグは従来どおり非対応・警告のまま)。もう1つの記法
|
|
448
|
+
# ([text]{color=red}、Pandoc由来)はmdit_py_plugins.attrsのspan機能で処理する。
|
|
449
|
+
HTML_SPAN_COLOR_OPEN_RE = re.compile(r'^<span\s+style\s*=\s*["\']color\s*:\s*([^;"\']+?)\s*;?\s*["\']\s*>$', re.IGNORECASE)
|
|
450
|
+
HTML_SPAN_CLOSE_RE = re.compile(r'^</span\s*>$', re.IGNORECASE)
|
|
451
|
+
|
|
452
|
+
# フォントサイズ指定(#93)。[text]{size=10pt}のsize属性、front-matterのfont_sizeの両方で使う
|
|
453
|
+
# 共通フォーマット。Typstの#text(size: ...)にそのまま渡せる"10pt"/"10.5pt"のような値のみ許可する。
|
|
454
|
+
FONT_SIZE_RE = re.compile(r'^\d+(\.\d+)?pt$')
|
|
455
|
+
|
|
456
|
+
# GitHub形式のalert記法(#61)。`> [!NOTE]`のように、blockquoteの最初の行がこのマーカーだけの
|
|
457
|
+
# ときだけ発動する。テンプレート側は@preview/note-me(MIT、#63でライセンス確認済み)が持つ
|
|
458
|
+
# note/tip/important/warning/cautionをcallout()でラップして呼び出す。
|
|
459
|
+
ALERT_MARKER_RE = re.compile(r'^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$')
|
|
460
|
+
|
|
461
|
+
def __init__(self, base_dir=None, typst_root=None, mermaid_enabled=True, mermaid_auto_download=False,
|
|
462
|
+
plantuml_enabled=True, plantuml_auto_download=True, d2_enabled=True, d2_auto_download=True,
|
|
463
|
+
glossary_enabled=False, line_mapping="block", marp_compat=False):
|
|
464
|
+
# 対応するMarkdown記法のスコープはGFM + GitHub Wiki(#48)。table/strikethroughはGFM拡張だが
|
|
465
|
+
# commonmarkプリセットにコアルールとして同梱されており、enable()するだけで使える。
|
|
466
|
+
self.md = (MarkdownIt("commonmark").enable("table").enable("strikethrough")
|
|
467
|
+
.use(tasklists_plugin)
|
|
468
|
+
.use(attrs_plugin, spans=True, span_after="link", allowed=["color", "size"]))
|
|
469
|
+
self.list_stack = []
|
|
470
|
+
self.current_file = ""
|
|
471
|
+
self.current_dir = ""
|
|
472
|
+
# base_dir: プロジェクト側の基準ディレクトリ(画像・mermaidキャッシュの相対パス解決に使う)
|
|
473
|
+
self.base_dir = base_dir or os.path.dirname(os.path.abspath(__file__))
|
|
474
|
+
# typst_root: typst compile の --root と同じ値。base_dirとツール本体(templates/)の
|
|
475
|
+
# 両方を跨いでも解決できるよう、image()呼び出しはこれを起点にルート絶対パスで組み立てる
|
|
476
|
+
self.typst_root = typst_root or os.path.dirname(self.base_dir)
|
|
477
|
+
self.allow_exec = False
|
|
478
|
+
self.front_matter = {}
|
|
479
|
+
# plugins.mermaid: false(6章、#21)。falseなら```mermaidフェンスをヘッドレスブラウザで
|
|
480
|
+
# 描画せず、他の未対応言語と同じく素のコード表示にフォールバックする。
|
|
481
|
+
self.mermaid_enabled = mermaid_enabled
|
|
482
|
+
self._mermaid_disabled_warned = False
|
|
483
|
+
# plugins.mermaid_auto_download: false(既定。#22の設計議論を踏まえて追加)。システムに
|
|
484
|
+
# Chrome/Edgeが無い場合、falseならFail-fast(従来どおり)、trueならPlaywright自身の
|
|
485
|
+
# Chromiumをダウンロードして使う(実測約700MB。#34/#35で避けた重いダウンロードそのものなので
|
|
486
|
+
# 既定はfalseのまま。手元にどうしても持っていない場合の最後の手段として明示的に選ばせる)。
|
|
487
|
+
self.mermaid_auto_download = mermaid_auto_download
|
|
488
|
+
# Mermaidレンダリング用ヘッドレスブラウザのライフサイクル状態。最初のmermaid図を描画する
|
|
489
|
+
# ときに遅延起動し、ビルド終了時にclose()で片付ける(複数の図で1つのブラウザ・ページを
|
|
490
|
+
# 使い回し、図ごとに起動し直さない)。
|
|
491
|
+
self._mermaid_page = None
|
|
492
|
+
self._mermaid_browser = None
|
|
493
|
+
self._mermaid_playwright = None
|
|
494
|
+
self._mermaid_chrome_proc = None
|
|
495
|
+
self._mermaid_profile_dir = None
|
|
496
|
+
# document.table_header / chapters[].table_headerのマージ結果(#45)。
|
|
497
|
+
# _render_markdown_chapterが章ごとに設定する。bold/background/colorいずれも
|
|
498
|
+
# 未指定なら従来どおり無装飾(キーが無ければ何もしない)。
|
|
499
|
+
self.table_header_style = {}
|
|
500
|
+
# document.glossary: false(既定。#47)。falseなら[[用語]]は素の文字列としてそのまま通す
|
|
501
|
+
# (trueの場合のみWIKILINK_REで検出・登録する)。用語ごとの出現ラベルID一覧を、全チャプター
|
|
502
|
+
# を跨いで蓄積する(dict、Python 3.7+で挿入順を保持。ビルド末尾で巻末索引の生成に使う)。
|
|
503
|
+
self.glossary_enabled = glossary_enabled
|
|
504
|
+
self.glossary_terms = {}
|
|
505
|
+
# document.marp_compat: false(既定、#92)。falseならCommonMark準拠で、hr(---/***/___の
|
|
506
|
+
# いずれも)は単なる水平線として描画し、改ページは<!-- pagebreak -->で明示する。trueなら
|
|
507
|
+
# 実際のMarpit(---/***/___のいずれもスライド区切りとして扱う)に忠実に、hrを一律
|
|
508
|
+
# 改ページとして描画する(従来の挙動)。
|
|
509
|
+
self.marp_compat = marp_compat
|
|
510
|
+
self._glossary_label_counter = 0
|
|
511
|
+
# plugins.plantuml: true(既定。#22)。falseなら```plantumlフェンスをローカルのjava+
|
|
512
|
+
# plantuml.jarで描画せず、他の未対応言語と同じく素のコード表示にフォールバックする。
|
|
513
|
+
self.plantuml_enabled = plantuml_enabled
|
|
514
|
+
self._plantuml_disabled_warned = False
|
|
515
|
+
# plugins.plantuml_auto_download: true(既定)。システムにJava 11+が無い場合、trueなら
|
|
516
|
+
# Eclipse Temurin JREを自動取得(実測約49.7MB。Chromiumの約700MBと違い許容できる規模)、
|
|
517
|
+
# falseならFail-fast。mermaidと非対称な既定値なのは意図的(ダウンロードされる実体の
|
|
518
|
+
# サイズが1桁違うため。#22の設計議論を参照)。
|
|
519
|
+
self.plantuml_auto_download = plantuml_auto_download
|
|
520
|
+
# java実行ファイル・plantuml.jarのパスは初回の```plantuml描画時に遅延解決する
|
|
521
|
+
# (mermaidのヘッドレスブラウザと異なり常駐プロセスではないため、都度subprocessで起動する)。
|
|
522
|
+
self._plantuml_java_bin = None
|
|
523
|
+
self._plantuml_jar_path = None
|
|
524
|
+
# plugins.d2: true(既定。#90)。falseなら```d2フェンスをローカルのD2 CLIバイナリで
|
|
525
|
+
# 描画せず、他の未対応言語と同じく素のコード表示にフォールバックする。
|
|
526
|
+
self.d2_enabled = d2_enabled
|
|
527
|
+
self._d2_disabled_warned = False
|
|
528
|
+
# plugins.d2_auto_download: true(既定)。システムにdコマンドが無い場合、trueならD2公式
|
|
529
|
+
# CLIバイナリを自動取得(実測約13MB。plantumlのJRE(約49.7MB)よりさらに小さいため、
|
|
530
|
+
# plantuml_auto_downloadと同じくtrueを既定にする。#22の設計議論を参照)、falseならFail-fast。
|
|
531
|
+
self.d2_auto_download = d2_auto_download
|
|
532
|
+
# d2実行ファイルのパスは初回の```d2描画時に遅延解決する(plantumlのjava/jarと同様)。
|
|
533
|
+
self._d2_bin = None
|
|
534
|
+
# document.diagnostics.line_mapping: "block"(既定、#27)。Typstコンパイルエラーの行番号を
|
|
535
|
+
# 元のMarkdownの行番号へ逆引きするための行コメント(`// @srcmap ...`)を生成コードに
|
|
536
|
+
# 挿し込むかどうかの精度。"off"なら挿し込まず、従来どおりTypst側の生の行番号のみになる。
|
|
537
|
+
# リスト項目・テーブル行単位まで踏み込む"fine"は将来課題(Typstのリスト継続判定への
|
|
538
|
+
# 影響を実機検証してから対応する)。
|
|
539
|
+
self.line_mapping = line_mapping
|
|
540
|
+
|
|
541
|
+
# 拡張子ごとの構造化データ言語(Typstのraw()に渡すシンタックスハイライト名)。
|
|
542
|
+
# 入力となるテキストファイルはMarkdownに限らない(1章、#15)。
|
|
543
|
+
STRUCTURED_TEXT_LANGS = {'.yaml': 'yaml', '.yml': 'yaml', '.json': 'json'}
|
|
544
|
+
|
|
545
|
+
# 図表ソースファイルそのものをchaptersに直接指定できる拡張子(#53)。Markdown内の
|
|
546
|
+
# フェンスコードブロックと同じ描画機構をそのまま流用する(新しい描画ロジックは書かない)。
|
|
547
|
+
# .iumlはPlantUMLの!includeで取り込む断片ファイル用の慣習であり、単体の図として
|
|
548
|
+
# 使われないため対象外。
|
|
549
|
+
DIAGRAM_FILE_EXTS = {
|
|
550
|
+
'.dot': 'graphviz', '.gv': 'graphviz',
|
|
551
|
+
'.mmd': 'mermaid',
|
|
552
|
+
'.puml': 'plantuml', '.plantuml': 'plantuml', '.pu': 'plantuml',
|
|
553
|
+
'.d2': 'd2',
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
def render_chapter(self, text, filepath="", drop_leading_title=False):
|
|
557
|
+
"""chaptersの1ファイルを拡張子に応じて変換する(1章、#15)。
|
|
558
|
+
.md/.markdown以外はmarkdown-itに一切通さない。素のテキストやYAML/JSON中の
|
|
559
|
+
行頭記号(#, -, [ 等)がMarkdown構文として誤解釈され、静かに壊れるのを防ぐため。"""
|
|
560
|
+
ext = os.path.splitext(filepath)[1].lower()
|
|
561
|
+
if ext in ('.md', '.markdown'):
|
|
562
|
+
return self.render(text, filepath=filepath, drop_leading_title=drop_leading_title)
|
|
563
|
+
|
|
564
|
+
self.current_file = filepath
|
|
565
|
+
self.current_dir = os.path.dirname(os.path.abspath(filepath)) if filepath else self.base_dir
|
|
566
|
+
self.front_matter = {}
|
|
567
|
+
|
|
568
|
+
diagram_kind = self.DIAGRAM_FILE_EXTS.get(ext)
|
|
569
|
+
if diagram_kind == 'graphviz':
|
|
570
|
+
return self._render_raw_text(text, 'dot')
|
|
571
|
+
elif diagram_kind == 'mermaid':
|
|
572
|
+
return self._render_mermaid(text)
|
|
573
|
+
elif diagram_kind == 'plantuml':
|
|
574
|
+
return self._render_plantuml(text)
|
|
575
|
+
elif diagram_kind == 'd2':
|
|
576
|
+
return self._render_d2(text)
|
|
577
|
+
elif ext == '.csv':
|
|
578
|
+
return self._render_csv_table(text)
|
|
579
|
+
|
|
580
|
+
return self._render_raw_text(text, self.STRUCTURED_TEXT_LANGS.get(ext))
|
|
581
|
+
|
|
582
|
+
def _render_csv_table(self, text):
|
|
583
|
+
""".csvファイルをTypstの#table()へ変換する(#36)。区切り文字はカンマ固定(sniffingは
|
|
584
|
+
しない。このツールが一貫して採る「明示性優先・魔法をしない」方針に合わせる)。RFC 4180の
|
|
585
|
+
クォート処理(セル内カンマ・改行、""によるクォート文字自体のエスケープ)は標準ライブラリの
|
|
586
|
+
csvモジュールにそのまま委譲する。1行目をヘッダーとして扱い、既存のMarkdownテーブルと同じ
|
|
587
|
+
table_headerスタイル(bold/background/color)を適用する(10章のaggregateとは別の、任意の
|
|
588
|
+
表形式データ向けの汎用機能という位置づけ)。列数が不揃いな行は、他の描画失敗(mermaid等)と
|
|
589
|
+
同様にフォールバックせずFail-fastで即エラー終了する(9章の方針)。"""
|
|
590
|
+
# splitlines()で先に行分割すると、クォートされたセル内の改行(RFC 4180で許容される
|
|
591
|
+
# マルチライン値)まで失われる(csv.readerが行をまたいだクォートを復元する前に、
|
|
592
|
+
# 改行文字自体が消えてしまうため)。StringIOで生テキストのまま渡し、行分割自体を
|
|
593
|
+
# csv.readerに任せる。
|
|
594
|
+
rows = list(csv.reader(io.StringIO(text)))
|
|
595
|
+
if not rows:
|
|
596
|
+
print(f"[Error] {self.current_file} is an empty CSV file.")
|
|
597
|
+
sys.exit(1)
|
|
598
|
+
|
|
599
|
+
header, *body = rows
|
|
600
|
+
cols = len(header)
|
|
601
|
+
for row_no, row in enumerate(body, start=2):
|
|
602
|
+
if len(row) != cols:
|
|
603
|
+
print(f"[Error] {self.current_file}:{row_no}: expected {cols} columns (from the header row), "
|
|
604
|
+
f"got {len(row)}.")
|
|
605
|
+
sys.exit(1)
|
|
606
|
+
|
|
607
|
+
open_wrap, close_wrap = self._table_header_open_close()
|
|
608
|
+
result = [f'#table(\n columns: {cols}{self._table_header_fill_arg()},\n table.header(\n ']
|
|
609
|
+
for cell in header:
|
|
610
|
+
result.append('[' + open_wrap + self.escape_typst(cell, at_line_start=True) + close_wrap + '], ')
|
|
611
|
+
# table.header()はデフォルトでrepeat: trueのため、表がページを跨いだ次ページ以降にも
|
|
612
|
+
# ヘッダー行が自動的に再掲される(#70。Markdownテーブル側と同じ仕組み)。
|
|
613
|
+
result.append('\n ),\n ')
|
|
614
|
+
for row in body:
|
|
615
|
+
for cell in row:
|
|
616
|
+
result.append('[' + self.escape_typst(cell, at_line_start=True) + '], ')
|
|
617
|
+
result.append('\n ')
|
|
618
|
+
result.append('\n)\n\n')
|
|
619
|
+
return ''.join(result)
|
|
620
|
+
|
|
621
|
+
def _render_raw_text(self, text, lang=None):
|
|
622
|
+
"""Markdown以外のテキスト(プレーンテキスト・コード・YAML/JSON等)を、markdown-itを一切
|
|
623
|
+
通さずTypstのraw()で等幅表示する。通常の段落として流し込むとTypstのテキストモードが
|
|
624
|
+
連続する空白を折りたたみ、コードのインデント等が失われるため、raw()で改行・空白とも
|
|
625
|
+
そのまま保持する。lang未指定時(プレーンテキスト・未知拡張子)はシンタックスハイライトなし。
|
|
626
|
+
```` ``` ````フェンス構文だと本文中に```が含まれた場合に壊れるため、文字列リテラルとして渡す。"""
|
|
627
|
+
escaped = (text.replace('\\', '\\\\').replace('"', '\\"')
|
|
628
|
+
.replace('\r\n', '\n').replace('\n', '\\n'))
|
|
629
|
+
lang_arg = f'lang: "{lang}", ' if lang else ''
|
|
630
|
+
return f'#raw("{escaped}", {lang_arg}block: true)\n\n'
|
|
631
|
+
|
|
632
|
+
def _render_graphviz(self, lang, code, width=None, height=None):
|
|
633
|
+
"""```dot/```graphvizフェンスの内容をTypstコードへ変換する。width/height未指定時は
|
|
634
|
+
raw()化するだけで、Typst側のshow raw.where(lang: "dot"/"graphviz")ショールール
|
|
635
|
+
(テンプレート側のrender-graph、ページ幅超過時のみ自動縮小)に描画を委ねる。showルールは
|
|
636
|
+
it.text(コード文字列)しか受け取れずwidth/heightを渡す経路が無いため、明示指定時は
|
|
637
|
+
raw()経由をやめ、テンプレートが公開しているrender-graph()を直接呼び出すコードを生成する
|
|
638
|
+
(#82)。"""
|
|
639
|
+
if width is None and height is None:
|
|
640
|
+
return self._render_raw_text(code, lang)
|
|
641
|
+
escaped = (code.replace('\\', '\\\\').replace('"', '\\"')
|
|
642
|
+
.replace('\r\n', '\n').replace('\n', '\\n'))
|
|
643
|
+
width_arg = f', width: {width}' if width else ''
|
|
644
|
+
height_arg = f', height: {height}' if height else ''
|
|
645
|
+
return f'#align(center)[#render-graph("{escaped}"{width_arg}{height_arg})]\n\n'
|
|
646
|
+
|
|
647
|
+
def render(self, text, filepath="", drop_leading_title=False):
|
|
648
|
+
self.current_file = filepath
|
|
649
|
+
self.current_dir = os.path.dirname(os.path.abspath(filepath)) if filepath else self.base_dir
|
|
650
|
+
# 【修正】typst-exec は「人間レビュー済み (reviewed/)」配下のみ許可するホワイトリスト方式
|
|
651
|
+
self.allow_exec = "reviewed" in Path(os.path.abspath(filepath)).parts if filepath else False
|
|
652
|
+
text, self.front_matter = self.strip_front_matter(text)
|
|
653
|
+
|
|
654
|
+
output = []
|
|
655
|
+
pos = 0
|
|
656
|
+
first_segment = True
|
|
657
|
+
for m in self._finditer_outside_fences(self.LAYOUT_BLOCK_RE, text):
|
|
658
|
+
md_before = text[pos:m.start()]
|
|
659
|
+
if md_before.strip() or first_segment:
|
|
660
|
+
output.append(self._render_markdown_segment(md_before, drop_leading_title and first_segment))
|
|
661
|
+
first_segment = False
|
|
662
|
+
block_kind, attrs_str, block_body = m.group(1), m.group(2), m.group(3)
|
|
663
|
+
if block_kind == 'layout-right':
|
|
664
|
+
output.append(self._render_layout_block(block_body, flip=False,
|
|
665
|
+
ratio=self._parse_layout_ratio(attrs_str, (35, 65))))
|
|
666
|
+
elif block_kind == 'layout-left':
|
|
667
|
+
output.append(self._render_layout_block(block_body, flip=True,
|
|
668
|
+
ratio=self._parse_layout_ratio(attrs_str, (65, 35))))
|
|
669
|
+
elif block_kind == 'layout-compare':
|
|
670
|
+
output.append(self._render_compare_block(block_body))
|
|
671
|
+
elif block_kind == 'layout-feature':
|
|
672
|
+
output.append(self._render_feature_block(block_body))
|
|
673
|
+
elif block_kind == 'layout-takahashi':
|
|
674
|
+
output.append(self._render_takahashi_block(attrs_str, block_body))
|
|
675
|
+
elif block_kind == 'align':
|
|
676
|
+
output.append(self._render_align_block(attrs_str, block_body))
|
|
677
|
+
else:
|
|
678
|
+
output.append(self._render_columns_block(attrs_str, block_body))
|
|
679
|
+
pos = m.end()
|
|
680
|
+
md_after = text[pos:]
|
|
681
|
+
if md_after.strip() or first_segment:
|
|
682
|
+
output.append(self._render_markdown_segment(md_after, drop_leading_title and first_segment))
|
|
683
|
+
return "".join(output)
|
|
684
|
+
|
|
685
|
+
def _render_markdown_segment(self, text, drop_leading_title):
|
|
686
|
+
"""通常のMarkdown断片をASTベースでTypstへ変換する(layout-rightブロックの前後の地の文用)"""
|
|
687
|
+
self.list_stack = []
|
|
688
|
+
tokens = self.md.parse(text)
|
|
689
|
+
start = self._skip_leading_title(tokens) if drop_leading_title else 0
|
|
690
|
+
return self.render_tokens(tokens, start)
|
|
691
|
+
|
|
692
|
+
def _parse_size_attrs(self, attrs_str):
|
|
693
|
+
"""フェンスのinfo string中の属性部分(例: '{width=50% height=8cm}')からwidth/heightを
|
|
694
|
+
取り出す。未指定のキーはNoneのまま返す(#82)。"""
|
|
695
|
+
width = height = None
|
|
696
|
+
if attrs_str:
|
|
697
|
+
for key, val in self.FENCE_ATTR_RE.findall(attrs_str):
|
|
698
|
+
if key == 'width':
|
|
699
|
+
width = val
|
|
700
|
+
elif key == 'height':
|
|
701
|
+
height = val
|
|
702
|
+
return width, height
|
|
703
|
+
|
|
704
|
+
def _render_diagram_fence(self, lang, code, width=None, height=None):
|
|
705
|
+
"""```mermaid/```plantuml/```dot/```graphviz/```svg/```d2フェンスの内容をTypstコードへ
|
|
706
|
+
変換する。通常のMarkdownフロー(render_tokens)とlayout-right/layout-compareブロックの
|
|
707
|
+
双方から共通で呼べるようにした処理(#77)。width/height(#82)が指定された場合、
|
|
708
|
+
mermaid/plantuml/svg/d2は自動縮小(fit-image)をバイパスして直接そのサイズで埋め込み、
|
|
709
|
+
dot/graphvizは_render_graphvizが同様にバイパスする。"""
|
|
710
|
+
if lang == 'mermaid':
|
|
711
|
+
return self._render_mermaid(code, width, height)
|
|
712
|
+
elif lang == 'plantuml':
|
|
713
|
+
return self._render_plantuml(code, width, height)
|
|
714
|
+
elif lang == 'svg':
|
|
715
|
+
return self._render_svg(code, width, height)
|
|
716
|
+
elif lang == 'd2':
|
|
717
|
+
return self._render_d2(code, width, height)
|
|
718
|
+
return self._render_graphviz(lang, code, width, height)
|
|
719
|
+
|
|
720
|
+
def _render_diagram_or_image_match(self, m):
|
|
721
|
+
"""DIAGRAM_OR_IMAGE_REの1マッチをTypstコードへ変換する。フェンスは_render_diagram_fenceへ、
|
|
722
|
+
単独行のMarkdown画像は通常の画像処理(alt|width=/height=構文込み)をそのまま再利用するため
|
|
723
|
+
_render_markdown_segmentに委譲する(#77)。"""
|
|
724
|
+
if m.group('lang'):
|
|
725
|
+
width, height = self._parse_size_attrs(m.group('attrs'))
|
|
726
|
+
return self._render_diagram_fence(m.group('lang'), m.group('code'), width, height)
|
|
727
|
+
return self._render_markdown_segment(m.group('image'), False).strip()
|
|
728
|
+
|
|
729
|
+
def _parse_layout_ratio(self, attrs_str, default):
|
|
730
|
+
"""'{left=30 right=70}'の中身からleft/rightのfr比率を取り出す(#83)。属性ブロックが
|
|
731
|
+
無ければdefaultをそのまま返す。`width`/`height`ではなく`left`/`right`という属性名なのは、
|
|
732
|
+
#82の画像サイズ指定(Typst寸法値としてのwidth/height)と意味が衝突しないようにするため。
|
|
733
|
+
片方だけ指定された場合はもう片方をdefault側の値で補う。数値の妥当性チェックは行わない
|
|
734
|
+
(layout-columnsのnと同様、バリデーションは追加しない方針)。"""
|
|
735
|
+
if not attrs_str:
|
|
736
|
+
return default
|
|
737
|
+
attrs = dict(self.FENCE_ATTR_RE.findall(attrs_str))
|
|
738
|
+
left = attrs.get('left')
|
|
739
|
+
right = attrs.get('right')
|
|
740
|
+
if left is None and right is None:
|
|
741
|
+
return default
|
|
742
|
+
return (int(left) if left is not None else default[0],
|
|
743
|
+
int(right) if right is not None else default[1])
|
|
744
|
+
|
|
745
|
+
def _render_layout_block(self, inner_text, flip=False, ratio=(35, 65)):
|
|
746
|
+
"""::: layout-right / layout-left ... ::: ブロックを、テキストと図(mermaid/plantuml/dot/
|
|
747
|
+
graphviz/svgまたはMarkdown画像)の2カラムgridへ変換する。flip=Trueならlayout-leftとして
|
|
748
|
+
図を左・テキストを右に配置する(#81)。ratioは(左, 右)のfr比率"""
|
|
749
|
+
block_name = 'layout-left' if flip else 'layout-right'
|
|
750
|
+
match = self._search_outside_fences(self.DIAGRAM_OR_IMAGE_RE, inner_text)
|
|
751
|
+
if not match:
|
|
752
|
+
print(f"[Error] '{block_name}' block in {self.current_file} must contain exactly one "
|
|
753
|
+
"```mermaid/```plantuml/```dot/```graphviz/```svg/```d2 fence or a standalone image.")
|
|
754
|
+
sys.exit(1)
|
|
755
|
+
surrounding_md = (inner_text[:match.start()] + inner_text[match.end():]).strip()
|
|
756
|
+
text_typst = self._render_markdown_segment(surrounding_md, False).strip()
|
|
757
|
+
image_typst = self._render_diagram_or_image_match(match).strip()
|
|
758
|
+
left_fr, right_fr = ratio
|
|
759
|
+
cells = [image_typst, text_typst] if flip else [text_typst, image_typst]
|
|
760
|
+
align = "(center + horizon, left + top)" if flip else "(left + top, center + horizon)"
|
|
761
|
+
return (
|
|
762
|
+
"#grid(\n"
|
|
763
|
+
f" columns: ({left_fr}fr, {right_fr}fr),\n"
|
|
764
|
+
" column-gutter: 1.5em,\n"
|
|
765
|
+
f" align: {align},\n"
|
|
766
|
+
f" [{cells[0]}],\n"
|
|
767
|
+
f" [{cells[1]}],\n"
|
|
768
|
+
")\n\n"
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
def _render_compare_block(self, inner_text):
|
|
772
|
+
"""::: layout-compare ... ::: ブロックを、2つの図(mermaid/plantuml/dot/graphviz/svg/d2または
|
|
773
|
+
Markdown画像。種類は混在可)を左右に並べた2カラムgridへ変換する。
|
|
774
|
+
各図の直前にあるテキスト(キャプション)は、その図と同じ列にまとめて配置する。"""
|
|
775
|
+
matches = self._finditer_outside_fences(self.DIAGRAM_OR_IMAGE_RE, inner_text)
|
|
776
|
+
if len(matches) != 2:
|
|
777
|
+
print(f"[Error] 'layout-compare' block in {self.current_file} must contain exactly two "
|
|
778
|
+
f"```mermaid/```plantuml/```dot/```graphviz/```svg/```d2 fences or images (found {len(matches)}).")
|
|
779
|
+
sys.exit(1)
|
|
780
|
+
cells = []
|
|
781
|
+
prev_end = 0
|
|
782
|
+
for i, m in enumerate(matches):
|
|
783
|
+
caption_md = inner_text[prev_end:m.start()].strip()
|
|
784
|
+
# 2番目以降の図の後ろに残ったテキストは、最後の列にまとめて含める
|
|
785
|
+
trailing_md = inner_text[matches[-1].end():].strip() if i == len(matches) - 1 else ""
|
|
786
|
+
caption_typst = self._render_markdown_segment(caption_md, False).strip() if caption_md else ""
|
|
787
|
+
image_typst = self._render_diagram_or_image_match(m).strip()
|
|
788
|
+
trailing_typst = self._render_markdown_segment(trailing_md, False).strip() if trailing_md else ""
|
|
789
|
+
cell = "\n\n".join(t for t in [caption_typst, image_typst, trailing_typst] if t)
|
|
790
|
+
cells.append(cell)
|
|
791
|
+
prev_end = m.end()
|
|
792
|
+
columns_typst = ",\n".join(f" [{cell}]" for cell in cells)
|
|
793
|
+
return (
|
|
794
|
+
"#grid(\n"
|
|
795
|
+
" columns: (1fr, 1fr),\n"
|
|
796
|
+
" column-gutter: 1.5em,\n"
|
|
797
|
+
" align: (left + top, left + top),\n"
|
|
798
|
+
f"{columns_typst},\n"
|
|
799
|
+
")\n\n"
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
# layout-featureの写真枠の高さ(スライド本文領域に対する割合)。#78の実機確認で判明した通り、
|
|
803
|
+
# width:100%だけだと縦長写真が大幅にはみ出す(枠の高さが写真任せになるため)。CSSの
|
|
804
|
+
# background-size:coverと同じ考え方で、高さを固定しfit:"cover"で余分をトリミングすることで、
|
|
805
|
+
# 縦長・横長どちらの写真でも枠からはみ出さないようにする。
|
|
806
|
+
FEATURE_IMG_HEIGHT = "70%"
|
|
807
|
+
|
|
808
|
+
def _render_feature_image(self, match):
|
|
809
|
+
"""layout-feature内の図/画像を、フルブリード表示用のTypstコードへ変換する(#78)。
|
|
810
|
+
「写真が主役」という趣旨に合わせ、Markdown画像はalt側のwidth/height指定(あれば)を
|
|
811
|
+
無視してwidth/height: 100%・fit: "cover"で枠いっぱいに敷き詰める(枠の高さ自体は
|
|
812
|
+
FEATURE_IMG_HEIGHTで固定するため、はみ出しはfit:coverのトリミングで吸収される)。
|
|
813
|
+
mermaid/plantuml/dot/graphviz/svg/d2フェンスは想定外の使い方だが、#77の汎用抽出をそのまま通し、
|
|
814
|
+
既存のfit-image表示(高さ上限あり・cover表示ではない)に委ねる。フェンス側のwidth/height
|
|
815
|
+
属性(#82)はcover化の対象外(画像と同じ強制はしない)なので、他のブロックと同様に
|
|
816
|
+
そのまま反映する。"""
|
|
817
|
+
if match.group('lang'):
|
|
818
|
+
width, height = self._parse_size_attrs(match.group('attrs'))
|
|
819
|
+
return self._render_diagram_fence(match.group('lang'), match.group('code'), width, height).strip()
|
|
820
|
+
src_match = re.match(r'!\[[^\]]*\]\(([^)]+)\)', match.group('image'))
|
|
821
|
+
return f'#image("{self._resolve_asset(src_match.group(1))}", width: 100%, height: 100%, fit: "cover")'
|
|
822
|
+
|
|
823
|
+
def _render_feature_block(self, inner_text):
|
|
824
|
+
"""::: layout-feature ... ::: ブロックを、写真(または図)をフルブリードで敷き、
|
|
825
|
+
下部に半透明の帯とキャッチコピーを重ねるレイアウトへ変換する(#78)。
|
|
826
|
+
図/画像の抽出はlayout-right/layout-compareと同じDIAGRAM_OR_IMAGE_REを再利用する(#77)。"""
|
|
827
|
+
match = self._search_outside_fences(self.DIAGRAM_OR_IMAGE_RE, inner_text)
|
|
828
|
+
if not match:
|
|
829
|
+
print(f"[Error] 'layout-feature' block in {self.current_file} must contain exactly one "
|
|
830
|
+
"```mermaid/```plantuml/```dot/```graphviz/```svg/```d2 fence or a standalone image.")
|
|
831
|
+
sys.exit(1)
|
|
832
|
+
catchcopy_md = (inner_text[:match.start()] + inner_text[match.end():]).strip()
|
|
833
|
+
catchcopy_typst = self._render_markdown_segment(catchcopy_md, False).strip()
|
|
834
|
+
image_typst = self._render_feature_image(match)
|
|
835
|
+
return (
|
|
836
|
+
f'#box(width: 100%, height: {self.FEATURE_IMG_HEIGHT})[\n'
|
|
837
|
+
f" {image_typst}\n"
|
|
838
|
+
" #place(bottom + left)[\n"
|
|
839
|
+
" #block(width: 100%, inset: (x: 1.5em, y: 1em), "
|
|
840
|
+
"fill: gradient.linear(rgb(\"#00000000\"), rgb(\"#000000B3\"), angle: 90deg))[\n"
|
|
841
|
+
f" #text(fill: white, size: 24pt, weight: \"bold\")[{catchcopy_typst}]\n"
|
|
842
|
+
" ]\n"
|
|
843
|
+
" ]\n"
|
|
844
|
+
"]\n\n"
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
def _render_columns_block(self, attrs_str, inner_text):
|
|
848
|
+
"""::: layout-columns ... ::: (または ::: layout-columns {n=N} ... :::) ブロックを、
|
|
849
|
+
TypstのN列columns()コンテナへ流し込む(省略時2列、#78)。layout-right/layout-compareと
|
|
850
|
+
違い中身の種類を判別する必要がなく「N列に流し込む」という見た目の指定に過ぎないため、
|
|
851
|
+
Fail-fastのバリデーションは設けず任意のMarkdownを許す。
|
|
852
|
+
columns()はコンテナの高さを超えて初めて次列へあふれる仕組みのため、スライドのように
|
|
853
|
+
本文が短く1列の高さに収まってしまう場合は素朴に#columns(N)[...]と書いても分割されない
|
|
854
|
+
(実機確認で判明)。measure()で中身の自然な高さを測り、その1/N(+わずかな余裕)を
|
|
855
|
+
コンテナの高さとして明示することで、あふれを強制してN列に均等分割する。"""
|
|
856
|
+
attrs = dict(self.FENCE_ATTR_RE.findall(attrs_str)) if attrs_str else {}
|
|
857
|
+
count = int(attrs['n']) if 'n' in attrs else 2
|
|
858
|
+
content_typst = self._render_markdown_segment(inner_text, False).strip()
|
|
859
|
+
return (
|
|
860
|
+
f'#let _columns_content = [{content_typst}]\n'
|
|
861
|
+
"#layout(size => {\n"
|
|
862
|
+
" let h = measure(_columns_content, width: size.width).height\n"
|
|
863
|
+
f" block(height: h / {count} + 1pt)[\n"
|
|
864
|
+
f" #columns({count}, gutter: 1.5em, _columns_content)\n"
|
|
865
|
+
" ]\n"
|
|
866
|
+
"})\n\n"
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
# layout-takahashiの既定の文字サイズ(#95)。本文既定の10.5pt(templates/template.typ)に
|
|
870
|
+
# 対し、高橋メソッド的な「大きな文字を1つだけ見せる」用途として十分大きい値を採用した。
|
|
871
|
+
# 内容の長さに合わない場合は`{size=...}`属性で上書きできる。
|
|
872
|
+
TAKAHASHI_DEFAULT_SIZE = "96pt"
|
|
873
|
+
|
|
874
|
+
def _render_takahashi_block(self, attrs_str, inner_text):
|
|
875
|
+
"""::: layout-takahashi ... ::: ブロックを、中身(任意のMarkdown)を画面の上下左右中央に
|
|
876
|
+
大きな文字で表示するレイアウトへ変換する(高橋メソッド、#95)。`{size=...}`で
|
|
877
|
+
TAKAHASHI_DEFAULT_SIZEを上書きできる。layout-right の left=/right= 等と同じく、
|
|
878
|
+
size の値自体の妥当性チェックは行わない(不正値はTypst側の#text()呼び出しが
|
|
879
|
+
コンパイルエラーになる)。"""
|
|
880
|
+
attrs = dict(self.FENCE_ATTR_RE.findall(attrs_str)) if attrs_str else {}
|
|
881
|
+
size = attrs.get('size', self.TAKAHASHI_DEFAULT_SIZE)
|
|
882
|
+
content_typst = self._render_markdown_segment(inner_text, False).strip()
|
|
883
|
+
return f'#align(center + horizon)[#text(size: {size})[{content_typst}]]\n\n'
|
|
884
|
+
|
|
885
|
+
def _render_align_block(self, attrs_str, inner_text):
|
|
886
|
+
"""::: align {align=center} / ::: align {align=right} ... ::: ブロックを、中身
|
|
887
|
+
(任意のMarkdown、複数行・複数段落可)を#align()でラップして中央寄せ・右寄せにする
|
|
888
|
+
(#87)。画像のalign属性(#75)と同じく、著者が明示的に指定できるオプションとして
|
|
889
|
+
追加したもので、`{align=...}`を省略した場合は既定の左寄せのまま変わらない。
|
|
890
|
+
layout-columnsと同様、中身の種類を判別する必要が無いためFail-fastのバリデーションは
|
|
891
|
+
設けない。`align=`に`left`/`center`/`right`以外の値を指定した場合の妥当性チェックも
|
|
892
|
+
行わない(他の独自属性と同じ無検証方針。不正な値はTypst側の#align()呼び出しが
|
|
893
|
+
コンパイルエラーになる)。"""
|
|
894
|
+
attrs = dict(self.FENCE_ATTR_RE.findall(attrs_str)) if attrs_str else {}
|
|
895
|
+
align = attrs.get('align', 'left')
|
|
896
|
+
content_typst = self._render_markdown_segment(inner_text, False).strip()
|
|
897
|
+
return f'#align({align})[{content_typst}]\n\n'
|
|
898
|
+
|
|
899
|
+
def _consume_heading(self, tokens, pos):
|
|
900
|
+
"""posがheading_open(H1/H2まで)ならそのブロックを読み飛ばし、(次の位置, 見出しテキスト)を返す。
|
|
901
|
+
該当しなければ (pos, None)。"""
|
|
902
|
+
if pos >= len(tokens) or tokens[pos].type != 'heading_open' or int(tokens[pos].tag[1:]) > 2:
|
|
903
|
+
return pos, None
|
|
904
|
+
j = pos
|
|
905
|
+
text_parts = []
|
|
906
|
+
while j < len(tokens) and tokens[j].type != 'heading_close':
|
|
907
|
+
if tokens[j].type == 'inline':
|
|
908
|
+
text_parts.append(tokens[j].content)
|
|
909
|
+
j += 1
|
|
910
|
+
return j + 1, ' '.join(text_parts)
|
|
911
|
+
|
|
912
|
+
def _consume_lead_image(self, tokens, pos):
|
|
913
|
+
"""posが「画像1個だけの段落」(タイトルスライドの図版)ならそのブロックを読み飛ばし、
|
|
914
|
+
次の位置を返す。該当しなければposをそのまま返す。"""
|
|
915
|
+
if (pos + 2 >= len(tokens)
|
|
916
|
+
or tokens[pos].type != 'paragraph_open'
|
|
917
|
+
or tokens[pos + 1].type != 'inline'
|
|
918
|
+
or tokens[pos + 2].type != 'paragraph_close'):
|
|
919
|
+
return pos
|
|
920
|
+
children = tokens[pos + 1].children or []
|
|
921
|
+
if len(children) == 1 and children[0].type == 'image':
|
|
922
|
+
return pos + 3
|
|
923
|
+
return pos
|
|
924
|
+
|
|
925
|
+
def _skip_leading_title(self, tokens):
|
|
926
|
+
"""cover: replace/none 用に、先頭のタイトルブロック(画像1枚 + H1/H2と直後の区切り線)を読み飛ばす"""
|
|
927
|
+
i = 0
|
|
928
|
+
dropped = []
|
|
929
|
+
# 先頭のHTMLコメント(Marpのディレクティブ等)は読み飛ばす。ただし警告は従来どおり出す
|
|
930
|
+
while i < len(tokens) and tokens[i].type in ['html_block', 'html_inline']:
|
|
931
|
+
self._handle_html_token(tokens[i])
|
|
932
|
+
i += 1
|
|
933
|
+
|
|
934
|
+
# タイトルの前に図版が1枚だけ置かれているタイトルスライド(画像 + H1 + H2)に対応する。
|
|
935
|
+
# ただしこの時点では見出しが続くかどうか未確定なので、実際に見出しが見つかったときだけ
|
|
936
|
+
# 画像も含めて読み飛ばす(見出しが無ければ画像はそのまま本文として残す)。
|
|
937
|
+
image_end = self._consume_lead_image(tokens, i)
|
|
938
|
+
title_start, title = self._consume_heading(tokens, image_end)
|
|
939
|
+
if title is None:
|
|
940
|
+
return 0
|
|
941
|
+
i = title_start
|
|
942
|
+
dropped.append(title)
|
|
943
|
+
|
|
944
|
+
# 直後がさらに見出し(H1/H2)の場合、それをサブタイトルとして一緒に読み飛ばすのは、
|
|
945
|
+
# そのすぐ後にhr(---)が続くか、そこでこのチャプター(ファイル)が終わっているとき
|
|
946
|
+
# に限る。それ以外(続けて本文の段落が来る等)は、本文側の実見出し(例: "## 1. はじめに")
|
|
947
|
+
# であり、タイトルスライドの一部ではないため触らない。
|
|
948
|
+
next_pos, subtitle = self._consume_heading(tokens, i)
|
|
949
|
+
if subtitle is not None and (next_pos >= len(tokens) or tokens[next_pos].type == 'hr'):
|
|
950
|
+
dropped.append(subtitle)
|
|
951
|
+
i = next_pos
|
|
952
|
+
if i < len(tokens) and tokens[i].type == 'hr':
|
|
953
|
+
i += 1
|
|
954
|
+
_log_info(f"Cover: replaced the leading title slide of {self.current_file} ({' / '.join(dropped)})")
|
|
955
|
+
return i
|
|
956
|
+
|
|
957
|
+
if i < len(tokens) and tokens[i].type == 'hr':
|
|
958
|
+
i += 1
|
|
959
|
+
# サイレントに本文を捨てないよう、取り除いた内容は必ずログに出す
|
|
960
|
+
_log_info(f"Cover: replaced the leading title slide of {self.current_file} ({' / '.join(dropped)})")
|
|
961
|
+
return i
|
|
962
|
+
|
|
963
|
+
def strip_front_matter(self, text):
|
|
964
|
+
"""冒頭のfront-matterを本文から除去し、設定として返す。行番号は空行で維持する"""
|
|
965
|
+
m = self.FRONT_MATTER_RE.match(text)
|
|
966
|
+
if not m:
|
|
967
|
+
return text, {}
|
|
968
|
+
meta = {}
|
|
969
|
+
if yaml is None:
|
|
970
|
+
print(f"[Warning] PyYAML is not installed; front-matter in {self.current_file} is ignored.")
|
|
971
|
+
else:
|
|
972
|
+
try:
|
|
973
|
+
loaded = yaml.safe_load(m.group(1))
|
|
974
|
+
if isinstance(loaded, dict):
|
|
975
|
+
meta = loaded
|
|
976
|
+
else:
|
|
977
|
+
print(f"[Warning] Front-matter in {self.current_file} is not a mapping; ignored.")
|
|
978
|
+
except Exception as e:
|
|
979
|
+
print(f"[Warning] Failed to parse front-matter in {self.current_file}: {e}")
|
|
980
|
+
for key in meta:
|
|
981
|
+
if key not in self.MARP_ONLY_KEYS and key not in ('title', 'subtitle', 'author', 'date',
|
|
982
|
+
'paper_size', 'landscape', 'font_size',
|
|
983
|
+
'header', 'footer', 'paginate'):
|
|
984
|
+
print(f"[Warning] Unknown front-matter key '{key}' in {self.current_file}")
|
|
985
|
+
if 'font_size' in meta and not self.FONT_SIZE_RE.match(str(meta['font_size'])):
|
|
986
|
+
print(f"[Warning] front-matter 'font_size' in {self.current_file} should look like '16pt'; got {meta['font_size']!r}. Ignoring.")
|
|
987
|
+
del meta['font_size']
|
|
988
|
+
# 除去した行数ぶん改行を残し、以降の警告メッセージの行番号がずれないようにする
|
|
989
|
+
return '\n' * m.group(0).count('\n') + text[m.end():], meta
|
|
990
|
+
|
|
991
|
+
def _detect_alert_kind(self, tokens, i):
|
|
992
|
+
"""tokens[i]がblockquote_openのとき、直後の段落が`[!NOTE]`等のマーカーだけの行なら
|
|
993
|
+
種別(小文字)を返す。マッチした場合、マーカーのテキストトークン(と直後のsoftbreak)を
|
|
994
|
+
その場で取り除く(以降のinlineレンダリングに影響しないようにするため)。"""
|
|
995
|
+
if i + 2 >= len(tokens):
|
|
996
|
+
return None
|
|
997
|
+
if tokens[i + 1].type != 'paragraph_open' or tokens[i + 2].type != 'inline':
|
|
998
|
+
return None
|
|
999
|
+
children = tokens[i + 2].children
|
|
1000
|
+
if not children or children[0].type != 'text':
|
|
1001
|
+
return None
|
|
1002
|
+
m = self.ALERT_MARKER_RE.match(children[0].content.strip())
|
|
1003
|
+
if not m:
|
|
1004
|
+
return None
|
|
1005
|
+
children.pop(0)
|
|
1006
|
+
if children and children[0].type == 'softbreak':
|
|
1007
|
+
children.pop(0)
|
|
1008
|
+
return m.group(1).lower()
|
|
1009
|
+
|
|
1010
|
+
def render_tokens(self, tokens, start=0):
|
|
1011
|
+
result = []
|
|
1012
|
+
i = start
|
|
1013
|
+
while i < len(tokens):
|
|
1014
|
+
t = tokens[i]
|
|
1015
|
+
if t.type == 'heading_open':
|
|
1016
|
+
self._emit_srcmap(result, t)
|
|
1017
|
+
level = int(t.tag[1:])
|
|
1018
|
+
result.append('=' * level + ' ')
|
|
1019
|
+
elif t.type == 'heading_close':
|
|
1020
|
+
result.append('\n\n')
|
|
1021
|
+
elif t.type == 'paragraph_open':
|
|
1022
|
+
self._emit_srcmap(result, t)
|
|
1023
|
+
elif t.type == 'paragraph_close':
|
|
1024
|
+
# 【修正】タイトなリスト内の暗黙段落(hidden)で空行を出さない(loose化防止)
|
|
1025
|
+
if not t.hidden:
|
|
1026
|
+
result.append('\n\n')
|
|
1027
|
+
elif t.type == 'blockquote_open':
|
|
1028
|
+
self._emit_srcmap(result, t)
|
|
1029
|
+
alert_kind = self._detect_alert_kind(tokens, i)
|
|
1030
|
+
if alert_kind:
|
|
1031
|
+
result.append(f'#callout(kind: "{alert_kind}")[\n')
|
|
1032
|
+
else:
|
|
1033
|
+
result.append('#quote(block: true)[\n')
|
|
1034
|
+
elif t.type == 'blockquote_close':
|
|
1035
|
+
result.append(']\n\n')
|
|
1036
|
+
elif t.type == 'inline':
|
|
1037
|
+
result.append(self.render_inline(t.children))
|
|
1038
|
+
# 【修正】ネストしたリストを階層のままインデント付きで出力する
|
|
1039
|
+
elif t.type in ['bullet_list_open', 'ordered_list_open']:
|
|
1040
|
+
self._emit_srcmap(result, t)
|
|
1041
|
+
if self.list_stack and not self._ends_with_newline(result):
|
|
1042
|
+
result.append('\n')
|
|
1043
|
+
self.list_stack.append('ordered' if t.type == 'ordered_list_open' else 'bullet')
|
|
1044
|
+
elif t.type in ['bullet_list_close', 'ordered_list_close']:
|
|
1045
|
+
if self.list_stack:
|
|
1046
|
+
self.list_stack.pop()
|
|
1047
|
+
if not self.list_stack:
|
|
1048
|
+
result.append('\n')
|
|
1049
|
+
elif t.type == 'list_item_open':
|
|
1050
|
+
indent = ' ' * max(0, len(self.list_stack) - 1)
|
|
1051
|
+
marker = '+ ' if self.list_stack and self.list_stack[-1] == 'ordered' else '- '
|
|
1052
|
+
result.append(indent + marker)
|
|
1053
|
+
elif t.type == 'list_item_close':
|
|
1054
|
+
if not self._ends_with_newline(result):
|
|
1055
|
+
result.append('\n')
|
|
1056
|
+
elif t.type == 'table_open':
|
|
1057
|
+
self._emit_srcmap(result, t)
|
|
1058
|
+
cols = self._count_table_cols(tokens, i)
|
|
1059
|
+
result.append(f'#table(\n columns: {cols}{self._table_header_fill_arg()},\n table.header(\n ')
|
|
1060
|
+
elif t.type == 'thead_close':
|
|
1061
|
+
# table.header()呼び出しを閉じ、以降のtd_open/td_closeはtable()本体への
|
|
1062
|
+
# 通常の位置引数として続く(#70)。table.header()はデフォルトでrepeat: trueの
|
|
1063
|
+
# ため、表がページを跨いだ次ページ以降にもヘッダー行が自動的に再掲される。
|
|
1064
|
+
result.append('\n ),\n ')
|
|
1065
|
+
elif t.type == 'table_close':
|
|
1066
|
+
result.append('\n)\n\n')
|
|
1067
|
+
elif t.type == 'hr':
|
|
1068
|
+
self._emit_srcmap(result, t)
|
|
1069
|
+
if self.marp_compat:
|
|
1070
|
+
# 見出し直前の自動改ページと二重に効いて空ページが発生する既知の不具合を
|
|
1071
|
+
# 避けるため、原則通りweak: trueを使う(doc/spec.md、#92で修正)。
|
|
1072
|
+
result.append('#pagebreak(weak: true)\n\n')
|
|
1073
|
+
else:
|
|
1074
|
+
result.append('#line(length: 100%)\n\n')
|
|
1075
|
+
elif t.type == 'fence':
|
|
1076
|
+
self._emit_srcmap(result, t)
|
|
1077
|
+
info = t.info.strip()
|
|
1078
|
+
if info == 'typst-exec':
|
|
1079
|
+
if not self.allow_exec:
|
|
1080
|
+
print(f"[Error] Security: 'typst-exec' is allowed only under a 'reviewed/' directory ({self.current_file}).")
|
|
1081
|
+
sys.exit(1)
|
|
1082
|
+
result.append(f"{t.content}\n\n")
|
|
1083
|
+
else:
|
|
1084
|
+
# info stringは'mermaid'や'mermaid {width=50% height=8cm}'のように、言語名の
|
|
1085
|
+
# 後ろへ空白区切りでサイズ指定属性を書ける(#82)。
|
|
1086
|
+
parts = info.split(None, 1)
|
|
1087
|
+
lang = parts[0] if parts else ''
|
|
1088
|
+
if lang in ('mermaid', 'plantuml', 'dot', 'graphviz', 'svg', 'd2'):
|
|
1089
|
+
width, height = self._parse_size_attrs(parts[1] if len(parts) > 1 else '')
|
|
1090
|
+
result.append(self._render_diagram_fence(lang, t.content, width, height))
|
|
1091
|
+
else:
|
|
1092
|
+
# ```` ``` ````フェンス構文で直接組み立てると、コード内容自体に```が
|
|
1093
|
+
# 含まれる場合にTypst側のフェンスが早期に閉じて壊れる。文字列リテラルとして
|
|
1094
|
+
# 渡すraw()なら安全(#15の_render_raw_textと同じ理由)。
|
|
1095
|
+
result.append(self._render_raw_text(t.content, lang or None))
|
|
1096
|
+
elif t.type in ['html_inline', 'html_block']:
|
|
1097
|
+
result.append(self._handle_html_token(t))
|
|
1098
|
+
elif t.type == 'th_open':
|
|
1099
|
+
open_wrap, _ = self._table_header_open_close()
|
|
1100
|
+
result.append('[' + open_wrap)
|
|
1101
|
+
elif t.type == 'th_close':
|
|
1102
|
+
_, close_wrap = self._table_header_open_close()
|
|
1103
|
+
result.append(close_wrap + '], ')
|
|
1104
|
+
elif t.type == 'td_open':
|
|
1105
|
+
result.append('[')
|
|
1106
|
+
elif t.type == 'td_close':
|
|
1107
|
+
result.append('], ')
|
|
1108
|
+
elif t.type == 'tr_close':
|
|
1109
|
+
result.append('\n ')
|
|
1110
|
+
i += 1
|
|
1111
|
+
return "".join(result)
|
|
1112
|
+
|
|
1113
|
+
def _warn_html(self, t):
|
|
1114
|
+
line_no = t.map[0] + 1 if t.map else '?'
|
|
1115
|
+
print(f"[Warning] HTML tag detected at {self.current_file}:{line_no} : {t.content.strip()}. HTML is not supported and will be ignored in Typst output.")
|
|
1116
|
+
|
|
1117
|
+
def _task_checkbox_glyph(self, html):
|
|
1118
|
+
"""tasklists_pluginが出力する<input class="task-list-item-checkbox" ...>だけを認識し、
|
|
1119
|
+
Unicodeのチェックボックス記号を返す(PDFは非対話的なので実際のcheckboxウィジェットは不要)。
|
|
1120
|
+
該当しなければNone(呼び出し側で通常のHTML警告にフォールバックする)。"""
|
|
1121
|
+
if 'task-list-item-checkbox' not in html:
|
|
1122
|
+
return None
|
|
1123
|
+
return '☑' if 'checked="checked"' in html else '☐'
|
|
1124
|
+
|
|
1125
|
+
def _handle_html_token(self, t):
|
|
1126
|
+
"""Marpのディレクティブコメント(<!-- header: X -->等)は、Marp原稿との共用時に不要な
|
|
1127
|
+
警告が出ないよう認識はするが、何も反映しない(値を読み捨てる)。実際に反映する機能は
|
|
1128
|
+
一度実装した(#16)が、チャプター(ファイル)をまたいで状態が持続する設計が、この
|
|
1129
|
+
ツールの売りである「章の並べ替え」と衝突する(並べ替えると意図しないヘッダーが
|
|
1130
|
+
混入しうる)ため撤回した(#41)。<!-- pagebreak -->(#92)はその場1回だけ効くアクション
|
|
1131
|
+
で状態を持ち越さないため、この制約の対象外として実際に反映する。それ以外(未対応の
|
|
1132
|
+
ディレクティブ・生のHTMLタグ)は従来どおり警告のみでビルドを継続する。"""
|
|
1133
|
+
content = t.content.strip()
|
|
1134
|
+
if self.PAGEBREAK_DIRECTIVE_RE.match(content):
|
|
1135
|
+
return '#pagebreak(weak: true)\n\n'
|
|
1136
|
+
if self.DIRECTIVE_RE.match(content):
|
|
1137
|
+
return ""
|
|
1138
|
+
self._warn_html(t)
|
|
1139
|
+
return ""
|
|
1140
|
+
|
|
1141
|
+
def _ends_with_newline(self, result):
|
|
1142
|
+
for s in reversed(result):
|
|
1143
|
+
if s:
|
|
1144
|
+
return s.endswith('\n')
|
|
1145
|
+
return True
|
|
1146
|
+
|
|
1147
|
+
# Typstコンパイルエラーの行番号を元のMarkdownの行番号へ逆引きするための目印(#27)。
|
|
1148
|
+
# _resolve_project_dirs後の_compile_and_cleanupがtemp_build.typ全体からこの行を
|
|
1149
|
+
# 一度スキャンし、「Typstの行番号→(Markdownファイル, 行番号)」の対応表を作る。
|
|
1150
|
+
SRCMAP_PREFIX = '// @srcmap '
|
|
1151
|
+
|
|
1152
|
+
def _emit_srcmap(self, result, t):
|
|
1153
|
+
"""document.diagnostics.line_mapping: "block"(既定)のとき、トップレベルのブロック
|
|
1154
|
+
(見出し・段落・引用・リスト全体・テーブル全体・hr・fence)の開始点で、生成Typst
|
|
1155
|
+
コードへ行コメントの目印を挿し込む。リストの中(self.list_stackが非空)は対象外
|
|
1156
|
+
(リスト項目・テーブル行単位まで踏み込む"fine"は、Typstのリスト継続判定への影響を
|
|
1157
|
+
実機検証してから対応する将来課題)。"off"時は何もしない(従来どおりTypst側の生の
|
|
1158
|
+
行番号のみになる)。"""
|
|
1159
|
+
if self.line_mapping != "block" or t.map is None or self.list_stack:
|
|
1160
|
+
return
|
|
1161
|
+
if result and not self._ends_with_newline(result):
|
|
1162
|
+
result.append('\n')
|
|
1163
|
+
result.append(f'{self.SRCMAP_PREFIX}{self.current_file}:{t.map[0] + 1}\n')
|
|
1164
|
+
|
|
1165
|
+
def _resolve_asset(self, src):
|
|
1166
|
+
"""画像の相対パスをMarkdownファイル基準から、typst_root起点のルート絶対パスへ変換する。
|
|
1167
|
+
temp_build.typ の実際の置き場所(.text-compositor/ 配下)に依存させないため。"""
|
|
1168
|
+
if not src or src.startswith('/') or re.match(r'^[a-zA-Z][a-zA-Z0-9+.\-]*://', src):
|
|
1169
|
+
return escape_string_literal(src)
|
|
1170
|
+
abs_path = os.path.normpath(os.path.join(self.current_dir, src))
|
|
1171
|
+
# 仕様9章: 画像パス欠損はフォールバックせず即エラー (Fail-fast)
|
|
1172
|
+
if not os.path.exists(abs_path):
|
|
1173
|
+
print(f"[Error] Image not found: {abs_path} (referenced from {self.current_file})")
|
|
1174
|
+
sys.exit(1)
|
|
1175
|
+
root_rel_path = "/" + os.path.relpath(abs_path, self.typst_root).replace(os.sep, '/')
|
|
1176
|
+
return escape_string_literal(root_rel_path)
|
|
1177
|
+
|
|
1178
|
+
def _ensure_mermaid_page(self):
|
|
1179
|
+
"""Mermaidレンダリング用のヘッドレスブラウザ・ページを遅延起動する(初回のみ)。
|
|
1180
|
+
Node.js/npxを介さず、mermaid.min.js(実測約3.4MB)を直接ヘッドレスブラウザへ読み込ませて
|
|
1181
|
+
mermaid.render()を呼ぶ(仕様書11章、#35。mermaid-cli丸ごとの約396MBを回避する)。
|
|
1182
|
+
既存のシステムChrome/Edge(#34の検出ロジック)が見つかればPlaywrightのCDP接続で繋ぐだけで、
|
|
1183
|
+
ブラウザの追加ダウンロードは発生しない。見つからない場合、plugins.mermaid_auto_downloadが
|
|
1184
|
+
trueならPlaywright自身のChromium(実測約700MB)をその場で取得して使う。既定はfalseで、
|
|
1185
|
+
Fail-fastでエラー終了する(#22の設計議論。700MBは#34/#35がまさに避けた規模のため、
|
|
1186
|
+
既定で自動取得はしない)。"""
|
|
1187
|
+
if self._mermaid_page is not None:
|
|
1188
|
+
return self._mermaid_page
|
|
1189
|
+
|
|
1190
|
+
try:
|
|
1191
|
+
from playwright.sync_api import sync_playwright
|
|
1192
|
+
except ImportError:
|
|
1193
|
+
print("[Error] The 'playwright' package is required for mermaid rendering (plugins.mermaid: true). "
|
|
1194
|
+
"Install it with: pip install playwright==1.62.0")
|
|
1195
|
+
sys.exit(1)
|
|
1196
|
+
|
|
1197
|
+
browser_path = find_system_browser()
|
|
1198
|
+
self._mermaid_playwright = sync_playwright().start()
|
|
1199
|
+
|
|
1200
|
+
if browser_path:
|
|
1201
|
+
_log_info(f"Reusing system browser for mermaid rendering: {browser_path}")
|
|
1202
|
+
self._mermaid_profile_dir = tempfile.mkdtemp(prefix="cc-mermaid-")
|
|
1203
|
+
self._mermaid_chrome_proc, port = _launch_headless_chrome(browser_path, self._mermaid_profile_dir)
|
|
1204
|
+
try:
|
|
1205
|
+
self._mermaid_browser = self._mermaid_playwright.chromium.connect_over_cdp(f"http://127.0.0.1:{port}")
|
|
1206
|
+
except Exception as e:
|
|
1207
|
+
print(f"[Error] Failed to connect to headless browser for mermaid rendering: {e}")
|
|
1208
|
+
diag = _check_mermaid(self.mermaid_enabled, self.mermaid_auto_download)
|
|
1209
|
+
if diag.status != "OK":
|
|
1210
|
+
print(f"[Hint] [{diag.status}] {diag.name}: {diag.message}")
|
|
1211
|
+
sys.exit(1)
|
|
1212
|
+
elif self.mermaid_auto_download:
|
|
1213
|
+
_log_info("No system Chrome/Edge found; plugins.mermaid_auto_download is true, so Playwright "
|
|
1214
|
+
"will download its own Chromium (one-time; approx. 700MB; cached under Playwright's "
|
|
1215
|
+
"browser cache, typically ~/.cache/ms-playwright)...")
|
|
1216
|
+
try:
|
|
1217
|
+
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
|
|
1218
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
1219
|
+
print(f"[Error] Failed to download Playwright's Chromium: {e}")
|
|
1220
|
+
sys.exit(1)
|
|
1221
|
+
try:
|
|
1222
|
+
self._mermaid_browser = self._mermaid_playwright.chromium.launch(headless=True)
|
|
1223
|
+
except Exception as e:
|
|
1224
|
+
print(f"[Error] Failed to launch the downloaded Chromium for mermaid rendering: {e}")
|
|
1225
|
+
sys.exit(1)
|
|
1226
|
+
else:
|
|
1227
|
+
print("[Error] No system Chrome/Edge found; required to render mermaid diagrams locally. "
|
|
1228
|
+
"Install Google Chrome or Microsoft Edge, or set plugins.mermaid_auto_download: true "
|
|
1229
|
+
"(downloads Playwright's own Chromium, approx. 700MB), or set plugins.mermaid: false.")
|
|
1230
|
+
sys.exit(1)
|
|
1231
|
+
|
|
1232
|
+
mermaid_js_path = ensure_mermaid_js()
|
|
1233
|
+
|
|
1234
|
+
context = self._mermaid_browser.contexts[0] if self._mermaid_browser.contexts else self._mermaid_browser.new_context()
|
|
1235
|
+
page = context.new_page()
|
|
1236
|
+
page.set_content("<div id='container'></div>")
|
|
1237
|
+
with open(mermaid_js_path, "r", encoding="utf-8") as f:
|
|
1238
|
+
page.add_script_tag(content=f.read())
|
|
1239
|
+
# Typstのraw SVGレンダラーは<foreignObject>内のHTMLを描画できないため、mermaid既定の
|
|
1240
|
+
# HTMLラベルを無効化し、通常のSVG<text>要素で出力させる(トップレベルとflowchart配下
|
|
1241
|
+
# 両方に指定する必要がある。PoCで確認済み)
|
|
1242
|
+
page.evaluate("mermaid.initialize({ startOnLoad: false, htmlLabels: false, flowchart: { htmlLabels: false } })")
|
|
1243
|
+
self._mermaid_page = page
|
|
1244
|
+
return page
|
|
1245
|
+
|
|
1246
|
+
def close(self):
|
|
1247
|
+
"""ビルド終了時に呼び出し、_ensure_mermaid_pageで起動したヘッドレスブラウザを片付ける
|
|
1248
|
+
(mermaidを一度も描画していなければ何もしない)。"""
|
|
1249
|
+
if self._mermaid_browser is not None:
|
|
1250
|
+
try:
|
|
1251
|
+
self._mermaid_browser.close()
|
|
1252
|
+
except Exception:
|
|
1253
|
+
pass
|
|
1254
|
+
if self._mermaid_playwright is not None:
|
|
1255
|
+
self._mermaid_playwright.stop()
|
|
1256
|
+
if self._mermaid_chrome_proc is not None:
|
|
1257
|
+
self._mermaid_chrome_proc.terminate()
|
|
1258
|
+
try:
|
|
1259
|
+
self._mermaid_chrome_proc.wait(timeout=5)
|
|
1260
|
+
except Exception:
|
|
1261
|
+
self._mermaid_chrome_proc.kill()
|
|
1262
|
+
if self._mermaid_profile_dir and os.path.exists(self._mermaid_profile_dir):
|
|
1263
|
+
shutil.rmtree(self._mermaid_profile_dir, ignore_errors=True)
|
|
1264
|
+
|
|
1265
|
+
def _render_sized_image(self, root_rel_path, width, height):
|
|
1266
|
+
"""事前レンダリング済み画像(mermaid/plantumlのSVG)をTypstコードへ変換する。
|
|
1267
|
+
width/height未指定ならfit-image()(はみ出し防止の自動縮小のみ、拡大はしない)、
|
|
1268
|
+
明示指定時は自動縮小をバイパスして#image()へそのままwidth/heightを渡す
|
|
1269
|
+
(通常のMarkdown画像のalt|width=構文と同じ挙動。拡大も含めて指定値どおりになる、#82)。"""
|
|
1270
|
+
if width or height:
|
|
1271
|
+
width_arg = f', width: {width}' if width else ''
|
|
1272
|
+
height_arg = f', height: {height}' if height else ''
|
|
1273
|
+
return f'#align(center)[#image("{root_rel_path}"{width_arg}{height_arg})]\n\n'
|
|
1274
|
+
return f'#align(center)[#fit-image("{root_rel_path}")]\n\n'
|
|
1275
|
+
|
|
1276
|
+
def _render_svg(self, code, width=None, height=None):
|
|
1277
|
+
"""```svgフェンスの内容をTypstのimage呼び出しに変換する。mermaid/plantumlと異なりSVGは
|
|
1278
|
+
既にテキストで完結したベクター画像フォーマットのため、外部レンダリングエンジンは呼ばず、
|
|
1279
|
+
コードをそのままキャッシュ用の.svgファイルへ書き出すだけでよい(#91)。"""
|
|
1280
|
+
cache_dir = os.path.join(self.base_dir, ".text-compositor", "cache")
|
|
1281
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
1282
|
+
digest = hashlib.sha256(code.encode('utf-8')).hexdigest()[:16]
|
|
1283
|
+
svg_path = os.path.join(cache_dir, f"svg_{digest}.svg")
|
|
1284
|
+
|
|
1285
|
+
if not os.path.exists(svg_path):
|
|
1286
|
+
with open(svg_path, "w", encoding="utf-8") as f:
|
|
1287
|
+
f.write(code)
|
|
1288
|
+
|
|
1289
|
+
root_rel_path = escape_string_literal("/" + os.path.relpath(svg_path, self.typst_root).replace(os.sep, '/'))
|
|
1290
|
+
return self._render_sized_image(root_rel_path, width, height)
|
|
1291
|
+
|
|
1292
|
+
def _render_mermaid(self, code, width=None, height=None):
|
|
1293
|
+
"""mermaidブロックをヘッドレスブラウザ上のmermaid.render()でSVG化し、Typstのimage呼び出しに
|
|
1294
|
+
変換する。外部APIへの通信は行わず、ローカルのブラウザで完結させる(仕様書10章・11章、#35)。"""
|
|
1295
|
+
if not self.mermaid_enabled:
|
|
1296
|
+
if not self._mermaid_disabled_warned:
|
|
1297
|
+
_log_info(f"plugins.mermaid is disabled; leaving ```mermaid fences as plain code (first seen in {self.current_file}).")
|
|
1298
|
+
self._mermaid_disabled_warned = True
|
|
1299
|
+
return f"```mermaid\n{code}```\n\n"
|
|
1300
|
+
|
|
1301
|
+
cache_dir = os.path.join(self.base_dir, ".text-compositor", "cache")
|
|
1302
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
1303
|
+
digest = hashlib.sha256(code.encode('utf-8')).hexdigest()[:16]
|
|
1304
|
+
svg_path = os.path.join(cache_dir, f"mermaid_{digest}.svg")
|
|
1305
|
+
|
|
1306
|
+
if not os.path.exists(svg_path):
|
|
1307
|
+
_log_info(f"Rendering mermaid diagram via headless browser -> {os.path.basename(svg_path)}")
|
|
1308
|
+
page = self._ensure_mermaid_page()
|
|
1309
|
+
try:
|
|
1310
|
+
svg = page.evaluate(
|
|
1311
|
+
"""async ([id, code]) => {
|
|
1312
|
+
const { svg } = await mermaid.render(id, code);
|
|
1313
|
+
return svg;
|
|
1314
|
+
}""",
|
|
1315
|
+
[f"mermaid-{digest}", code],
|
|
1316
|
+
)
|
|
1317
|
+
except Exception as e:
|
|
1318
|
+
# 仕様9章のFail-fast方針: 描画失敗時はテキストへフォールバックせず即エラー
|
|
1319
|
+
print(f"[Error] mermaid rendering failed for {self.current_file}:\n{e}")
|
|
1320
|
+
sys.exit(1)
|
|
1321
|
+
with open(svg_path, "w", encoding="utf-8") as f:
|
|
1322
|
+
f.write(svg)
|
|
1323
|
+
else:
|
|
1324
|
+
_log_verbose(f"Reusing cached mermaid diagram: {os.path.basename(svg_path)}")
|
|
1325
|
+
|
|
1326
|
+
# fit-image() は templates/slide.typ 側で定義されているため、image() の相対パス解決基準は
|
|
1327
|
+
# base_dir ではなく templates/ になってしまう。ファイルの置き場所に依存しない
|
|
1328
|
+
# ルート絶対パス(--root 起点の "/..." 形式)にして、どこから呼んでも解決できるようにする。
|
|
1329
|
+
root_rel_path = escape_string_literal("/" + os.path.relpath(svg_path, self.typst_root).replace(os.sep, '/'))
|
|
1330
|
+
return self._render_sized_image(root_rel_path, width, height)
|
|
1331
|
+
|
|
1332
|
+
def _ensure_plantuml_tools(self):
|
|
1333
|
+
"""PlantUML実行に必要なjava実行ファイルとplantuml.jarを遅延解決する(初回のみ)。
|
|
1334
|
+
システムJava(11+)があればそのまま再利用する(2章の最小限のダウンロード)。無い場合、
|
|
1335
|
+
plugins.plantuml_auto_download(既定true)ならEclipse Temurin JREを自動取得し、falseなら
|
|
1336
|
+
Fail-fastでエラー終了する。mermaidのブラウザ自動取得(既定false)と非対称な既定値なのは、
|
|
1337
|
+
ダウンロードされる実体のサイズが一桁違うため(JRE約49.7MB対Chromium約700MB。#22の設計議論)。"""
|
|
1338
|
+
if self._plantuml_java_bin is None:
|
|
1339
|
+
java_bin = find_system_java()
|
|
1340
|
+
if java_bin:
|
|
1341
|
+
_log_info(f"Reusing system Java for PlantUML rendering: {java_bin}")
|
|
1342
|
+
elif self.plantuml_auto_download:
|
|
1343
|
+
java_bin = ensure_temurin_jre()
|
|
1344
|
+
else:
|
|
1345
|
+
print("[Error] No local Java 11+ found; required to render PlantUML diagrams. "
|
|
1346
|
+
"Install Java 11+, or set plugins.plantuml_auto_download: true "
|
|
1347
|
+
"(downloads Eclipse Temurin JRE, approx. 50MB), or set plugins.plantuml: false.")
|
|
1348
|
+
sys.exit(1)
|
|
1349
|
+
self._plantuml_java_bin = java_bin
|
|
1350
|
+
if self._plantuml_jar_path is None:
|
|
1351
|
+
self._plantuml_jar_path = ensure_plantuml_jar()
|
|
1352
|
+
return self._plantuml_java_bin, self._plantuml_jar_path
|
|
1353
|
+
|
|
1354
|
+
def _render_plantuml(self, code, width=None, height=None):
|
|
1355
|
+
"""```plantumlブロックをローカルのjava+plantuml.jar(Smetanaレイアウトエンジン。dot等の
|
|
1356
|
+
外部バイナリに依存しない)でSVG化し、Typstのimage呼び出しに変換する。外部APIへの通信は
|
|
1357
|
+
行わない(仕様書10章・11章、#22)。コードは実際のPlantUML構文どおり@startuml/@enduml
|
|
1358
|
+
込みで書く必要がある(暗黙の補完はしない。9章の決定論的出力・明示性の方針に沿う)。"""
|
|
1359
|
+
if not self.plantuml_enabled:
|
|
1360
|
+
if not self._plantuml_disabled_warned:
|
|
1361
|
+
_log_info(f"plugins.plantuml is disabled; leaving ```plantuml fences as plain code (first seen in {self.current_file}).")
|
|
1362
|
+
self._plantuml_disabled_warned = True
|
|
1363
|
+
return f"```plantuml\n{code}```\n\n"
|
|
1364
|
+
|
|
1365
|
+
cache_dir = os.path.join(self.base_dir, ".text-compositor", "cache")
|
|
1366
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
1367
|
+
digest = hashlib.sha256(code.encode('utf-8')).hexdigest()[:16]
|
|
1368
|
+
svg_path = os.path.join(cache_dir, f"plantuml_{digest}.svg")
|
|
1369
|
+
|
|
1370
|
+
if not os.path.exists(svg_path):
|
|
1371
|
+
_log_info(f"Rendering PlantUML diagram via local Java -> {os.path.basename(svg_path)}")
|
|
1372
|
+
java_bin, jar_path = self._ensure_plantuml_tools()
|
|
1373
|
+
try:
|
|
1374
|
+
result = subprocess.run(
|
|
1375
|
+
[java_bin, "-jar", jar_path, "-tsvg", "-pipe", "-Playout=smetana"],
|
|
1376
|
+
input=code, capture_output=True, text=True, encoding="utf-8", timeout=60)
|
|
1377
|
+
except OSError as e:
|
|
1378
|
+
print(f"[Error] Failed to run PlantUML for {self.current_file}:\n{e}")
|
|
1379
|
+
# 隔離環境(venv/pipx)外での実行が原因の可能性が高い(#113、ファイルが
|
|
1380
|
+
# 存在するように見えてもサブプロセスから見えない既知の問題)ため優先して案内する。
|
|
1381
|
+
for diag in (_check_isolated_env(), _check_plantuml(self.plantuml_enabled, self.plantuml_auto_download)):
|
|
1382
|
+
if diag.status != "OK":
|
|
1383
|
+
print(f"[Hint] [{diag.status}] {diag.name}: {diag.message}")
|
|
1384
|
+
sys.exit(1)
|
|
1385
|
+
if result.returncode != 0:
|
|
1386
|
+
# 仕様9章のFail-fast方針: 描画失敗時はテキストへフォールバックせず即エラー
|
|
1387
|
+
print(f"[Error] PlantUML rendering failed for {self.current_file}:\n{result.stderr}")
|
|
1388
|
+
sys.exit(1)
|
|
1389
|
+
with open(svg_path, "w", encoding="utf-8") as f:
|
|
1390
|
+
f.write(result.stdout)
|
|
1391
|
+
else:
|
|
1392
|
+
_log_verbose(f"Reusing cached PlantUML diagram: {os.path.basename(svg_path)}")
|
|
1393
|
+
|
|
1394
|
+
root_rel_path = escape_string_literal("/" + os.path.relpath(svg_path, self.typst_root).replace(os.sep, '/'))
|
|
1395
|
+
return self._render_sized_image(root_rel_path, width, height)
|
|
1396
|
+
|
|
1397
|
+
def _ensure_d2_bin(self):
|
|
1398
|
+
"""d2実行ファイルを遅延解決する(初回のみ)。システムのdコマンドがあればそのまま
|
|
1399
|
+
再利用する(2章の最小限のダウンロード)。無い場合、plugins.d2_auto_download(既定true)
|
|
1400
|
+
ならD2公式CLIバイナリを自動取得し、falseならFail-fastでエラー終了する(#90)。"""
|
|
1401
|
+
if self._d2_bin is None:
|
|
1402
|
+
d2_bin = find_system_d2()
|
|
1403
|
+
if d2_bin:
|
|
1404
|
+
_log_info(f"Reusing system D2 for d2 rendering: {d2_bin}")
|
|
1405
|
+
elif self.d2_auto_download:
|
|
1406
|
+
d2_bin = ensure_d2_binary()
|
|
1407
|
+
else:
|
|
1408
|
+
print("[Error] No local D2 found; required to render d2 diagrams. "
|
|
1409
|
+
"Install D2 (https://d2lang.com), or set plugins.d2_auto_download: true "
|
|
1410
|
+
"(downloads the D2 CLI binary, approx. 13MB), or set plugins.d2: false.")
|
|
1411
|
+
sys.exit(1)
|
|
1412
|
+
self._d2_bin = d2_bin
|
|
1413
|
+
return self._d2_bin
|
|
1414
|
+
|
|
1415
|
+
def _render_d2(self, code, width=None, height=None):
|
|
1416
|
+
"""```d2```ブロックをローカルのD2公式CLIバイナリでSVG化し、Typstのimage呼び出しに変換する。
|
|
1417
|
+
外部APIへの通信は行わない(仕様書10章・11章、#90)。`d2 - -`で標準入力から読み、標準出力へ
|
|
1418
|
+
SVGを書く(D2公式のstdin/stdout規約。ステータスメッセージは標準エラーへ出るため混ざらない)。"""
|
|
1419
|
+
if not self.d2_enabled:
|
|
1420
|
+
if not self._d2_disabled_warned:
|
|
1421
|
+
_log_info(f"plugins.d2 is disabled; leaving ```d2 fences as plain code (first seen in {self.current_file}).")
|
|
1422
|
+
self._d2_disabled_warned = True
|
|
1423
|
+
return f"```d2\n{code}```\n\n"
|
|
1424
|
+
|
|
1425
|
+
cache_dir = os.path.join(self.base_dir, ".text-compositor", "cache")
|
|
1426
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
1427
|
+
digest = hashlib.sha256(code.encode('utf-8')).hexdigest()[:16]
|
|
1428
|
+
svg_path = os.path.join(cache_dir, f"d2_{digest}.svg")
|
|
1429
|
+
|
|
1430
|
+
if not os.path.exists(svg_path):
|
|
1431
|
+
_log_info(f"Rendering d2 diagram via local D2 -> {os.path.basename(svg_path)}")
|
|
1432
|
+
d2_bin = self._ensure_d2_bin()
|
|
1433
|
+
try:
|
|
1434
|
+
result = subprocess.run(
|
|
1435
|
+
[d2_bin, "-", "-"],
|
|
1436
|
+
input=code, capture_output=True, text=True, encoding="utf-8", timeout=60)
|
|
1437
|
+
except OSError as e:
|
|
1438
|
+
print(f"[Error] Failed to run D2 for {self.current_file}:\n{e}")
|
|
1439
|
+
# 隔離環境(venv/pipx)外での実行が原因の可能性が高い(#113、ファイルが
|
|
1440
|
+
# 存在するように見えてもサブプロセスから見えない既知の問題)ため優先して案内する。
|
|
1441
|
+
for diag in (_check_isolated_env(), _check_d2(self.d2_enabled, self.d2_auto_download)):
|
|
1442
|
+
if diag.status != "OK":
|
|
1443
|
+
print(f"[Hint] [{diag.status}] {diag.name}: {diag.message}")
|
|
1444
|
+
sys.exit(1)
|
|
1445
|
+
if result.returncode != 0:
|
|
1446
|
+
# 仕様9章のFail-fast方針: 描画失敗時はテキストへフォールバックせず即エラー
|
|
1447
|
+
print(f"[Error] d2 rendering failed for {self.current_file}:\n{result.stderr}")
|
|
1448
|
+
sys.exit(1)
|
|
1449
|
+
with open(svg_path, "w", encoding="utf-8") as f:
|
|
1450
|
+
f.write(result.stdout)
|
|
1451
|
+
else:
|
|
1452
|
+
_log_verbose(f"Reusing cached d2 diagram: {os.path.basename(svg_path)}")
|
|
1453
|
+
|
|
1454
|
+
root_rel_path = escape_string_literal("/" + os.path.relpath(svg_path, self.typst_root).replace(os.sep, '/'))
|
|
1455
|
+
return self._render_sized_image(root_rel_path, width, height)
|
|
1456
|
+
|
|
1457
|
+
def escape_typst(self, text, at_line_start=False):
|
|
1458
|
+
text = text.replace('\\', '\\\\')
|
|
1459
|
+
# 【修正】テーブルセル破壊等のサイレントバグを防ぐため [ および ] もエスケープ対象に追加
|
|
1460
|
+
for c in ['#', '$', '<', '>', '@', '*', '_', '`', '~', '[', ']']:
|
|
1461
|
+
text = text.replace(c, '\\' + c)
|
|
1462
|
+
# 【修正】行頭の = - + / 1. がTypstの見出し・リスト記法に化けるのを防ぐ
|
|
1463
|
+
if at_line_start:
|
|
1464
|
+
text = self.BLOCK_HEAD_RE.sub(lambda m: m.group(1) + '\\' + m.group(2), text)
|
|
1465
|
+
return text
|
|
1466
|
+
|
|
1467
|
+
def _register_glossary_term(self, term):
|
|
1468
|
+
"""[[用語]]の1出現を登録し、Typstの#metadata(none)<gloss-N>ラベルを埋め込むコード片を
|
|
1469
|
+
返す(#47)。metadata()は見た目に影響しない不可視要素で、目次のoutline()と同じ
|
|
1470
|
+
「context+query()でレイアウト後にページ番号を取得する」パターンで巻末索引を組み立てる。"""
|
|
1471
|
+
label_id = f"gloss-{self._glossary_label_counter}"
|
|
1472
|
+
self._glossary_label_counter += 1
|
|
1473
|
+
self.glossary_terms.setdefault(term, []).append(label_id)
|
|
1474
|
+
return f'{self.escape_typst(term)}#metadata(none)<{label_id}>'
|
|
1475
|
+
|
|
1476
|
+
def _render_text_with_glossary(self, content, at_line_start):
|
|
1477
|
+
"""textトークンの中身から[[用語]]を検出して登録しつつ、それ以外は通常どおりエスケープする。
|
|
1478
|
+
code_inline/fence等はrender_inlineに来ないtextトークンとして独立に処理されるため、
|
|
1479
|
+
ここで正規表現置換してもコードブロックの中身を巻き込む心配はない。"""
|
|
1480
|
+
parts = []
|
|
1481
|
+
last_end = 0
|
|
1482
|
+
first_segment = True
|
|
1483
|
+
for m in self.WIKILINK_RE.finditer(content):
|
|
1484
|
+
plain = content[last_end:m.start()]
|
|
1485
|
+
if plain:
|
|
1486
|
+
parts.append(self.escape_typst(plain, at_line_start=(at_line_start and first_segment)))
|
|
1487
|
+
first_segment = False
|
|
1488
|
+
term = m.group(1).strip()
|
|
1489
|
+
parts.append(self._register_glossary_term(term))
|
|
1490
|
+
first_segment = False
|
|
1491
|
+
last_end = m.end()
|
|
1492
|
+
remaining = content[last_end:]
|
|
1493
|
+
if remaining or not parts:
|
|
1494
|
+
parts.append(self.escape_typst(remaining, at_line_start=(at_line_start and first_segment)))
|
|
1495
|
+
return "".join(parts)
|
|
1496
|
+
|
|
1497
|
+
def render_inline(self, tokens):
|
|
1498
|
+
res = []
|
|
1499
|
+
at_line_start = True
|
|
1500
|
+
# 文字色指定(#46)。<span style="color:...">はhtml_inlineの開き/閉じが独立したトークン
|
|
1501
|
+
# として出てくるため、段落内でスタック管理して対応させる。閉じずに段落が終わった場合は
|
|
1502
|
+
# 壊れたTypstコードを生成しないよう自動で閉じ、警告を出す。
|
|
1503
|
+
html_span_depth = 0
|
|
1504
|
+
# [text]{color=red}(attrs_pluginのspan_open/span_close)は既に開閉が対になった
|
|
1505
|
+
# トークンとして出てくるため、各span_openが実際にラップを出力したかどうかだけ
|
|
1506
|
+
# スタックで覚えておけばよい(span_close側は自分でattrsを持たないため)。
|
|
1507
|
+
span_wrap_stack = []
|
|
1508
|
+
for t in tokens:
|
|
1509
|
+
if t.type == 'text':
|
|
1510
|
+
if self.glossary_enabled and '[[' in t.content:
|
|
1511
|
+
res.append(self._render_text_with_glossary(t.content, at_line_start))
|
|
1512
|
+
else:
|
|
1513
|
+
res.append(self.escape_typst(t.content, at_line_start=at_line_start))
|
|
1514
|
+
elif t.type == 'strong_open':
|
|
1515
|
+
res.append('#strong[')
|
|
1516
|
+
elif t.type == 'strong_close':
|
|
1517
|
+
res.append(']')
|
|
1518
|
+
elif t.type == 'em_open':
|
|
1519
|
+
res.append('#emph[')
|
|
1520
|
+
elif t.type == 'em_close':
|
|
1521
|
+
res.append(']')
|
|
1522
|
+
elif t.type == 's_open':
|
|
1523
|
+
res.append('#strike[')
|
|
1524
|
+
elif t.type == 's_close':
|
|
1525
|
+
res.append(']')
|
|
1526
|
+
elif t.type == 'code_inline':
|
|
1527
|
+
# `` `text` ``のように直接バッククォートで組み立てると、text自体にバッククォートが
|
|
1528
|
+
# 含まれる場合(例: 4バッククォートのインラインコードスパンの中身が```を含む)に
|
|
1529
|
+
# Typst側のraw構文が早期に閉じて壊れる。文字列リテラルとして渡すraw()なら安全
|
|
1530
|
+
# (#15の_render_raw_text・フェンスのelse分岐と同じ理由。実測で発覚したバグ)。
|
|
1531
|
+
res.append(f'#raw("{escape_string_literal(t.content)}")')
|
|
1532
|
+
elif t.type in ['softbreak', 'hardbreak']:
|
|
1533
|
+
res.append('#linebreak()\n')
|
|
1534
|
+
elif t.type == 'image':
|
|
1535
|
+
src = dict(t.attrs).get('src', '')
|
|
1536
|
+
alt_text = t.content or ""
|
|
1537
|
+
width_opt = ""
|
|
1538
|
+
height_opt = ""
|
|
1539
|
+
align = None
|
|
1540
|
+
|
|
1541
|
+
# alt_textからサイズ・配置指定 (例: alt|width=50%|height=30%|align=center) を解析
|
|
1542
|
+
if "|" in alt_text:
|
|
1543
|
+
parts = alt_text.split("|")
|
|
1544
|
+
for p in parts[1:]:
|
|
1545
|
+
p = p.strip()
|
|
1546
|
+
if p.startswith("width="):
|
|
1547
|
+
w = p.split("=", 1)[1]
|
|
1548
|
+
width_opt = f', width: {w}'
|
|
1549
|
+
elif p.startswith("height="):
|
|
1550
|
+
h = p.split("=", 1)[1]
|
|
1551
|
+
height_opt = f', height: {h}'
|
|
1552
|
+
elif p.startswith("align="):
|
|
1553
|
+
align = p.split("=", 1)[1].strip()
|
|
1554
|
+
|
|
1555
|
+
# width/height未指定ならfit-image()(実寸基準、はみ出す場合のみ自動縮小)を使う。
|
|
1556
|
+
# 明示指定時は自動縮小をバイパスして#image()へそのまま渡す(拡大も含めて指定値どおり
|
|
1557
|
+
# になる)。mermaid/plantuml等の事前レンダリング画像(_render_sized_image)と同じ
|
|
1558
|
+
# 方針(#69、#82で確立済みの優先順位をそのまま踏襲)。
|
|
1559
|
+
if width_opt or height_opt:
|
|
1560
|
+
image_expr = f'#image("{self._resolve_asset(src)}"{width_opt}{height_opt})'
|
|
1561
|
+
else:
|
|
1562
|
+
image_expr = f'#fit-image("{self._resolve_asset(src)}")'
|
|
1563
|
+
# align未指定時は従来通り(暗黙の左寄せ)のまま変更しない(#75)。他の独自属性
|
|
1564
|
+
# (layout-rightのleft=/right=比率等)と同様、align=の値自体の妥当性チェックは
|
|
1565
|
+
# 行わない(不正値はTypst側の#align()呼び出しでコンパイルエラーになる)。
|
|
1566
|
+
if align:
|
|
1567
|
+
res.append(f'#align({align})[{image_expr}]')
|
|
1568
|
+
else:
|
|
1569
|
+
res.append(image_expr)
|
|
1570
|
+
elif t.type == 'link_open':
|
|
1571
|
+
href = dict(t.attrs).get('href', '')
|
|
1572
|
+
res.append(f'#link("{escape_string_literal(href)}")[')
|
|
1573
|
+
elif t.type == 'link_close':
|
|
1574
|
+
res.append(']')
|
|
1575
|
+
elif t.type == 'span_open':
|
|
1576
|
+
# [text]{color=red}(#46)、[text]{size=10pt}(#93)。attrs_pluginに
|
|
1577
|
+
# allowed=["color", "size"]を指定しているため、それ以外の属性は既にパース段階で
|
|
1578
|
+
# 取り除かれている。color/sizeのどちらも無ければ何もラップしない。両方指定された
|
|
1579
|
+
# 場合は#text()呼び出し1つにfill/sizeをまとめる(2重にラップしない)。
|
|
1580
|
+
attrs = dict(t.attrs)
|
|
1581
|
+
color = attrs.get('color')
|
|
1582
|
+
size = attrs.get('size')
|
|
1583
|
+
if size is not None and not self.FONT_SIZE_RE.match(str(size)):
|
|
1584
|
+
line_no = t.map[0] + 1 if t.map else '?'
|
|
1585
|
+
print(f"[Warning] Ignoring invalid size {size!r} in {self.current_file}:{line_no}; expected e.g. '10pt'.")
|
|
1586
|
+
size = None
|
|
1587
|
+
text_args = []
|
|
1588
|
+
if color:
|
|
1589
|
+
text_args.append(f'fill: {self._color_to_typst(color)}')
|
|
1590
|
+
if size:
|
|
1591
|
+
text_args.append(f'size: {size}')
|
|
1592
|
+
if text_args:
|
|
1593
|
+
res.append(f'#text({", ".join(text_args)})[')
|
|
1594
|
+
span_wrap_stack.append(True)
|
|
1595
|
+
else:
|
|
1596
|
+
span_wrap_stack.append(False)
|
|
1597
|
+
elif t.type == 'span_close':
|
|
1598
|
+
if span_wrap_stack and span_wrap_stack.pop():
|
|
1599
|
+
res.append(']')
|
|
1600
|
+
elif t.type == 'html_inline':
|
|
1601
|
+
content = t.content.strip()
|
|
1602
|
+
span_color_match = self.HTML_SPAN_COLOR_OPEN_RE.match(content)
|
|
1603
|
+
# tasklists_pluginはチェックボックスを生HTML(<input class="task-list-item-checkbox" ...>)
|
|
1604
|
+
# として出力する。<span style="color:...">とあわせ、#46で決めた「閉じた許可リスト」の
|
|
1605
|
+
# 考え方に沿い、このパターンだけを特別扱いする(#48)。それ以外のHTMLは従来どおり警告。
|
|
1606
|
+
if span_color_match:
|
|
1607
|
+
color = span_color_match.group(1).strip()
|
|
1608
|
+
res.append(f'#text(fill: {self._color_to_typst(color)})[')
|
|
1609
|
+
html_span_depth += 1
|
|
1610
|
+
elif self.HTML_SPAN_CLOSE_RE.match(content) and html_span_depth > 0:
|
|
1611
|
+
res.append(']')
|
|
1612
|
+
html_span_depth -= 1
|
|
1613
|
+
else:
|
|
1614
|
+
checkbox = self._task_checkbox_glyph(t.content)
|
|
1615
|
+
if checkbox is not None:
|
|
1616
|
+
res.append(checkbox)
|
|
1617
|
+
else:
|
|
1618
|
+
self._warn_html(t)
|
|
1619
|
+
else:
|
|
1620
|
+
line_no = t.map[0] + 1 if t.map else '?'
|
|
1621
|
+
print(f"[Warning] Unhandled inline token '{t.type}' at {self.current_file}:{line_no}")
|
|
1622
|
+
# 改行直後のテキストのみ行頭エスケープの対象にする
|
|
1623
|
+
at_line_start = t.type in ['softbreak', 'hardbreak']
|
|
1624
|
+
if html_span_depth > 0:
|
|
1625
|
+
# 壊れたTypstコード(閉じ角括弧の不足)を生成しないよう自動で閉じ、書き忘れに気付けるよう警告する
|
|
1626
|
+
print(f"[Warning] Unclosed <span style=\"color:...\"> in {self.current_file}; closing it automatically.")
|
|
1627
|
+
res.append(']' * html_span_depth)
|
|
1628
|
+
return "".join(res)
|
|
1629
|
+
|
|
1630
|
+
def _count_table_cols(self, tokens, start_idx):
|
|
1631
|
+
cols = 0
|
|
1632
|
+
for i in range(start_idx, len(tokens)):
|
|
1633
|
+
if tokens[i].type in ['th_open', 'td_open']:
|
|
1634
|
+
cols += 1
|
|
1635
|
+
if tokens[i].type == 'tr_close':
|
|
1636
|
+
break
|
|
1637
|
+
return max(1, cols)
|
|
1638
|
+
|
|
1639
|
+
# 単純な英数字+ハイフンの識別子(例: "red")のみ、Typstの色定数名として安全に生コード注入できる
|
|
1640
|
+
# と判断する。それ以外("#eeeeee"のようなhex形式や、記号を含む不正な値)はrgb()の文字列引数
|
|
1641
|
+
# として渡す(Typstのコンパイルエラーとして安全に失敗する。文字列リテラル内なのでコード注入の
|
|
1642
|
+
# 心配もない)。
|
|
1643
|
+
COLOR_IDENTIFIER_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9\-]*$')
|
|
1644
|
+
|
|
1645
|
+
def _color_to_typst(self, value):
|
|
1646
|
+
"""config.yamlやMarkdownの色文字列をTypstの色表現へ変換する(#45、#46)。
|
|
1647
|
+
Typstのrgb()は"red"のような色名文字列を受け付けないため(hex文字列のみ)、"#rrggbb"形式は
|
|
1648
|
+
rgb()に、"red"のような単純な識別子はTypstの色定数名としてそのまま渡す。"""
|
|
1649
|
+
value = str(value).strip()
|
|
1650
|
+
if self.COLOR_IDENTIFIER_RE.match(value):
|
|
1651
|
+
return value
|
|
1652
|
+
return f'rgb("{escape_string_literal(value)}")'
|
|
1653
|
+
|
|
1654
|
+
def _table_header_fill_arg(self):
|
|
1655
|
+
"""table_header.backgroundが指定されていれば、#table()のfill:引数(1行目のみ着色)を返す。
|
|
1656
|
+
未指定なら空文字列(従来どおり無装飾)。"""
|
|
1657
|
+
background = self.table_header_style.get('background')
|
|
1658
|
+
if not background:
|
|
1659
|
+
return ''
|
|
1660
|
+
return f',\n fill: (col, row) => if row == 0 {{ {self._color_to_typst(background)} }} else {{ none }}'
|
|
1661
|
+
|
|
1662
|
+
def _table_header_open_close(self):
|
|
1663
|
+
"""table_header.bold/colorに応じた、ヘッダセルの開き/閉じラッパー文字列のペアを返す。
|
|
1664
|
+
いずれも未指定なら空文字列(従来どおり無装飾)。スタイルはセル内で変化しないため、
|
|
1665
|
+
th_open/th_closeそれぞれで独立に呼び出しても一貫した結果になる。"""
|
|
1666
|
+
open_parts = []
|
|
1667
|
+
close_parts = []
|
|
1668
|
+
if self.table_header_style.get('bold'):
|
|
1669
|
+
open_parts.append('#strong[')
|
|
1670
|
+
close_parts.append(']')
|
|
1671
|
+
color = self.table_header_style.get('color')
|
|
1672
|
+
if color:
|
|
1673
|
+
open_parts.append(f'#text(fill: {self._color_to_typst(color)})[')
|
|
1674
|
+
close_parts.append(']')
|
|
1675
|
+
return ''.join(open_parts), ''.join(reversed(close_parts))
|
|
1676
|
+
|
|
1677
|
+
def deep_update(d, u):
|
|
1678
|
+
for k, v in u.items():
|
|
1679
|
+
if isinstance(v, dict):
|
|
1680
|
+
d[k] = deep_update(d.get(k, {}), v)
|
|
1681
|
+
else:
|
|
1682
|
+
d[k] = v
|
|
1683
|
+
return d
|
|
1684
|
+
|
|
1685
|
+
def default_config():
|
|
1686
|
+
return {
|
|
1687
|
+
"document": {
|
|
1688
|
+
"title": "System_Specification",
|
|
1689
|
+
"subtitle": "自動生成ドキュメント",
|
|
1690
|
+
"author": "開発チーム",
|
|
1691
|
+
"date": "auto",
|
|
1692
|
+
"diagnostics": {
|
|
1693
|
+
"line_mapping": "block"
|
|
1694
|
+
}
|
|
1695
|
+
},
|
|
1696
|
+
"output": {
|
|
1697
|
+
"filename": "System_Specification.pdf",
|
|
1698
|
+
"dir": "outputs"
|
|
1699
|
+
},
|
|
1700
|
+
"template": {
|
|
1701
|
+
"path": "template"
|
|
1702
|
+
},
|
|
1703
|
+
"inputs": {
|
|
1704
|
+
"dir": "inputs",
|
|
1705
|
+
"files": None
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
def resolve_template_path(template_path_value, tool_dir, project_dir):
|
|
1710
|
+
"""template.pathを「名前」と「パス」で区別して解決する(5章、#23)。
|
|
1711
|
+
拡張子(.typ)を含まない値(例: template, slide)は「名前」とみなし、ツール同梱の
|
|
1712
|
+
tool_dir/templates/<name>.typ から解決する。.typで終わる値は「パス」とみなし、
|
|
1713
|
+
他の相対パスと同じ規則(5章)でproject_dir基準で解決し、プロジェクト独自の
|
|
1714
|
+
テンプレートを持ち込めるようにする(サブディレクトリの有無を問わない)。
|
|
1715
|
+
絶対パスはos.path.joinの挙動によりそのまま使われる。"""
|
|
1716
|
+
if template_path_value.endswith('.typ'):
|
|
1717
|
+
return os.path.normpath(os.path.join(project_dir, template_path_value))
|
|
1718
|
+
return os.path.join(tool_dir, "templates", template_path_value + ".typ")
|
|
1719
|
+
|
|
1720
|
+
# CJKフォント(Noto Sans JP)の取得元。バイナリはリポジトリに同梱せず、初回ビルド時にのみ
|
|
1721
|
+
# ここから取得しユーザーキャッシュディレクトリ(#110)に保存する(2章の「最小限のダウンロード」方針)。
|
|
1722
|
+
# 版とSHA256を固定し、同梱バイナリと違って取得結果が変わらないようにする(9章の決定論的出力)。
|
|
1723
|
+
NOTO_SANS_JP_RELEASE_URL = "https://github.com/notofonts/noto-cjk/releases/download/Sans2.004/16_NotoSansJP.zip"
|
|
1724
|
+
NOTO_SANS_JP_FILES = {
|
|
1725
|
+
"NotoSansJP-Regular.otf": "dff723ba59d57d136764a04b9b2d03205544f7cd785a711442d6d2d085ac5073",
|
|
1726
|
+
"NotoSansJP-Bold.otf": "1b0edfb500b73a4fa8a4fcaae1bbbd403994e08e73e3e0da37e70d3853f42c5f",
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
def ensure_fonts():
|
|
1730
|
+
"""Noto Sans JP(Regular/Bold)がユーザーキャッシュディレクトリの fonts/NotoSansJP/ に
|
|
1731
|
+
なければダウンロードする。2回目以降のビルドはキャッシュを使い、ネットワークアクセスなしで
|
|
1732
|
+
完結する(#110)。"""
|
|
1733
|
+
font_dir = os.path.join(_user_cache_dir(), "fonts", "NotoSansJP")
|
|
1734
|
+
os.makedirs(font_dir, exist_ok=True)
|
|
1735
|
+
|
|
1736
|
+
missing = [name for name in NOTO_SANS_JP_FILES if not os.path.exists(os.path.join(font_dir, name))]
|
|
1737
|
+
if not missing:
|
|
1738
|
+
return font_dir
|
|
1739
|
+
|
|
1740
|
+
_log_info(f"Downloading Noto Sans JP font (one-time; cached under {font_dir})...")
|
|
1741
|
+
zip_path = os.path.join(font_dir, "_download.zip")
|
|
1742
|
+
try:
|
|
1743
|
+
urllib.request.urlretrieve(NOTO_SANS_JP_RELEASE_URL, zip_path)
|
|
1744
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
1745
|
+
for name in missing:
|
|
1746
|
+
data = zf.read(name)
|
|
1747
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
1748
|
+
if digest != NOTO_SANS_JP_FILES[name]:
|
|
1749
|
+
print(f"[Error] Checksum mismatch for {name}: expected {NOTO_SANS_JP_FILES[name]}, got {digest}")
|
|
1750
|
+
sys.exit(1)
|
|
1751
|
+
with open(os.path.join(font_dir, name), "wb") as f:
|
|
1752
|
+
f.write(data)
|
|
1753
|
+
except zipfile.BadZipFile as e:
|
|
1754
|
+
print(f"[Error] Failed to download fonts (bad zip): {e}")
|
|
1755
|
+
sys.exit(1)
|
|
1756
|
+
except OSError as e:
|
|
1757
|
+
print(f"[Error] Failed to download fonts: {e}")
|
|
1758
|
+
sys.exit(1)
|
|
1759
|
+
finally:
|
|
1760
|
+
if os.path.exists(zip_path):
|
|
1761
|
+
os.remove(zip_path)
|
|
1762
|
+
|
|
1763
|
+
return font_dir
|
|
1764
|
+
|
|
1765
|
+
# Mermaid公式配布の単一バンドルJS(UMD形式、全図種込み)。mermaid-cli丸ごと(npm依存ツリー約396MB)
|
|
1766
|
+
# ではなくこのファイル単体(実測約3.4MB)だけを取得し、Playwright経由でヘッドレスブラウザに
|
|
1767
|
+
# 読み込ませてmermaid.render()を直接呼び出す(仕様書11章、#35)。バージョン・SHA256を固定し、
|
|
1768
|
+
# Noto Sans JPと同様に決定論的な取得結果にする(9章)。
|
|
1769
|
+
MERMAID_JS_URL = "https://cdn.jsdelivr.net/npm/mermaid@11.16.1/dist/mermaid.min.js"
|
|
1770
|
+
MERMAID_JS_SHA256 = "18327bef70d96fb505fe7287d9f6a7362ebf07ff6576ddfaffb1a06f3e1a2954"
|
|
1771
|
+
|
|
1772
|
+
def ensure_mermaid_js():
|
|
1773
|
+
"""mermaid.min.jsがユーザーキャッシュディレクトリの mermaid/ になければダウンロードする。
|
|
1774
|
+
2回目以降のビルドはキャッシュを使い、ネットワークアクセスなしで完結する(#110)。"""
|
|
1775
|
+
cache_dir = os.path.join(_user_cache_dir(), "mermaid")
|
|
1776
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
1777
|
+
js_path = os.path.join(cache_dir, "mermaid.min.js")
|
|
1778
|
+
if os.path.exists(js_path):
|
|
1779
|
+
return js_path
|
|
1780
|
+
|
|
1781
|
+
_log_info(f"Downloading mermaid.min.js (one-time; cached under {cache_dir})...")
|
|
1782
|
+
try:
|
|
1783
|
+
urllib.request.urlretrieve(MERMAID_JS_URL, js_path)
|
|
1784
|
+
except OSError as e:
|
|
1785
|
+
print(f"[Error] Failed to download mermaid.min.js: {e}")
|
|
1786
|
+
sys.exit(1)
|
|
1787
|
+
|
|
1788
|
+
with open(js_path, "rb") as f:
|
|
1789
|
+
digest = hashlib.sha256(f.read()).hexdigest()
|
|
1790
|
+
if digest != MERMAID_JS_SHA256:
|
|
1791
|
+
os.remove(js_path)
|
|
1792
|
+
print(f"[Error] Checksum mismatch for mermaid.min.js: expected {MERMAID_JS_SHA256}, got {digest}")
|
|
1793
|
+
sys.exit(1)
|
|
1794
|
+
|
|
1795
|
+
return js_path
|
|
1796
|
+
|
|
1797
|
+
# ローカルにJava 11+が見つからない場合のみ取得するEclipse Temurin JRE(Adoptium配布、
|
|
1798
|
+
# GPLv2+Classpath Exception。OpenJDK本体と同じライセンス系統で安心度が高い)。CI
|
|
1799
|
+
# (GitHub Actions ubuntu-latest等)はJavaが標準搭載されているためこの取得は発生しない(#22)。
|
|
1800
|
+
# バージョン・プラットフォーム別にURL・SHA256を固定し、決定論的な取得結果にする(9章)。
|
|
1801
|
+
TEMURIN_JRE_RELEASE = "jdk-21.0.12+8"
|
|
1802
|
+
TEMURIN_JRE_BASE_URL = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12%2B8/"
|
|
1803
|
+
TEMURIN_JRE_TOP_DIR = "jdk-21.0.12+8-jre"
|
|
1804
|
+
# キー: (sys.platform判定用キー, platform.machine()正規化キー)。
|
|
1805
|
+
# 値: (アーカイブファイル名, SHA256, アーカイブ形式, TEMURIN_JRE_TOP_DIR配下のjava実行ファイルへの相対パス)
|
|
1806
|
+
TEMURIN_JRE_ASSETS = {
|
|
1807
|
+
("win32", "x86_64"): ("OpenJDK21U-jre_x64_windows_hotspot_21.0.12_8.zip",
|
|
1808
|
+
"b8aa18fef5edb69bee8618f99677d66d0873d22cb40d974c15ac9ffcdecf73ba",
|
|
1809
|
+
"zip", ("bin", "java.exe")),
|
|
1810
|
+
("win32", "aarch64"): ("OpenJDK21U-jre_aarch64_windows_hotspot_21.0.12_8.zip",
|
|
1811
|
+
"a50ed83b6a88d3127d406713f5057d78f845c3412d59e201dac6db37714af85c",
|
|
1812
|
+
"zip", ("bin", "java.exe")),
|
|
1813
|
+
("linux", "x86_64"): ("OpenJDK21U-jre_x64_linux_hotspot_21.0.12_8.tar.gz",
|
|
1814
|
+
"8a379a67c91a3ae61ffb33d46e0a40c7ba35e70713c4db31cfca30492f792eff",
|
|
1815
|
+
"tar.gz", ("bin", "java")),
|
|
1816
|
+
("linux", "aarch64"): ("OpenJDK21U-jre_aarch64_linux_hotspot_21.0.12_8.tar.gz",
|
|
1817
|
+
"5f9c96b656827b9d14ebeda7739e25be554fa6d25669b03847c1df6e869c0679",
|
|
1818
|
+
"tar.gz", ("bin", "java")),
|
|
1819
|
+
("darwin", "x86_64"): ("OpenJDK21U-jre_x64_mac_hotspot_21.0.12_8.tar.gz",
|
|
1820
|
+
"539706197baea8189c9a677aea5bf44671b74a71baa42dde436e312f2158fa3a",
|
|
1821
|
+
"tar.gz", ("Contents", "Home", "bin", "java")),
|
|
1822
|
+
("darwin", "aarch64"): ("OpenJDK21U-jre_aarch64_mac_hotspot_21.0.12_8.tar.gz",
|
|
1823
|
+
"36bb71d6fa5184e12a6483e7662783c2cbd383f5dca8034140f0a84dd5aa797d",
|
|
1824
|
+
"tar.gz", ("Contents", "Home", "bin", "java")),
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
def _temurin_platform_key():
|
|
1828
|
+
if sys.platform == "darwin":
|
|
1829
|
+
os_key = "darwin"
|
|
1830
|
+
elif sys.platform.startswith("linux"):
|
|
1831
|
+
os_key = "linux"
|
|
1832
|
+
else:
|
|
1833
|
+
os_key = "win32"
|
|
1834
|
+
machine = platform.machine().lower()
|
|
1835
|
+
arch_key = "aarch64" if machine in ("arm64", "aarch64") else "x86_64"
|
|
1836
|
+
return os_key, arch_key
|
|
1837
|
+
|
|
1838
|
+
def _jre_cache_root():
|
|
1839
|
+
return os.path.join(_user_cache_dir(), "jre")
|
|
1840
|
+
|
|
1841
|
+
def ensure_temurin_jre():
|
|
1842
|
+
"""ユーザーキャッシュディレクトリの jre/ にEclipse Temurin JREが無ければダウンロード・
|
|
1843
|
+
展開する。java実行ファイルの絶対パスを返す。2回目以降のビルドはキャッシュを使い、
|
|
1844
|
+
ネットワークアクセスなしで完結する(find_system_java()でシステムJavaが見つからなかった
|
|
1845
|
+
場合のみ呼ばれる、#22、#110)。"""
|
|
1846
|
+
key = _temurin_platform_key()
|
|
1847
|
+
asset = TEMURIN_JRE_ASSETS.get(key)
|
|
1848
|
+
if asset is None:
|
|
1849
|
+
print(f"[Error] No Eclipse Temurin JRE build available for this platform ({key[0]}/{key[1]}). "
|
|
1850
|
+
"Install Java 11+ manually and ensure it is on PATH, or set plugins.plantuml: false.")
|
|
1851
|
+
sys.exit(1)
|
|
1852
|
+
filename, sha256, archive_type, java_rel_parts = asset
|
|
1853
|
+
|
|
1854
|
+
cache_root = _jre_cache_root()
|
|
1855
|
+
java_bin_path = os.path.join(cache_root, TEMURIN_JRE_TOP_DIR, *java_rel_parts)
|
|
1856
|
+
if os.path.exists(java_bin_path):
|
|
1857
|
+
return java_bin_path
|
|
1858
|
+
|
|
1859
|
+
os.makedirs(cache_root, exist_ok=True)
|
|
1860
|
+
archive_path = os.path.join(cache_root, filename)
|
|
1861
|
+
_log_info(f"No local Java 11+ found; downloading Eclipse Temurin JRE {TEMURIN_JRE_RELEASE} "
|
|
1862
|
+
f"(one-time; cached under {cache_root})...")
|
|
1863
|
+
try:
|
|
1864
|
+
urllib.request.urlretrieve(TEMURIN_JRE_BASE_URL + filename, archive_path)
|
|
1865
|
+
except OSError as e:
|
|
1866
|
+
print(f"[Error] Failed to download Eclipse Temurin JRE: {e}")
|
|
1867
|
+
sys.exit(1)
|
|
1868
|
+
|
|
1869
|
+
with open(archive_path, "rb") as f:
|
|
1870
|
+
digest = hashlib.sha256(f.read()).hexdigest()
|
|
1871
|
+
if digest != sha256:
|
|
1872
|
+
os.remove(archive_path)
|
|
1873
|
+
print(f"[Error] Checksum mismatch for {filename}: expected {sha256}, got {digest}")
|
|
1874
|
+
sys.exit(1)
|
|
1875
|
+
|
|
1876
|
+
if archive_type == "zip":
|
|
1877
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
1878
|
+
zf.extractall(cache_root)
|
|
1879
|
+
else:
|
|
1880
|
+
with tarfile.open(archive_path, "r:gz") as tf:
|
|
1881
|
+
tf.extractall(cache_root)
|
|
1882
|
+
os.remove(archive_path)
|
|
1883
|
+
|
|
1884
|
+
if not os.path.exists(java_bin_path):
|
|
1885
|
+
print(f"[Error] Eclipse Temurin JRE extraction did not produce the expected binary: {java_bin_path}")
|
|
1886
|
+
sys.exit(1)
|
|
1887
|
+
if key[0] != "win32":
|
|
1888
|
+
os.chmod(java_bin_path, 0o755)
|
|
1889
|
+
|
|
1890
|
+
return java_bin_path
|
|
1891
|
+
|
|
1892
|
+
# PlantUML本体(MIT版。GPL/LGPL/Apache/EPL版と機能差はDITAA等ごく一部のみで、UML図生成は
|
|
1893
|
+
# 100%対応。#22の調査でmit-light版はstdlib/クラウドアイコン素材と絵文字データのみが欠けることが
|
|
1894
|
+
# 分かったが、Markdown原稿(GitHub管理・AI生成)には絵文字が含まれ得るため、フル機能のmit版を
|
|
1895
|
+
# 採用する)。レイアウトエンジンはSmetana(純Java実装)を明示指定し、Graphviz(dot)実行ファイルへの
|
|
1896
|
+
# 依存を避ける(-Playout=smetana)。バージョン・SHA256を固定し、決定論的な取得結果にする(9章)。
|
|
1897
|
+
PLANTUML_JAR_URL = "https://github.com/plantuml/plantuml/releases/download/v1.2026.6/plantuml-mit-1.2026.6.jar"
|
|
1898
|
+
PLANTUML_JAR_SHA256 = "5814ab31dd569f3772747c3a0c1b52fd3bf2996b8132c62d17006d758c2d3fe3"
|
|
1899
|
+
|
|
1900
|
+
def ensure_plantuml_jar():
|
|
1901
|
+
"""plantuml.jarがユーザーキャッシュディレクトリの plantuml/ になければダウンロードする。
|
|
1902
|
+
2回目以降のビルドはキャッシュを使い、ネットワークアクセスなしで完結する(#110)。"""
|
|
1903
|
+
cache_dir = os.path.join(_user_cache_dir(), "plantuml")
|
|
1904
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
1905
|
+
jar_path = os.path.join(cache_dir, "plantuml-mit.jar")
|
|
1906
|
+
if os.path.exists(jar_path):
|
|
1907
|
+
return jar_path
|
|
1908
|
+
|
|
1909
|
+
_log_info(f"Downloading plantuml.jar (one-time; cached under {cache_dir})...")
|
|
1910
|
+
try:
|
|
1911
|
+
urllib.request.urlretrieve(PLANTUML_JAR_URL, jar_path)
|
|
1912
|
+
except OSError as e:
|
|
1913
|
+
print(f"[Error] Failed to download plantuml.jar: {e}")
|
|
1914
|
+
sys.exit(1)
|
|
1915
|
+
|
|
1916
|
+
with open(jar_path, "rb") as f:
|
|
1917
|
+
digest = hashlib.sha256(f.read()).hexdigest()
|
|
1918
|
+
if digest != PLANTUML_JAR_SHA256:
|
|
1919
|
+
os.remove(jar_path)
|
|
1920
|
+
print(f"[Error] Checksum mismatch for plantuml.jar: expected {PLANTUML_JAR_SHA256}, got {digest}")
|
|
1921
|
+
sys.exit(1)
|
|
1922
|
+
|
|
1923
|
+
return jar_path
|
|
1924
|
+
|
|
1925
|
+
# D2公式CLIバイナリ(Go製、単一実行ファイル、外部ランタイム不要)。mermaid/plantumlと同じ設計方針
|
|
1926
|
+
# (#90)で、GitHub Releasesから取得しSHA256を固定した上でtool_dir配下にキャッシュする。
|
|
1927
|
+
# バージョン・SHA256を固定し、決定論的な取得結果にする(9章)。SHA256は公式リリースの
|
|
1928
|
+
# SHA256SUMSアセットから取得した値(https://github.com/d2lang/d2/releases/tag/v0.9.0)。
|
|
1929
|
+
D2_RELEASE = "v0.9.0"
|
|
1930
|
+
D2_BASE_URL = f"https://github.com/d2lang/d2/releases/download/{D2_RELEASE}/"
|
|
1931
|
+
D2_TOP_DIR = f"d2-{D2_RELEASE}"
|
|
1932
|
+
D2_ASSETS = {
|
|
1933
|
+
("win32", "x86_64"): ("d2-v0.9.0-windows-amd64.tar.gz",
|
|
1934
|
+
"5f63b643de8f5a6dfb922d172e1b5496e4caf47497c33c4427cf1127f28c340f"),
|
|
1935
|
+
("win32", "aarch64"): ("d2-v0.9.0-windows-arm64.tar.gz",
|
|
1936
|
+
"dd05cab459410c287d7ca3eb9cf78145a071742ee8e1b81a0122f84f471883e1"),
|
|
1937
|
+
("linux", "x86_64"): ("d2-v0.9.0-linux-amd64.tar.gz",
|
|
1938
|
+
"5669ddc46b99e942cc96078f4a4e36d5e62103348f4c05179ede27802fdd87a9"),
|
|
1939
|
+
("linux", "aarch64"): ("d2-v0.9.0-linux-arm64.tar.gz",
|
|
1940
|
+
"ac2c028697199479acb321db1e3d68caee9f2ba492ed73caa3cd13f3829bf913"),
|
|
1941
|
+
("darwin", "x86_64"): ("d2-v0.9.0-macos-amd64.tar.gz",
|
|
1942
|
+
"cad39576a480d6bb02ea142fef1726647914b0d2da51ccc9b30b660a2b1babf0"),
|
|
1943
|
+
("darwin", "aarch64"): ("d2-v0.9.0-macos-arm64.tar.gz",
|
|
1944
|
+
"eaf6c0c143e56dd9fa97bfb6df25ea9c1ebce40245f056a0768cf1a6c15d3064"),
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
def _d2_cache_root():
|
|
1948
|
+
return os.path.join(_user_cache_dir(), "d2")
|
|
1949
|
+
|
|
1950
|
+
def _d2_bin_path(cache_root, os_key):
|
|
1951
|
+
exe = "d2.exe" if os_key == "win32" else "d2"
|
|
1952
|
+
return os.path.join(cache_root, D2_TOP_DIR, "bin", exe)
|
|
1953
|
+
|
|
1954
|
+
def ensure_d2_binary():
|
|
1955
|
+
"""ユーザーキャッシュディレクトリの d2/ にD2公式CLIバイナリが無ければダウンロード・展開する。
|
|
1956
|
+
d2実行ファイルの絶対パスを返す。2回目以降のビルドはキャッシュを使い、ネットワークアクセス
|
|
1957
|
+
なしで完結する(find_system_d2()でシステムのdコマンドが見つからなかった場合のみ呼ばれる、
|
|
1958
|
+
#90)。プラットフォーム判定は_temurin_platform_key()を共用する(OS/CPUアーキテクチャの
|
|
1959
|
+
検出ロジックはD2固有の事情が無く、Temurin JRE用のものと同一のため)。"""
|
|
1960
|
+
key = _temurin_platform_key()
|
|
1961
|
+
asset = D2_ASSETS.get(key)
|
|
1962
|
+
if asset is None:
|
|
1963
|
+
print(f"[Error] No D2 CLI build available for this platform ({key[0]}/{key[1]}). "
|
|
1964
|
+
"Install D2 manually (https://d2lang.com) and ensure it is on PATH, or set plugins.d2: false.")
|
|
1965
|
+
sys.exit(1)
|
|
1966
|
+
filename, sha256 = asset
|
|
1967
|
+
|
|
1968
|
+
cache_root = _d2_cache_root()
|
|
1969
|
+
d2_bin_path = _d2_bin_path(cache_root, key[0])
|
|
1970
|
+
if os.path.exists(d2_bin_path):
|
|
1971
|
+
return d2_bin_path
|
|
1972
|
+
|
|
1973
|
+
os.makedirs(cache_root, exist_ok=True)
|
|
1974
|
+
archive_path = os.path.join(cache_root, filename)
|
|
1975
|
+
_log_info(f"No local D2 found; downloading D2 CLI {D2_RELEASE} (one-time; cached under {cache_root})...")
|
|
1976
|
+
try:
|
|
1977
|
+
urllib.request.urlretrieve(D2_BASE_URL + filename, archive_path)
|
|
1978
|
+
except OSError as e:
|
|
1979
|
+
print(f"[Error] Failed to download D2 CLI: {e}")
|
|
1980
|
+
sys.exit(1)
|
|
1981
|
+
|
|
1982
|
+
with open(archive_path, "rb") as f:
|
|
1983
|
+
digest = hashlib.sha256(f.read()).hexdigest()
|
|
1984
|
+
if digest != sha256:
|
|
1985
|
+
os.remove(archive_path)
|
|
1986
|
+
print(f"[Error] Checksum mismatch for {filename}: expected {sha256}, got {digest}")
|
|
1987
|
+
sys.exit(1)
|
|
1988
|
+
|
|
1989
|
+
with tarfile.open(archive_path, "r:gz") as tf:
|
|
1990
|
+
tf.extractall(cache_root)
|
|
1991
|
+
os.remove(archive_path)
|
|
1992
|
+
|
|
1993
|
+
if not os.path.exists(d2_bin_path):
|
|
1994
|
+
print(f"[Error] D2 CLI extraction did not produce the expected binary: {d2_bin_path}")
|
|
1995
|
+
sys.exit(1)
|
|
1996
|
+
if key[0] != "win32":
|
|
1997
|
+
os.chmod(d2_bin_path, 0o755)
|
|
1998
|
+
|
|
1999
|
+
return d2_bin_path
|
|
2000
|
+
|
|
2001
|
+
def _launch_headless_chrome(browser_path, user_data_dir):
|
|
2002
|
+
"""browser_pathをリモートデバッグ有効・ヘッドレスで起動し、(Popen, ポート番号)を返す。
|
|
2003
|
+
ポートは0(OSに自動割当させる)を指定し、Chromeがuser_data_dir/DevToolsActivePortに
|
|
2004
|
+
書き出す実際のポートを読み取る(固定ポートによる競合を避けるため)。"""
|
|
2005
|
+
os.makedirs(user_data_dir, exist_ok=True)
|
|
2006
|
+
port_file = os.path.join(user_data_dir, "DevToolsActivePort")
|
|
2007
|
+
if os.path.exists(port_file):
|
|
2008
|
+
os.remove(port_file)
|
|
2009
|
+
|
|
2010
|
+
proc = subprocess.Popen(
|
|
2011
|
+
[browser_path, "--remote-debugging-port=0", "--headless=new", "--disable-gpu",
|
|
2012
|
+
"--no-first-run", f"--user-data-dir={user_data_dir}"],
|
|
2013
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
2014
|
+
)
|
|
2015
|
+
for _ in range(80):
|
|
2016
|
+
if os.path.exists(port_file):
|
|
2017
|
+
with open(port_file, "r", encoding="utf-8") as f:
|
|
2018
|
+
port = int(f.readline().strip())
|
|
2019
|
+
return proc, port
|
|
2020
|
+
if proc.poll() is not None:
|
|
2021
|
+
break
|
|
2022
|
+
time.sleep(0.25)
|
|
2023
|
+
|
|
2024
|
+
proc.terminate()
|
|
2025
|
+
print("[Error] Headless browser did not become ready in time (needed for mermaid rendering).")
|
|
2026
|
+
sys.exit(1)
|
|
2027
|
+
|
|
2028
|
+
def load_config_file(config_path):
|
|
2029
|
+
"""指定された1ファイル(yaml/json)から設定を読み込む。存在しなければFail-fast。"""
|
|
2030
|
+
config = default_config()
|
|
2031
|
+
if not os.path.exists(config_path):
|
|
2032
|
+
print(f"[Error] Config file not found: {config_path}")
|
|
2033
|
+
sys.exit(1)
|
|
2034
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
2035
|
+
if config_path.endswith(('.yaml', '.yml')):
|
|
2036
|
+
if yaml is None:
|
|
2037
|
+
print("[Error] PyYAML is not installed; cannot read a .yaml config file.")
|
|
2038
|
+
sys.exit(1)
|
|
2039
|
+
loaded = yaml.safe_load(f) or {}
|
|
2040
|
+
else:
|
|
2041
|
+
loaded = json.load(f) or {}
|
|
2042
|
+
deep_update(config, loaded)
|
|
2043
|
+
return config
|
|
2044
|
+
|
|
2045
|
+
def escape_string_literal(text):
|
|
2046
|
+
return str(text).replace('\\', '\\\\').replace('"', '\\"')
|
|
2047
|
+
|
|
2048
|
+
def _typst_str_or_none(value):
|
|
2049
|
+
"""PythonのNone/文字列をTypstの`none`/文字列リテラルへ変換する(#42のheader/footer等)。"""
|
|
2050
|
+
if value is None:
|
|
2051
|
+
return "none"
|
|
2052
|
+
return f'"{escape_string_literal(str(value))}"'
|
|
2053
|
+
|
|
2054
|
+
def _resolve_project_image_path(path, base_dir, typst_root, label):
|
|
2055
|
+
"""document.background/chapters[].background(#55)、document.logo/chapters[].logo(#54)の
|
|
2056
|
+
相対パスを、project_dir基準からtypst_root起点のルート絶対パスへ変換する(5章: config.yaml内の
|
|
2057
|
+
相対パスはproject_dir基準。Markdown内画像の_resolve_asset()とは基準ディレクトリが異なる)。
|
|
2058
|
+
仕様9章のFail-fast方針に従い、画像欠損は即エラーとする。"""
|
|
2059
|
+
if not path:
|
|
2060
|
+
return None
|
|
2061
|
+
abs_path = os.path.normpath(os.path.join(base_dir, path))
|
|
2062
|
+
if not os.path.exists(abs_path):
|
|
2063
|
+
print(f"[Error] {label} image not found: {abs_path}")
|
|
2064
|
+
sys.exit(1)
|
|
2065
|
+
return "/" + os.path.relpath(abs_path, typst_root).replace(os.sep, '/')
|
|
2066
|
+
|
|
2067
|
+
def _page_set_fragment(paper, landscape, header, footer, paginate, background, logo):
|
|
2068
|
+
"""paper/landscape/header/footer/paginate/background/logoをまとめた#set page(...)断片を
|
|
2069
|
+
組み立てる(#42、#17、#55、#54)。headerがNone(chapters[]/front-matterで明示的にnullを
|
|
2070
|
+
指定した場合のみ起こりうる。グローバルの既定値は常にtitleへフォールバック済みでNoneにならない)
|
|
2071
|
+
ならheader自体を非表示にする(logoも一緒に消える。ヘッダーごと消す指定のため妥当)。
|
|
2072
|
+
footerはrender-footer()側でNone/paginateの組み合わせを判定するため、常にrender-footer()を
|
|
2073
|
+
呼ぶ。backgroundも同様にrender-background()側でNone判定する。"""
|
|
2074
|
+
header_expr = ("none" if header is None
|
|
2075
|
+
else f'render-header({_typst_str_or_none(header)}, {_typst_str_or_none(logo)})')
|
|
2076
|
+
footer_expr = f'render-footer({_typst_str_or_none(footer)}, {str(paginate).lower()})'
|
|
2077
|
+
background_expr = f'render-background({_typst_str_or_none(background)})'
|
|
2078
|
+
return (f'#set page(paper: "{paper}", flipped: {str(landscape).lower()}, '
|
|
2079
|
+
f'header: {header_expr}, footer: {footer_expr}, background: {background_expr})\n')
|
|
2080
|
+
|
|
2081
|
+
def _build_glossary_section(glossary_terms):
|
|
2082
|
+
"""document.glossary: trueの場合、全チャプター処理後にTypstRenderer.glossary_terms
|
|
2083
|
+
(term -> [label_id, ...])から巻末の用語索引ページを組み立てる(#47)。文字コード順
|
|
2084
|
+
(Pythonのsorted())で並べ、同じ用語の全出現ページ番号を重複除去のうえ昇順で列挙する。
|
|
2085
|
+
定義文は持たない索引型(本の巻末索引と同じ形)。"""
|
|
2086
|
+
entries = []
|
|
2087
|
+
for term in sorted(glossary_terms.keys()):
|
|
2088
|
+
label_ids = glossary_terms[term]
|
|
2089
|
+
label_list = ", ".join(f'"{escape_string_literal(lbl)}"' for lbl in label_ids)
|
|
2090
|
+
safe_term = escape_string_literal(term)
|
|
2091
|
+
entries.append(f' ("{safe_term}", ({label_list},)),')
|
|
2092
|
+
entries_block = "\n".join(entries)
|
|
2093
|
+
|
|
2094
|
+
static_part = """
|
|
2095
|
+
#context {
|
|
2096
|
+
for (term, label_ids) in __glossary_entries {
|
|
2097
|
+
let pages = ()
|
|
2098
|
+
for lbl in label_ids {
|
|
2099
|
+
let found = query(label(lbl))
|
|
2100
|
+
if found.len() > 0 {
|
|
2101
|
+
pages.push(found.first().location().page())
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
pages = pages.sorted().dedup()
|
|
2105
|
+
let page-str = pages.map(str).join(", ")
|
|
2106
|
+
[#term #box(width: 1fr, repeat[.]) #page-str]
|
|
2107
|
+
linebreak()
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
"""
|
|
2111
|
+
return (
|
|
2112
|
+
"\n#pagebreak(weak: true)\n"
|
|
2113
|
+
"= 用語索引\n\n"
|
|
2114
|
+
"#let __glossary_entries = (\n"
|
|
2115
|
+
f"{entries_block}\n"
|
|
2116
|
+
")\n"
|
|
2117
|
+
+ static_part
|
|
2118
|
+
)
|
|
2119
|
+
|
|
2120
|
+
def extract_md_string(data, key):
|
|
2121
|
+
"""YAMLからテキストを抽出。リスト形式の場合は改行で結合して単一文字列にする"""
|
|
2122
|
+
val = data.get(key, "")
|
|
2123
|
+
if isinstance(val, list):
|
|
2124
|
+
return "\n".join(str(v) for v in val)
|
|
2125
|
+
return str(val)
|
|
2126
|
+
|
|
2127
|
+
def find_config_in_cwd():
|
|
2128
|
+
"""--config省略時、カレントディレクトリ直下の推奨ファイル名を探す(ツール本体ディレクトリは見ない)。"""
|
|
2129
|
+
for name in ("text-compositor.config.yaml", "text-compositor.config.json"):
|
|
2130
|
+
path = os.path.join(os.getcwd(), name)
|
|
2131
|
+
if os.path.exists(path):
|
|
2132
|
+
return path
|
|
2133
|
+
return None
|
|
2134
|
+
|
|
2135
|
+
def parse_args():
|
|
2136
|
+
parser = argparse.ArgumentParser(description="Markdown -> Typst -> PDF ドキュメントビルダー")
|
|
2137
|
+
parser.add_argument("--config", help="設定ファイル(yaml/json)へのパス。省略時はカレントディレクトリの text-compositor.config.yaml/.json を探す。")
|
|
2138
|
+
parser.add_argument("--config-list", help="ビルド対象のconfigファイルパスを1行1件で列挙したテキストファイル。空行と'#'で始まる行は無視される。--configとは同時指定できない。相対パスはこのファイル自身の置き場所が基準。")
|
|
2139
|
+
parser.add_argument("--check-env", action="store_true",
|
|
2140
|
+
help="ビルドを実行せず、実行環境の前提(依存パッケージ・Typstバージョン・"
|
|
2141
|
+
"フォントキャッシュ・mermaid/plantumlに必要なツール)を確認して終了する(#37)。"
|
|
2142
|
+
"--configと併用するとそのplugins設定を反映する。NGが1件でもあればexit code 1。")
|
|
2143
|
+
# 実行時の振る舞い系オプション(#52)。文書の内容(出力先・用紙設定等)に関わる上書きオプションは
|
|
2144
|
+
# 「config.yamlが単一の正」という方針とやや相性が悪いため見送り、ログレベルと中間ファイルの
|
|
2145
|
+
# 扱いのみをCLIオプション化した(Issue本文で見送りが推奨されていた)。
|
|
2146
|
+
parser.add_argument("-q", "--quiet", action="store_true",
|
|
2147
|
+
help="[Info]レベルのログを抑制する([Warning]/[Error]/[Success]は常に表示)。-vとは同時指定できない。")
|
|
2148
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
2149
|
+
help="[Info]に加え、処理中の章やキャッシュ再利用状況など[Verbose]レベルの詳細なログも表示する。-qとは同時指定できない。")
|
|
2150
|
+
parser.add_argument("--keep-temp", action="store_true",
|
|
2151
|
+
help="ビルド成功時も中間ファイル(temp_build.typ等、.text-compositor/配下)を削除せずに残す。"
|
|
2152
|
+
"既定ではビルド失敗時のみ残る(デバッグ用)。")
|
|
2153
|
+
args = parser.parse_args()
|
|
2154
|
+
if args.quiet and args.verbose:
|
|
2155
|
+
parser.error("-q/--quiet と -v/--verbose は同時に指定できません。")
|
|
2156
|
+
if args.config and args.config_list:
|
|
2157
|
+
parser.error("--config と --config-list は同時に指定できません。")
|
|
2158
|
+
if args.check_env and args.config_list:
|
|
2159
|
+
parser.error("--check-env と --config-list は同時に指定できません。")
|
|
2160
|
+
return args
|
|
2161
|
+
|
|
2162
|
+
def _read_config_list(list_path):
|
|
2163
|
+
"""--config-listのファイルを読み、configファイルパスのリストを返す(コメント行・空行を除く)。"""
|
|
2164
|
+
list_path = os.path.abspath(list_path)
|
|
2165
|
+
base_dir = os.path.dirname(list_path)
|
|
2166
|
+
paths = []
|
|
2167
|
+
with open(list_path, "r", encoding="utf-8") as f:
|
|
2168
|
+
for line in f:
|
|
2169
|
+
line = line.strip()
|
|
2170
|
+
if not line or line.startswith("#"):
|
|
2171
|
+
continue
|
|
2172
|
+
paths.append(line if os.path.isabs(line) else os.path.join(base_dir, line))
|
|
2173
|
+
return paths
|
|
2174
|
+
|
|
2175
|
+
def _load_project_config(config_path):
|
|
2176
|
+
"""configパス(Noneならカレントディレクトリから探索)から設定ファイルを読み込み、(project_dir, config, chapters)を返す。"""
|
|
2177
|
+
if config_path:
|
|
2178
|
+
config_path = os.path.abspath(config_path)
|
|
2179
|
+
else:
|
|
2180
|
+
config_path = find_config_in_cwd()
|
|
2181
|
+
if not config_path:
|
|
2182
|
+
print("[Error] --config not specified, and no text-compositor.config.yaml/.json found in the current directory.")
|
|
2183
|
+
sys.exit(1)
|
|
2184
|
+
project_dir = os.path.dirname(config_path)
|
|
2185
|
+
config = load_config_file(config_path)
|
|
2186
|
+
|
|
2187
|
+
chapters = config.get("chapters", [])
|
|
2188
|
+
# 【修正】章が空の場合は正常終了せずFail-fastでエラー終了させる
|
|
2189
|
+
if not chapters:
|
|
2190
|
+
print("[Error] No chapters configured in config.yaml. Aborting.")
|
|
2191
|
+
sys.exit(1)
|
|
2192
|
+
return project_dir, config, chapters
|
|
2193
|
+
|
|
2194
|
+
def _resolve_project_dirs(project_dir, config):
|
|
2195
|
+
"""出力先・入力元・作業ディレクトリと、それらを跨ぐ--root(typst_root)を解決する。"""
|
|
2196
|
+
outputs_dir = os.path.normpath(os.path.join(project_dir, config["output"]["dir"]))
|
|
2197
|
+
os.makedirs(outputs_dir, exist_ok=True)
|
|
2198
|
+
# 【修正】ハードコードをやめ config の inputs.dir を実際に使用する
|
|
2199
|
+
inputs_dir = os.path.normpath(os.path.join(project_dir, config.get("inputs", {}).get("dir") or "inputs"))
|
|
2200
|
+
|
|
2201
|
+
work_dir = os.path.join(project_dir, ".text-compositor")
|
|
2202
|
+
os.makedirs(work_dir, exist_ok=True)
|
|
2203
|
+
|
|
2204
|
+
# project_dir・inputs_dir・outputs_dir・work_dirすべてを跨いでtypstから参照できるよう、
|
|
2205
|
+
# それら全ての共通の親ディレクトリを --root にする(tool_dirは含めない)
|
|
2206
|
+
typst_root = os.path.commonpath([project_dir, inputs_dir, outputs_dir, work_dir])
|
|
2207
|
+
return outputs_dir, inputs_dir, work_dir, typst_root
|
|
2208
|
+
|
|
2209
|
+
def _prepare_template(config, tool_dir, project_dir, work_dir, typst_root):
|
|
2210
|
+
"""template.pathを解決してwork_dir配下へコピーし、(コピー先の絶対パス, --root起点の
|
|
2211
|
+
ルート絶対パス文字列)を返す。8章のセキュリティ要件(tool_dirを--rootにしない)を満たす
|
|
2212
|
+
ため、テンプレートは元の置き場所に関わらずwork_dir(--rootの内側)へコピーしてから参照する。"""
|
|
2213
|
+
template_abs_path = resolve_template_path(config["template"]["path"], tool_dir, project_dir)
|
|
2214
|
+
if not os.path.exists(template_abs_path):
|
|
2215
|
+
print(f"[Error] Template not found: {template_abs_path}")
|
|
2216
|
+
sys.exit(1)
|
|
2217
|
+
template_copy_path = os.path.join(work_dir, "_template" + os.path.splitext(template_abs_path)[1])
|
|
2218
|
+
shutil.copyfile(template_abs_path, template_copy_path)
|
|
2219
|
+
|
|
2220
|
+
# 生成コード(temp_build.typ)の実際の置き場所に依存させないよう、typst_root起点の
|
|
2221
|
+
# ルート絶対パスに変換する(.text-compositor/等サブディレクトリに置いても解決できる)。
|
|
2222
|
+
template_root_rel_path = "/" + os.path.relpath(template_copy_path, typst_root).replace(os.sep, '/')
|
|
2223
|
+
return template_copy_path, template_root_rel_path
|
|
2224
|
+
|
|
2225
|
+
def _build_document_preamble(config, template_root_rel_path, graphviz_enabled, project_dir, typst_root):
|
|
2226
|
+
"""document:設定からtypst_codeの冒頭(テンプレートのimportとconf()呼び出し)を組み立てる。
|
|
2227
|
+
戻り値は (preamble文字列, global_landscape, global_paper, cover_mode, global_table_header,
|
|
2228
|
+
global_header, global_footer, global_paginate, global_background, global_logo)。"""
|
|
2229
|
+
doc_config = config.get("document", {})
|
|
2230
|
+
global_landscape = str(doc_config.get('landscape', False)).lower() == 'true'
|
|
2231
|
+
global_paper = doc_config.get('paper_size', 'a4')
|
|
2232
|
+
# 通常のMarkdownテーブルのヘッダ行スタイル(#45)。未指定なら従来どおり無装飾。
|
|
2233
|
+
global_table_header = doc_config.get('table_header') or {}
|
|
2234
|
+
# 本文ページのヘッダー・フッター・ページ番号表示(#42)。header/footerは未指定ならNone
|
|
2235
|
+
# (テンプレート側でheaderはtitleへフォールバックする。footerはページ番号のみの従来動作)。
|
|
2236
|
+
global_header = doc_config.get('header')
|
|
2237
|
+
global_footer = doc_config.get('footer')
|
|
2238
|
+
global_paginate = str(doc_config.get('paginate', True)).lower() == 'true'
|
|
2239
|
+
# 本文ページの背景画像(#55)。header/footerと同じ「常にconf()へ渡す必須引数」パターンで、
|
|
2240
|
+
# chapters[]単位の上書きにも対応する(_page_set_fragment)。
|
|
2241
|
+
global_background = _resolve_project_image_path(doc_config.get('background'), project_dir, typst_root, "Background")
|
|
2242
|
+
# ヘッダーのロゴ画像(#54)。backgroundと全く同じパターン。
|
|
2243
|
+
global_logo = _resolve_project_image_path(doc_config.get('logo'), project_dir, typst_root, "Logo")
|
|
2244
|
+
|
|
2245
|
+
# 表紙の扱い: template=テンプレートの表紙のみ / replace=テンプレートの表紙でMarkdown先頭の
|
|
2246
|
+
# タイトルスライドを置き換える / markdown=Markdown側のみ / none=表紙なし。既定はnone(安定版前の
|
|
2247
|
+
# ため、表紙の要否を明示させる方針。#58の目次デフォルト変更と合わせた判断)
|
|
2248
|
+
cover_mode = doc_config.get('cover', 'none')
|
|
2249
|
+
if isinstance(cover_mode, bool):
|
|
2250
|
+
cover_mode = 'template' if cover_mode else 'none'
|
|
2251
|
+
cover_mode = str(cover_mode).lower()
|
|
2252
|
+
if cover_mode not in ('template', 'replace', 'markdown', 'none'):
|
|
2253
|
+
print(f"[Error] Invalid document.cover: {cover_mode!r} (expected template / replace / markdown / none)")
|
|
2254
|
+
sys.exit(1)
|
|
2255
|
+
# template/replaceのときだけ引数を渡さず、cover引数を持たない既存テンプレートとの互換を保つ
|
|
2256
|
+
cover_arg = '' if cover_mode in ('template', 'replace') else ' cover: false,\n'
|
|
2257
|
+
|
|
2258
|
+
# 表紙のページ番号表示。未指定ならテンプレート自身の既定値に任せ、引数自体を渡さない
|
|
2259
|
+
cover_page_number = doc_config.get('cover_page_number')
|
|
2260
|
+
cover_page_number_arg = (
|
|
2261
|
+
f' cover_page_number: {str(bool(cover_page_number)).lower()},\n'
|
|
2262
|
+
if cover_page_number is not None else ''
|
|
2263
|
+
)
|
|
2264
|
+
|
|
2265
|
+
# 目次の表示有無(#58)。未指定ならテンプレート自身の既定値(false)に任せ、引数自体を渡さない
|
|
2266
|
+
toc = doc_config.get('toc')
|
|
2267
|
+
toc_arg = f' toc: {str(bool(toc)).lower()},\n' if toc is not None else ''
|
|
2268
|
+
|
|
2269
|
+
date_str = doc_config.get("date", "")
|
|
2270
|
+
if date_str == "auto":
|
|
2271
|
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
2272
|
+
|
|
2273
|
+
safe_title = escape_string_literal(doc_config.get('title', 'Untitled'))
|
|
2274
|
+
safe_subtitle = escape_string_literal(doc_config.get('subtitle', ''))
|
|
2275
|
+
safe_author = escape_string_literal(doc_config.get('author', ''))
|
|
2276
|
+
safe_date = escape_string_literal(date_str)
|
|
2277
|
+
|
|
2278
|
+
preamble = f"""
|
|
2279
|
+
#import "{template_root_rel_path.replace(os.sep, '/')}": conf, fit-image, render-graph, render-header, render-footer, render-background, callout
|
|
2280
|
+
#show: doc => conf(
|
|
2281
|
+
title: "{safe_title}",
|
|
2282
|
+
subtitle: "{safe_subtitle}",
|
|
2283
|
+
author: "{safe_author}",
|
|
2284
|
+
date: "{safe_date}",
|
|
2285
|
+
paper_size: "{global_paper}",
|
|
2286
|
+
landscape: {str(global_landscape).lower()},
|
|
2287
|
+
{cover_arg}{cover_page_number_arg}{toc_arg} graphviz: {str(graphviz_enabled).lower()},
|
|
2288
|
+
header: {_typst_str_or_none(global_header)},
|
|
2289
|
+
footer: {_typst_str_or_none(global_footer)},
|
|
2290
|
+
paginate: {str(global_paginate).lower()},
|
|
2291
|
+
background: {_typst_str_or_none(global_background)},
|
|
2292
|
+
logo: {_typst_str_or_none(global_logo)},
|
|
2293
|
+
doc,
|
|
2294
|
+
)
|
|
2295
|
+
|
|
2296
|
+
"""
|
|
2297
|
+
return (preamble, global_landscape, global_paper, cover_mode, global_table_header,
|
|
2298
|
+
global_header, global_footer, global_paginate, global_background, global_logo)
|
|
2299
|
+
|
|
2300
|
+
def _parse_chapter_entry(ch):
|
|
2301
|
+
"""chaptersの1エントリを解析し、(ファイル/ディレクトリ名, 章固有設定のdict, 種別)を返す。
|
|
2302
|
+
種別は"file"(Markdown等の通常章)または"aggregate"(YAML/JSON集約)。"""
|
|
2303
|
+
if isinstance(ch, str):
|
|
2304
|
+
return ch, {}, "file"
|
|
2305
|
+
if not isinstance(ch, dict):
|
|
2306
|
+
print(f"[Error] Invalid chapter entry (must be a string or a mapping): {ch!r}")
|
|
2307
|
+
sys.exit(1)
|
|
2308
|
+
if "aggregate" in ch:
|
|
2309
|
+
ch_file = ch["aggregate"]
|
|
2310
|
+
ch_type = "aggregate"
|
|
2311
|
+
else:
|
|
2312
|
+
ch_file = ch.get("file")
|
|
2313
|
+
ch_type = "file"
|
|
2314
|
+
if not ch_file:
|
|
2315
|
+
print(f"[Error] Invalid chapter entry (no 'file' or 'aggregate' key): {ch!r}")
|
|
2316
|
+
sys.exit(1)
|
|
2317
|
+
return ch_file, ch, ch_type
|
|
2318
|
+
|
|
2319
|
+
def _render_aggregate_chapter(ch_dict, ch_file, inputs_dir, renderer, current_landscape, current_paper,
|
|
2320
|
+
global_landscape, global_paper, current_header, current_footer, current_paginate,
|
|
2321
|
+
global_header, global_footer, global_paginate, current_background, global_background,
|
|
2322
|
+
current_logo, global_logo):
|
|
2323
|
+
"""aggregate: チャプター(YAML/JSONファイル群のテーブル集約)をTypstへ変換する。
|
|
2324
|
+
aggregateはYAML/JSONのテストケース集約であり、front-matter(Markdown固有の概念)は関係しない。
|
|
2325
|
+
戻り値は (typst断片, 更新後のcurrent_landscape, 更新後のcurrent_paper, 更新後のcurrent_header,
|
|
2326
|
+
更新後のcurrent_footer, 更新後のcurrent_paginate, 更新後のcurrent_background, 更新後のcurrent_logo)。"""
|
|
2327
|
+
typst_code = ""
|
|
2328
|
+
ch_landscape = str(ch_dict.get("landscape", global_landscape)).lower() == 'true'
|
|
2329
|
+
ch_paper = ch_dict.get("paper_size", global_paper)
|
|
2330
|
+
ch_header = ch_dict.get("header", global_header)
|
|
2331
|
+
ch_footer = ch_dict.get("footer", global_footer)
|
|
2332
|
+
ch_paginate = str(ch_dict.get("paginate", global_paginate)).lower() == 'true'
|
|
2333
|
+
ch_background = (_resolve_project_image_path(ch_dict["background"], renderer.base_dir, renderer.typst_root, "Background")
|
|
2334
|
+
if "background" in ch_dict else global_background)
|
|
2335
|
+
ch_logo = (_resolve_project_image_path(ch_dict["logo"], renderer.base_dir, renderer.typst_root, "Logo")
|
|
2336
|
+
if "logo" in ch_dict else global_logo)
|
|
2337
|
+
if (ch_landscape, ch_paper, ch_header, ch_footer, ch_paginate, ch_background, ch_logo) != (
|
|
2338
|
+
current_landscape, current_paper, current_header, current_footer, current_paginate, current_background, current_logo):
|
|
2339
|
+
typst_code += _page_set_fragment(ch_paper, ch_landscape, ch_header, ch_footer, ch_paginate, ch_background, ch_logo)
|
|
2340
|
+
current_landscape, current_paper = ch_landscape, ch_paper
|
|
2341
|
+
current_header, current_footer, current_paginate = ch_header, ch_footer, ch_paginate
|
|
2342
|
+
current_background, current_logo = ch_background, ch_logo
|
|
2343
|
+
|
|
2344
|
+
agg_path = os.path.join(inputs_dir, ch_file)
|
|
2345
|
+
typst_code += f'= {renderer.escape_typst(ch_dict.get("title", "Test Cases"))}\n\n'
|
|
2346
|
+
|
|
2347
|
+
if os.path.exists(agg_path) and os.path.isdir(agg_path):
|
|
2348
|
+
# 【修正】YAMLだけでなくJSONファイルも読み込み対象に含める
|
|
2349
|
+
tc_files = sorted([f for f in os.listdir(agg_path) if f.endswith(('.yaml', '.yml', '.json'))])
|
|
2350
|
+
|
|
2351
|
+
typst_code += '#table(\n columns: (auto, auto, auto, 1fr, 1fr),\n'
|
|
2352
|
+
typst_code += ' align: (center, left, center, left, left),\n'
|
|
2353
|
+
typst_code += ' stroke: 0.5pt + luma(150),\n'
|
|
2354
|
+
typst_code += ' fill: (col, row) => if row == 0 { luma(240) } else { none },\n'
|
|
2355
|
+
typst_code += ' [*ID*], [*Title*], [*Priority*], [*Steps*], [*Expected*],\n'
|
|
2356
|
+
|
|
2357
|
+
for tc_file in tc_files:
|
|
2358
|
+
tc_path = os.path.join(agg_path, tc_file)
|
|
2359
|
+
with open(tc_path, "r", encoding="utf-8") as f:
|
|
2360
|
+
try:
|
|
2361
|
+
if tc_file.endswith('.json'):
|
|
2362
|
+
tc_data = json.load(f) or {}
|
|
2363
|
+
else:
|
|
2364
|
+
tc_data = yaml.safe_load(f) or {}
|
|
2365
|
+
except Exception as e:
|
|
2366
|
+
print(f"[Warning] Failed to parse {tc_file}: {e}")
|
|
2367
|
+
continue
|
|
2368
|
+
|
|
2369
|
+
tc_id = renderer.escape_typst(str(tc_data.get("id", "")))
|
|
2370
|
+
tc_title = renderer.escape_typst(str(tc_data.get("title", "")))
|
|
2371
|
+
tc_priority = renderer.escape_typst(str(tc_data.get("priority", "")))
|
|
2372
|
+
|
|
2373
|
+
# 【修正】YAMLでリスト形式で書かれていた場合も結合して安全に処理する
|
|
2374
|
+
steps_md = extract_md_string(tc_data, "steps")
|
|
2375
|
+
expected_md = extract_md_string(tc_data, "expected")
|
|
2376
|
+
steps_typst = renderer.render(steps_md, filepath=tc_path).strip()
|
|
2377
|
+
expected_typst = renderer.render(expected_md, filepath=tc_path).strip()
|
|
2378
|
+
|
|
2379
|
+
typst_code += f' [{tc_id}], [{tc_title}], [{tc_priority}], [{steps_typst}], [{expected_typst}],\n'
|
|
2380
|
+
|
|
2381
|
+
typst_code += ')\n\n#pagebreak(weak: true)\n'
|
|
2382
|
+
else:
|
|
2383
|
+
# 仕様9章: 入力欠損は黙って飛ばさず即エラー (Fail-fast)
|
|
2384
|
+
print(f"[Error] Aggregate directory not found: {agg_path}")
|
|
2385
|
+
sys.exit(1)
|
|
2386
|
+
|
|
2387
|
+
return typst_code, current_landscape, current_paper, current_header, current_footer, current_paginate, current_background, current_logo
|
|
2388
|
+
|
|
2389
|
+
def _render_markdown_chapter(ch_dict, ch_file, inputs_dir, renderer, current_landscape, current_paper,
|
|
2390
|
+
global_landscape, global_paper, is_first_chapter, cover_mode, global_table_header,
|
|
2391
|
+
current_header, current_footer, current_paginate,
|
|
2392
|
+
global_header, global_footer, global_paginate, current_background, global_background,
|
|
2393
|
+
current_logo, global_logo):
|
|
2394
|
+
"""通常のチャプター(Markdown/YAML/JSON/プレーンテキスト等、#15の拡張子ディスパッチ対象)を
|
|
2395
|
+
Typstへ変換する。戻り値は (typst断片, 更新後のcurrent_landscape, 更新後のcurrent_paper,
|
|
2396
|
+
更新後のcurrent_header, 更新後のcurrent_footer, 更新後のcurrent_paginate, 更新後のcurrent_background,
|
|
2397
|
+
更新後のcurrent_logo)。"""
|
|
2398
|
+
md_path = os.path.join(inputs_dir, ch_file)
|
|
2399
|
+
if not os.path.exists(md_path):
|
|
2400
|
+
print(f"[Error] Chapter file not found: {md_path}")
|
|
2401
|
+
sys.exit(1)
|
|
2402
|
+
|
|
2403
|
+
# テーブルヘッダのスタイル(#45)。chapters[].table_headerはdocument.table_headerに対する
|
|
2404
|
+
# キー単位の上書き(chapters[].landscape/paper_sizeと同じ優先順位)。前後関係上、
|
|
2405
|
+
# front-matterはrender_chapter()実行後にしかわからないため、front-matterでの上書きは
|
|
2406
|
+
# サポートしない(#41以降、front-matterはlandscape/paper_size/font_sizeのみ反映する方針)。
|
|
2407
|
+
ch_table_header = dict(global_table_header)
|
|
2408
|
+
ch_table_header.update(ch_dict.get("table_header") or {})
|
|
2409
|
+
renderer.table_header_style = ch_table_header
|
|
2410
|
+
|
|
2411
|
+
with open(md_path, "r", encoding="utf-8") as f:
|
|
2412
|
+
md_text = f.read()
|
|
2413
|
+
chapter_typst = renderer.render_chapter(
|
|
2414
|
+
md_text, filepath=md_path,
|
|
2415
|
+
drop_leading_title=is_first_chapter and cover_mode in ('replace', 'none'))
|
|
2416
|
+
front_matter = renderer.front_matter
|
|
2417
|
+
|
|
2418
|
+
# front-matterのpaper_size/landscapeは、config.yamlのチャプター個別設定より弱い
|
|
2419
|
+
# 優先順位で適用する(7章、#17)。config.yaml側に明示指定が無い場合のみ使う。
|
|
2420
|
+
# front-matterはファイルを読んで初めてわかるため、#set pageの要否判定もここで行う
|
|
2421
|
+
# (aggregateには front-matter の概念が無く、判定をchapters読み込み前に済ませられる)。
|
|
2422
|
+
ch_landscape = str(ch_dict.get("landscape", front_matter.get("landscape", global_landscape))).lower() == 'true'
|
|
2423
|
+
ch_paper = ch_dict.get("paper_size", front_matter.get("paper_size", global_paper))
|
|
2424
|
+
# header/footer/paginateも同じ優先順位(chapters[]の明示指定>front-matter>グローバル)で
|
|
2425
|
+
# 解決する(#42)。state()は使わず、landscape/paper_sizeと同じ「変化した時だけ#set pageを
|
|
2426
|
+
# 出し直す」パターンで、章の並べ替えに対して安全にする。
|
|
2427
|
+
ch_header = ch_dict.get("header", front_matter.get("header", global_header))
|
|
2428
|
+
ch_footer = ch_dict.get("footer", front_matter.get("footer", global_footer))
|
|
2429
|
+
ch_paginate = str(ch_dict.get("paginate", front_matter.get("paginate", global_paginate))).lower() == 'true'
|
|
2430
|
+
# 背景画像(#55)・ロゴ(#54)。パス値のためfront-matter経由の上書きはサポートしない
|
|
2431
|
+
# (table_headerと同じ判断)。
|
|
2432
|
+
ch_background = (_resolve_project_image_path(ch_dict["background"], renderer.base_dir, renderer.typst_root, "Background")
|
|
2433
|
+
if "background" in ch_dict else global_background)
|
|
2434
|
+
ch_logo = (_resolve_project_image_path(ch_dict["logo"], renderer.base_dir, renderer.typst_root, "Logo")
|
|
2435
|
+
if "logo" in ch_dict else global_logo)
|
|
2436
|
+
typst_code = ""
|
|
2437
|
+
if (ch_landscape, ch_paper, ch_header, ch_footer, ch_paginate, ch_background, ch_logo) != (
|
|
2438
|
+
current_landscape, current_paper, current_header, current_footer, current_paginate, current_background, current_logo):
|
|
2439
|
+
typst_code += _page_set_fragment(ch_paper, ch_landscape, ch_header, ch_footer, ch_paginate, ch_background, ch_logo)
|
|
2440
|
+
current_landscape, current_paper = ch_landscape, ch_paper
|
|
2441
|
+
current_header, current_footer, current_paginate = ch_header, ch_footer, ch_paginate
|
|
2442
|
+
current_background, current_logo = ch_background, ch_logo
|
|
2443
|
+
|
|
2444
|
+
font_size = front_matter.get('font_size')
|
|
2445
|
+
if font_size:
|
|
2446
|
+
# スコープを#[...]で閉じ、このチャプターだけにフォントサイズ指定を適用する
|
|
2447
|
+
typst_code += f"#[\n#set text(size: {font_size})\n{chapter_typst}\n]\n"
|
|
2448
|
+
else:
|
|
2449
|
+
typst_code += chapter_typst
|
|
2450
|
+
# front-matterのtitle/subtitle/author/dateは認識はするが、何も反映しない(#41)。
|
|
2451
|
+
# 文書全体の表紙(title/subtitle/author/date)は常にconfig.yaml側のみが正。
|
|
2452
|
+
typst_code += "\n#pagebreak(weak: true)\n"
|
|
2453
|
+
|
|
2454
|
+
return typst_code, current_landscape, current_paper, current_header, current_footer, current_paginate, current_background, current_logo
|
|
2455
|
+
|
|
2456
|
+
def _resolve_line_mapping(config):
|
|
2457
|
+
"""document.diagnostics.line_mapping: "block"(既定。#27)。Typstコンパイルエラーの行番号を
|
|
2458
|
+
元のMarkdownの行番号へ逆引きする精度を読み取る。"fine"(リスト項目・テーブル行単位)は
|
|
2459
|
+
未実装の将来課題のため、指定されても現時点では"block"にフォールバックする。
|
|
2460
|
+
document.diagnosticsキー自体は存在するが値が空(YAMLで`diagnostics:`とだけ書いてNoneに
|
|
2461
|
+
なる場合)でも例外を出さないよう、`or {}`でNoneをdictに読み替える。"""
|
|
2462
|
+
line_mapping = (config.get("document", {}).get("diagnostics") or {}).get("line_mapping", "block")
|
|
2463
|
+
if line_mapping not in ("off", "block"):
|
|
2464
|
+
print(f"[Warning] document.diagnostics.line_mapping: {line_mapping!r} is not supported yet; falling back to 'block'.")
|
|
2465
|
+
line_mapping = "block"
|
|
2466
|
+
return line_mapping
|
|
2467
|
+
|
|
2468
|
+
# TypstRenderer._emit_srcmapが生成コードへ挿し込む目印行(`// @srcmap {mdファイル}:{md行番号}`)
|
|
2469
|
+
# を検出する正規表現(#27)。ファイルパス自体にコロンを含みうる(Windowsの絶対パス`C:\...`)ため、
|
|
2470
|
+
# 末尾の数字グループのみを行番号として貪欲マッチさせ、残り全体をファイルパスとして扱う。
|
|
2471
|
+
SRCMAP_LINE_RE = re.compile(re.escape(TypstRenderer.SRCMAP_PREFIX) + r'(.+):(\d+)$', re.MULTILINE)
|
|
2472
|
+
# typst_lib.TypstErrorのメッセージ(codespan_reportingが整形する`┌─ temp_build.typ:12:5`形式)
|
|
2473
|
+
# から、コンパイル対象ファイル内の行:列を検出する正規表現。
|
|
2474
|
+
TYPST_ERROR_LOC_RE = re.compile(r'temp_build\.typ:(\d+):\d+')
|
|
2475
|
+
|
|
2476
|
+
def _build_srcmap(typst_code):
|
|
2477
|
+
"""typst_code全体から`// @srcmap`の目印行を集め、[(typstコード上の行番号, mdファイル, md行番号), ...]
|
|
2478
|
+
をtypst行番号の昇順で返す(#27)。line_mapping: "off"(既定はblock)で目印が無い場合は空リスト。"""
|
|
2479
|
+
return [
|
|
2480
|
+
(typst_code.count('\n', 0, m.start()) + 1, m.group(1), int(m.group(2)))
|
|
2481
|
+
for m in SRCMAP_LINE_RE.finditer(typst_code)
|
|
2482
|
+
]
|
|
2483
|
+
|
|
2484
|
+
def _resolve_srcmap(src_map, typst_line):
|
|
2485
|
+
"""typst_line以前にある直近の目印から、対応する元のMarkdownの(ファイル, 行番号)を引く(#27)。
|
|
2486
|
+
目印より前(テンプレートのpreambleなど)の行はNoneを返す。"""
|
|
2487
|
+
linenos = [s[0] for s in src_map]
|
|
2488
|
+
idx = bisect.bisect_right(linenos, typst_line) - 1
|
|
2489
|
+
return (src_map[idx][1], src_map[idx][2]) if idx >= 0 else None
|
|
2490
|
+
|
|
2491
|
+
def _annotate_typst_error(error_text, src_map):
|
|
2492
|
+
"""Typstのコンパイルエラーメッセージ中の`temp_build.typ:行:列`を#27のsrc_mapで元のMarkdownの
|
|
2493
|
+
(ファイル, 行番号)へ逆引きし、ヒントとして追記する。src_mapが空(line_mapping: off、または
|
|
2494
|
+
該当行が目印より前)の場合は元のメッセージのまま返す。"""
|
|
2495
|
+
hints = []
|
|
2496
|
+
seen = set()
|
|
2497
|
+
for m in TYPST_ERROR_LOC_RE.finditer(error_text):
|
|
2498
|
+
typst_line = int(m.group(1))
|
|
2499
|
+
if typst_line in seen:
|
|
2500
|
+
continue
|
|
2501
|
+
seen.add(typst_line)
|
|
2502
|
+
resolved = _resolve_srcmap(src_map, typst_line)
|
|
2503
|
+
if resolved:
|
|
2504
|
+
md_file, md_line = resolved
|
|
2505
|
+
hints.append(f"[Hint] temp_build.typ:{typst_line} corresponds to around {md_file}:{md_line}")
|
|
2506
|
+
return error_text + "\n" + "\n".join(hints) if hints else error_text
|
|
2507
|
+
|
|
2508
|
+
def _compile_and_cleanup(typst_code, work_dir, outputs_dir, config, typst_root, font_dir, template_copy_path, repo_root,
|
|
2509
|
+
keep_temp=False):
|
|
2510
|
+
"""temp_build.typへ書き出してtypstコンパイルし、成功時は使い捨ての中間ファイルを削除する。
|
|
2511
|
+
keep_temp=True(--keep-temp、#52)なら成功時も削除せず残す(失敗時は元々常に残る)。"""
|
|
2512
|
+
temp_typ_path = os.path.join(work_dir, "temp_build.typ")
|
|
2513
|
+
with open(temp_typ_path, "w", encoding="utf-8") as f:
|
|
2514
|
+
f.write(typst_code)
|
|
2515
|
+
|
|
2516
|
+
# コンパイル失敗時にTypst側の行番号を元のMarkdownへ逆引きするための対応表(#27)。
|
|
2517
|
+
src_map = _build_srcmap(typst_code)
|
|
2518
|
+
|
|
2519
|
+
out_pdf = os.path.join(outputs_dir, config["output"]["filename"])
|
|
2520
|
+
|
|
2521
|
+
try:
|
|
2522
|
+
# ignore_system_fonts=True(#71)。テンプレート(template.typ/slide.typ)は本文フォントを
|
|
2523
|
+
# 一貫して"Noto Sans JP"(font_dirに同梱・キャッシュ済み)のみ指定しているため、システム
|
|
2524
|
+
# フォントを混ぜる必要が無い。付けないと、テンプレート指定フォントがカバーしない文字
|
|
2525
|
+
# (絵文字等)のフォールバック先がOSごとに異なる system フォント構成に左右され、同一入力
|
|
2526
|
+
# からでも環境ごとに出力(フォールバックフォントの選択)が変わり得る(9章の決定論的出力の
|
|
2527
|
+
# 前提が崩れる)。デフォルトで常に有効にし、config.yaml側に設定項目は設けない(このツールの
|
|
2528
|
+
# 「明示性優先」方針に合わせ、フォントを変えたい場合は独自テンプレート(template.path)で
|
|
2529
|
+
# 対応する)。
|
|
2530
|
+
typst_lib.compile(temp_typ_path, output=out_pdf, root=typst_root, font_paths=[font_dir],
|
|
2531
|
+
ignore_system_fonts=True)
|
|
2532
|
+
print(f"[Success] Generated PDF: {out_pdf}")
|
|
2533
|
+
except typst_lib.TypstError as e:
|
|
2534
|
+
# str(e)はe.message(例: "unknown variable: foo")のみで位置情報を持たない。
|
|
2535
|
+
# ファイル:行:列を含む整形済み診断(`┌─ temp_build.typ:32:1`形式)はe.diagnosticに
|
|
2536
|
+
# 別途入っている(実機確認で判明。#27の行番号マッピングはこちらが無いと機能しない)。
|
|
2537
|
+
diagnostic_text = getattr(e, "diagnostic", None) or str(e)
|
|
2538
|
+
print(f"[Error] Compile failed:\n{_annotate_typst_error(diagnostic_text, src_map)}")
|
|
2539
|
+
sys.exit(1)
|
|
2540
|
+
except Exception as e:
|
|
2541
|
+
print(f"[Error] Execution failed: {e}")
|
|
2542
|
+
# 原因が記述ミスではなく環境不備(typstのバージョン不一致等)の可能性があるため、
|
|
2543
|
+
# 関連するチェックだけを再実行して診断ヒントを出す(#37。全項目は--check-env参照)。
|
|
2544
|
+
diag = _check_typst_env(repo_root)
|
|
2545
|
+
if diag.status != "OK":
|
|
2546
|
+
print(f"[Hint] [{diag.status}] {diag.name}: {diag.message}")
|
|
2547
|
+
sys.exit(1)
|
|
2548
|
+
|
|
2549
|
+
# ビルド成功後、使い捨ての中間ファイルを削除する(12章、#20)。
|
|
2550
|
+
# mermaidキャッシュ(cache/)は次回以降のビルドで再利用するため対象外。
|
|
2551
|
+
# 失敗時は温存し、生成されたTypstコードをそのままデバッグに使えるようにする。
|
|
2552
|
+
if keep_temp:
|
|
2553
|
+
_log_info(f"--keep-temp: keeping intermediate files ({temp_typ_path}, {template_copy_path})")
|
|
2554
|
+
else:
|
|
2555
|
+
os.remove(temp_typ_path)
|
|
2556
|
+
os.remove(template_copy_path)
|
|
2557
|
+
|
|
2558
|
+
def build():
|
|
2559
|
+
# tool_dir: ツール自身に同梱されたリソース(templates/)の場所。パッケージ化後は
|
|
2560
|
+
# text_compositor/ パッケージのディレクトリを指す(#111)。
|
|
2561
|
+
tool_dir = os.path.dirname(os.path.abspath(__file__))
|
|
2562
|
+
# repo_root: 「クローンして直接叩く」場合のリポジトリルート。requirements.txt探索にのみ使う。
|
|
2563
|
+
# pipインストール後はrequirements.txtが同梱されないため、自然に「見つからない」扱いになる。
|
|
2564
|
+
repo_root = os.path.dirname(tool_dir)
|
|
2565
|
+
args = parse_args()
|
|
2566
|
+
|
|
2567
|
+
# ログの詳細度(#52)。CLI起動時に一度だけプロセスグローバルへ反映する。
|
|
2568
|
+
global _QUIET, _VERBOSE
|
|
2569
|
+
_QUIET = args.quiet
|
|
2570
|
+
_VERBOSE = args.verbose
|
|
2571
|
+
|
|
2572
|
+
if args.check_env:
|
|
2573
|
+
sys.exit(run_env_check(repo_root, args.config))
|
|
2574
|
+
|
|
2575
|
+
check_typst_version(repo_root)
|
|
2576
|
+
font_dir = ensure_fonts()
|
|
2577
|
+
|
|
2578
|
+
if args.config_list:
|
|
2579
|
+
config_paths = _read_config_list(args.config_list)
|
|
2580
|
+
if not config_paths:
|
|
2581
|
+
print(f"[Error] --config-list {args.config_list} に有効なconfigパスがありません。")
|
|
2582
|
+
sys.exit(1)
|
|
2583
|
+
# いずれかのビルドが失敗した時点でsys.exit(1)により停止する(_load_project_config等が担う)。
|
|
2584
|
+
for config_path in config_paths:
|
|
2585
|
+
print(f"[Build] {config_path}")
|
|
2586
|
+
_build_one(tool_dir, repo_root, font_dir, config_path, keep_temp=args.keep_temp)
|
|
2587
|
+
else:
|
|
2588
|
+
_build_one(tool_dir, repo_root, font_dir, args.config, keep_temp=args.keep_temp)
|
|
2589
|
+
|
|
2590
|
+
def _build_one(tool_dir, repo_root, font_dir, config_path, keep_temp=False):
|
|
2591
|
+
# 汎用ツールとして、呼び出し元プロジェクトが持つ設定ファイルを指定できるようにする。
|
|
2592
|
+
# inputs.dir/output.dir などプロジェクト固有の相対パスは、このconfigファイルの
|
|
2593
|
+
# 置き場所(project_dir)を基準に解決する。templates/等ツール自身のリソースはtool_dir基準のまま。
|
|
2594
|
+
project_dir, config, chapters = _load_project_config(config_path)
|
|
2595
|
+
|
|
2596
|
+
# plugins: Graphviz/PlantUML/Mermaid/D2の有効・無効切り替え(6章、#21、#90)。未指定時は
|
|
2597
|
+
# 既存動作を維持する既定値(graphviz/mermaid/plantuml/d2はいずれも常時有効)。
|
|
2598
|
+
# *_auto_download は、システムに必要なツール(ブラウザ/Java/D2)が無い場合の振る舞いを制御する
|
|
2599
|
+
# 別軸のフラグ(#22の設計議論)。既定値が非対称なのは、ダウンロードされる実体のサイズが
|
|
2600
|
+
# 一桁違うため(Playwright自身のChromium: 約700MB対Eclipse Temurin JRE: 約49.7MB対D2 CLI: 約13MB)。
|
|
2601
|
+
plugins_config = config.get("plugins") or {}
|
|
2602
|
+
graphviz_enabled = bool(plugins_config.get("graphviz", True))
|
|
2603
|
+
mermaid_enabled = bool(plugins_config.get("mermaid", True))
|
|
2604
|
+
mermaid_auto_download = bool(plugins_config.get("mermaid_auto_download", False))
|
|
2605
|
+
plantuml_enabled = bool(plugins_config.get("plantuml", True))
|
|
2606
|
+
plantuml_auto_download = bool(plugins_config.get("plantuml_auto_download", True))
|
|
2607
|
+
d2_enabled = bool(plugins_config.get("d2", True))
|
|
2608
|
+
d2_auto_download = bool(plugins_config.get("d2_auto_download", True))
|
|
2609
|
+
# document.glossary: false(既定。#47)。trueなら[[用語]]を検出し、巻末に索引ページを生成する。
|
|
2610
|
+
glossary_enabled = bool(config.get("document", {}).get("glossary", False))
|
|
2611
|
+
# document.marp_compat: false(既定、#92)。trueなら実際のMarpitに合わせ、hr(---/***/___)を
|
|
2612
|
+
# 一律改ページとして描画する。
|
|
2613
|
+
marp_compat = bool(config.get("document", {}).get("marp_compat", False))
|
|
2614
|
+
line_mapping = _resolve_line_mapping(config)
|
|
2615
|
+
|
|
2616
|
+
outputs_dir, inputs_dir, work_dir, typst_root = _resolve_project_dirs(project_dir, config)
|
|
2617
|
+
template_copy_path, template_root_rel_path = _prepare_template(config, tool_dir, project_dir, work_dir, typst_root)
|
|
2618
|
+
|
|
2619
|
+
(typst_code, global_landscape, global_paper, cover_mode, global_table_header,
|
|
2620
|
+
global_header, global_footer, global_paginate, global_background, global_logo) = _build_document_preamble(
|
|
2621
|
+
config, template_root_rel_path, graphviz_enabled, project_dir, typst_root)
|
|
2622
|
+
|
|
2623
|
+
# headerの実効グローバル既定値。document.headerが未指定ならテンプレート側と同じくtitleへ
|
|
2624
|
+
# フォールバックする(#42)。章ごとの解決(chapters[]/front-matter)は、この実効値を起点にする。
|
|
2625
|
+
doc_title = config.get("document", {}).get("title", "Untitled")
|
|
2626
|
+
effective_global_header = global_header if global_header is not None else doc_title
|
|
2627
|
+
|
|
2628
|
+
renderer = TypstRenderer(project_dir, typst_root=typst_root,
|
|
2629
|
+
mermaid_enabled=mermaid_enabled, mermaid_auto_download=mermaid_auto_download,
|
|
2630
|
+
plantuml_enabled=plantuml_enabled, plantuml_auto_download=plantuml_auto_download,
|
|
2631
|
+
d2_enabled=d2_enabled, d2_auto_download=d2_auto_download,
|
|
2632
|
+
glossary_enabled=glossary_enabled, line_mapping=line_mapping,
|
|
2633
|
+
marp_compat=marp_compat)
|
|
2634
|
+
current_landscape, current_paper = global_landscape, global_paper
|
|
2635
|
+
current_header, current_footer, current_paginate = effective_global_header, global_footer, global_paginate
|
|
2636
|
+
current_background = global_background
|
|
2637
|
+
current_logo = global_logo
|
|
2638
|
+
is_first_chapter = True
|
|
2639
|
+
|
|
2640
|
+
try:
|
|
2641
|
+
for ch in chapters:
|
|
2642
|
+
ch_file, ch_dict, ch_type = _parse_chapter_entry(ch)
|
|
2643
|
+
_log_verbose(f"Processing chapter ({ch_type}): {ch_file}")
|
|
2644
|
+
if ch_type == "aggregate":
|
|
2645
|
+
(fragment, current_landscape, current_paper,
|
|
2646
|
+
current_header, current_footer, current_paginate,
|
|
2647
|
+
current_background, current_logo) = _render_aggregate_chapter(
|
|
2648
|
+
ch_dict, ch_file, inputs_dir, renderer, current_landscape, current_paper,
|
|
2649
|
+
global_landscape, global_paper, current_header, current_footer, current_paginate,
|
|
2650
|
+
effective_global_header, global_footer, global_paginate, current_background, global_background,
|
|
2651
|
+
current_logo, global_logo)
|
|
2652
|
+
else:
|
|
2653
|
+
(fragment, current_landscape, current_paper,
|
|
2654
|
+
current_header, current_footer, current_paginate,
|
|
2655
|
+
current_background, current_logo) = _render_markdown_chapter(
|
|
2656
|
+
ch_dict, ch_file, inputs_dir, renderer, current_landscape, current_paper,
|
|
2657
|
+
global_landscape, global_paper, is_first_chapter, cover_mode, global_table_header,
|
|
2658
|
+
current_header, current_footer, current_paginate,
|
|
2659
|
+
effective_global_header, global_footer, global_paginate, current_background, global_background,
|
|
2660
|
+
current_logo, global_logo)
|
|
2661
|
+
typst_code += fragment
|
|
2662
|
+
is_first_chapter = False
|
|
2663
|
+
finally:
|
|
2664
|
+
# mermaidレンダリング用に起動したヘッドレスブラウザを、エラー終了時も含め必ず片付ける(#35)。
|
|
2665
|
+
renderer.close()
|
|
2666
|
+
|
|
2667
|
+
if (current_landscape, current_paper, current_header, current_footer, current_paginate, current_background, current_logo) != (
|
|
2668
|
+
global_landscape, global_paper, effective_global_header, global_footer, global_paginate, global_background, global_logo):
|
|
2669
|
+
typst_code += _page_set_fragment(
|
|
2670
|
+
global_paper, global_landscape, effective_global_header, global_footer, global_paginate, global_background, global_logo)
|
|
2671
|
+
|
|
2672
|
+
# 巻末の用語索引(#47)。全チャプター処理後、実際に[[用語]]が使われていた場合のみ追加する。
|
|
2673
|
+
if glossary_enabled and renderer.glossary_terms:
|
|
2674
|
+
typst_code += _build_glossary_section(renderer.glossary_terms)
|
|
2675
|
+
|
|
2676
|
+
_compile_and_cleanup(typst_code, work_dir, outputs_dir, config, typst_root, font_dir, template_copy_path, repo_root,
|
|
2677
|
+
keep_temp=keep_temp)
|
|
2678
|
+
|
|
2679
|
+
if __name__ == "__main__":
|
|
2680
|
+
build()
|