Mochi0622 commited on
Commit
e66cfb4
·
0 Parent(s):

initial deploy

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 敏感資料(絕對不能上傳)
2
+ .env
3
+ firebase_key.json
4
+
5
+ # Python 快取
6
+ __pycache__/
7
+ *.pyc
8
+ *.pyo
9
+
10
+ # 開發工具
11
+ .vscode/
12
+ .idea/
13
+ *.ipynb
14
+
15
+ # 其他
16
+ other/
17
+ *.docx
18
+ *.pdf
.gitattributes ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
37
+ *.jpg filter=lfs diff=lfs merge=lfs -text
38
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
39
+ *.svg filter=lfs diff=lfs merge=lfs -text
40
+ *.gif filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 敏感資料(絕對不能上傳)
2
+ .env
3
+ firebase_key.json
4
+
5
+ # Python 快取
6
+ __pycache__/
7
+ *.pyc
8
+ *.pyo
9
+
10
+ # 開發工具
11
+ .vscode/
12
+ .idea/
13
+ *.ipynb
14
+
15
+ # 其他
16
+ other/
17
+ *.docx
18
+ *.pdf
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # 設定工作目錄
4
+ WORKDIR /app
5
+
6
+ # 先安裝依賴(利用 Docker layer cache,只有 requirements.txt 改變才重建)
7
+ COPY requirements.txt .
8
+ RUN pip install --no-cache-dir -r requirements.txt
9
+
10
+ # 複製專案檔案(.dockerignore 會排除 .env、firebase_key.json)
11
+ COPY . .
12
+
13
+ # HF Spaces 要求 port 7860
14
+ EXPOSE 7860
15
+
16
+ # 用 gunicorn 啟動,timeout 調高因為 build pipeline 需要時間
17
+ CMD ["gunicorn", "app:server", \
18
+ "--bind", "0.0.0.0:7860", \
19
+ "--timeout", "120", \
20
+ "--workers", "1"]
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Dash-math Database
3
+ emoji: ⚡
4
+ colorFrom: pink
5
+ colorTo: pink
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+ ------
4
+ Dash app 進入點。
5
+
6
+ 職責(僅此而已):
7
+ 1. 建立 Dash app 實例
8
+ 2. 組裝 AppShell layout
9
+ 3. 掛載全域 callback(navbar、主題、MathJax)
10
+ 4. 啟動 dev server
11
+
12
+ Build pipeline(MD → JSON)已移至 build.py。
13
+ - 若希望每次啟動都重建:取消下方 build_if_needed 的 force=True 呼叫
14
+ - 若只想手動重建:直接執行 python build.py --force
15
+ """
16
+
17
+ import dash
18
+ from dash import Dash, html, dcc
19
+ from dash import Input, Output, clientside_callback
20
+ import dash_mantine_components as dmc
21
+ from pathlib import Path
22
+
23
+ from layout.shell.navbar import generate_navbar
24
+ from layout.shell.header import generate_header
25
+ from layout.shell.main import generate_main
26
+ from layout.shell.footer import generate_footer
27
+ from layout.shell.callbacks import generate_toggle_navbar, switch_light_dark
28
+
29
+ # 確保頁面 section 切換 callback 在啟動時就被註冊
30
+ import layout.page.page_callbacks # noqa: F401
31
+
32
+ # 學生登入元件
33
+ from student_login import create_student_store, create_login_modal, create_student_badge
34
+
35
+ # Firebase quiz hooks(答題記錄)
36
+ from firebase_quiz_hooks import create_dummy_stores
37
+ import firebase_quiz_hooks # noqa: F401 ← 掛載 callback
38
+
39
+ # Session 狀態保存(重整後恢復 section、答題、解法)由 assets/session_state.js 處理
40
+ from session_state import create_session_stores
41
+
42
+ # Build pipeline:只在啟動時呼叫,不影響 app 本體邏輯
43
+ from build import build_if_needed
44
+
45
+ BASE_DIR = Path(__file__).resolve().parent
46
+ CONFIG_PATH = BASE_DIR / "paths.json"
47
+
48
+ build_if_needed(CONFIG_PATH, force=False) # 只重建比 .md 新的 JSON,加速啟動
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # App 實例
52
+ # ---------------------------------------------------------------------------
53
+ app = Dash(
54
+ __name__,
55
+ use_pages=True,
56
+ external_stylesheets=dmc.styles.ALL,
57
+ suppress_callback_exceptions=True,
58
+ )
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Callbacks
62
+ # ---------------------------------------------------------------------------
63
+ generate_toggle_navbar()
64
+ switch_light_dark()
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Layout 元件
68
+ # ---------------------------------------------------------------------------
69
+ header = generate_header()
70
+ navbar = generate_navbar()
71
+ main = generate_main()
72
+ footer = generate_footer()
73
+
74
+ layout = dmc.AppShell(
75
+ id="appshell",
76
+ children=[
77
+ dcc.Store(id="noop-store"),
78
+ html.Div(id="theme-dummy", style={"display": "none"}),
79
+
80
+ # ── 學生登入相關 ──
81
+ create_student_store(), # localStorage 學號 Store
82
+ create_login_modal(), # 學號輸入 Modal
83
+ *create_dummy_stores(), # Firebase 虛擬 Store
84
+ *create_session_stores(), # Session 狀態 Store
85
+
86
+ header, navbar, main, footer,
87
+ ],
88
+ header={"height": "4rem"},
89
+ navbar={
90
+ "width": {"xs": 200, "sm": 250, "md": 300, "lg": 300},
91
+ "breakpoint": "sm",
92
+ "collapsed": {"mobile": True, "desktop": False},
93
+ },
94
+ footer={"height": "2rem"},
95
+ padding="lg",
96
+ )
97
+
98
+ app.layout = dmc.MantineProvider(
99
+ id="mantine-provider",
100
+ children=layout,
101
+ defaultColorScheme="light",
102
+ )
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # MathJax:每次換頁都重新 typeset
106
+ # ---------------------------------------------------------------------------
107
+ clientside_callback(
108
+ """
109
+ function(pathname) {
110
+ if (window.MathJax) {
111
+ window.MathJax.typeset();
112
+ }
113
+ return "";
114
+ }
115
+ """,
116
+ Output("noop-store", "data"),
117
+ Input("_pages_location", "pathname"),
118
+ )
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # 啟動
122
+ # ---------------------------------------------------------------------------
123
+ server = app.server # 給 gunicorn 用:gunicorn app:server
124
+
125
+ if __name__ == "__main__":
126
+ app.run(debug=True)
127
+ # 部署時改用:
128
+ # app.run(host="0.0.0.0", port=8050, debug=False)
assets/Ch14_5_ex4.svg ADDED

Git LFS Details

  • SHA256: da4cfa0348fdb9581a431ba3c7d141c584c4a05ab582f35efd81dfc02076a5e7
  • Pointer size: 129 Bytes
  • Size of remote file: 2.2 kB
assets/Ch14_5_ex5.svg ADDED

Git LFS Details

  • SHA256: 52878918fbf3abed111104c3d1c82e176d4ec754f59a902ff2824624767c975d
  • Pointer size: 129 Bytes
  • Size of remote file: 2.31 kB
assets/Ch14_5_ex7.svg ADDED

Git LFS Details

  • SHA256: 1f4075790c66b59a3610eef4ae888b3d6fa1a4808c64dd42263096e5ef438fbf
  • Pointer size: 129 Bytes
  • Size of remote file: 3.3 kB
assets/Ch14_6_8.svg ADDED

Git LFS Details

  • SHA256: 28f311a77046331d56187918f6b2d18531b256c52cc9f4f5761791283a84e122
  • Pointer size: 130 Bytes
  • Size of remote file: 50.5 kB
assets/Ch14_6_ex1.svg ADDED

Git LFS Details

  • SHA256: 2ba7a2200d2c16b627ddc0ac9be5ef64fdad37c4b7fd78795c9b28f9d2ef62ad
  • Pointer size: 130 Bytes
  • Size of remote file: 81.6 kB
assets/Ch14_6_ex3.svg ADDED

Git LFS Details

  • SHA256: 9f6e97f78a74c9f5542386d4eed4f6fd7b199e1a16b14a6c6714aaca8dbc05b9
  • Pointer size: 131 Bytes
  • Size of remote file: 194 kB
assets/Ch14_6_ex5.svg ADDED

Git LFS Details

  • SHA256: ac275d5be15c21d246394c11658aa954d45ac952d3b50eab4a74e049a799628e
  • Pointer size: 131 Bytes
  • Size of remote file: 127 kB
assets/Ch14_6_ex6.svg ADDED

Git LFS Details

  • SHA256: 4ff2907b1af0fb48e1759ce1a85fa7cf79e1ff51943c6c91ef340e5901a6aa73
  • Pointer size: 131 Bytes
  • Size of remote file: 198 kB
assets/SaddlePoint.svg ADDED

Git LFS Details

  • SHA256: 316ac421a50872a4bf0cef671dd3190eab0ecb84cb3e8bf21b1d8dcd68cc6452
  • Pointer size: 131 Bytes
  • Size of remote file: 415 kB
assets/anchor-scroll.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ function cleanIdFromHash(h) {
3
+ if (!h) return "";
4
+ var s = h.replace(/^#/, "");
5
+ if (s.startsWith("h-")) s = s.slice(2);
6
+ return s;
7
+ }
8
+
9
+ function scrollToId(id) {
10
+ if (!id) return;
11
+ var el = document.getElementById(id);
12
+ if (!el) return;
13
+ // ?? CSS ? scroll-margin-top??????? header ??
14
+ el.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest" });
15
+ }
16
+
17
+ // 1) ??? #hash ?????????????
18
+ window.addEventListener("load", function () {
19
+ var id = cleanIdFromHash(location.hash);
20
+ if (id) setTimeout(function(){ scrollToId(id); }, 0);
21
+ });
22
+
23
+ // 2) hash ????? pushState ??????
24
+ window.addEventListener("hashchange", function () {
25
+ var id = cleanIdFromHash(location.hash);
26
+ if (id) scrollToId(id);
27
+ });
28
+
29
+ // 3) ?????? #fragment ???????????
30
+ document.addEventListener("click", function (e) {
31
+ var a = e.target.closest('a[href^="#"]');
32
+ if (!a) return;
33
+ var raw = a.getAttribute("href") || "";
34
+ var id = cleanIdFromHash(raw);
35
+ if (!id) return; // ?????????????????
36
+ var el = document.getElementById(id);
37
+ if (!el) return; // ???????
38
+ e.preventDefault();
39
+ scrollToId(id);
40
+ // ???? hash?????????
41
+ history.pushState(null, "", "#" + id);
42
+ });
43
+ })();
assets/chain_tree.svg ADDED

Git LFS Details

  • SHA256: dad871bada9dd6ba2cc896b08b217b9bdff84d34ca1be0a205ebd9e58ba5a62f
  • Pointer size: 129 Bytes
  • Size of remote file: 6.57 kB
assets/chain_tree_card1.svg ADDED

Git LFS Details

  • SHA256: d57bc7fbc702cb9945bdcbf2667d11d212b6f12bb96a3fe78a3bf9ca1d4336a2
  • Pointer size: 129 Bytes
  • Size of remote file: 4.38 kB
assets/ggb.css ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /* 把任何誤蓋在 ggb-wrap 上的透明層都失效 */
2
+ .ggb-wrap * {
3
+ pointer-events: auto;
4
+ }
5
+ .ggb-wrap::before {
6
+ content: "";
7
+ position: absolute;
8
+ inset: 0;
9
+ pointer-events: none; /* 確保沒有匿名覆蓋層攔截 */
10
+ }
assets/logo.svg ADDED

Git LFS Details

  • SHA256: 953e00deb0dc8fc3d613ef956b31ca1acb48e288ceb03246afe0510f9255ed87
  • Pointer size: 132 Bytes
  • Size of remote file: 2.94 MB
assets/mathjax-plotly.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // assets/mathjax-plotly.js
2
+ (function () {
3
+ function attachListeners() {
4
+ function typeset(target) {
5
+ try {
6
+ if (window.MathJax) {
7
+ // v3
8
+ if (typeof window.MathJax.typesetPromise === "function") {
9
+ return window.MathJax.typesetPromise([target || document.body]);
10
+ }
11
+ // v2
12
+ if (window.MathJax.Hub && typeof window.MathJax.Hub.Queue === "function") {
13
+ window.MathJax.Hub.Queue(["Typeset", window.MathJax.Hub, target || document.body]);
14
+ }
15
+ }
16
+ } catch (e) {
17
+ console.warn("MathJax typeset failed:", e);
18
+ }
19
+ }
20
+
21
+ document.addEventListener("plotly_afterplot", function (e) { typeset(e.target); });
22
+ document.addEventListener("plotly_relayout", function (e) { typeset(e.target); });
23
+ document.addEventListener("plotly_redraw", function (e) { typeset(e.target); });
24
+ window.addEventListener("load", function () { typeset(document.body); });
25
+ }
26
+
27
+ // 如果已經有 MathJax,就只掛事件
28
+ if (window.MathJax) { attachListeners(); return; }
29
+
30
+ // 推薦載 v3(tex-svg 渲染器);載完再掛事件
31
+ var s = document.createElement("script");
32
+ s.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js";
33
+ s.defer = true;
34
+ s.onload = attachListeners;
35
+ document.head.appendChild(s);
36
+ })();
assets/maximum.svg ADDED

Git LFS Details

  • SHA256: 7bd934a8f9bf01d16c5e8aef9c69c128851d32cf942606e319755bed7ae50d5b
  • Pointer size: 131 Bytes
  • Size of remote file: 446 kB
assets/partial_derivative.svg ADDED

Git LFS Details

  • SHA256: cda43979d58e8efbc240edf7cede3803de332467cec4edf8008fc031473d62b9
  • Pointer size: 131 Bytes
  • Size of remote file: 476 kB
assets/quiz.css ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ? Radio ?????????????? */
2
+ .quiz-option .mantine-Radio-body {
3
+ display: flex;
4
+ align-items: center; /* ?????? flex-start????????? */
5
+ }
6
+
7
+ /* ????????????????? flex ?? */
8
+ .quiz-option .mantine-Radio-label {
9
+ display: inline-flex;
10
+ align-items: center;
11
+ gap: 4px; /* ?? Python ? inline-flex ? gap ?? */
12
+ }
13
+
14
+
15
+
16
+
17
+ /* ???????????????????? marker */
18
+ .math-ol {
19
+ counter-reset: mathitem;
20
+ list-style: none; /* ???????? 1. 2. 3. */
21
+ margin-left: 1.5rem; /* ????????? */
22
+ padding-left: 0;
23
+ }
24
+
25
+ .math-ol > li {
26
+ counter-increment: mathitem;
27
+ display: flex;
28
+ align-items: center; /* ????????????????????? */
29
+ gap: 0.5rem; /* ?????????? */
30
+ }
31
+
32
+ /* ????? marker */
33
+ .math-ol > li::before {
34
+ content: counter(mathitem) ".";
35
+ flex: 0 0 auto;
36
+ min-width: 1.5rem; /* ???????????? */
37
+ text-align: right;
38
+ }
39
+
40
+ /* ?????? marker ??????????????????? */
41
+
42
+ /* ?? ListItem??????marker + ?? */
43
+ .mantine-List-item.themed-color {
44
+ display: flex;
45
+ align-items: center; /* marker ????????? */
46
+ }
47
+
48
+ /* ??? span ?????????? "1." "2." ?? marker */
49
+ .mantine-List-item.themed-color > span:first-child {
50
+ position: static !important; /* ? render_list ?? absolute ?? */
51
+ left: auto !important;
52
+ top: auto !important;
53
+ transform: none !important;
54
+
55
+ min-width: 1.8rem; /* ???????????? */
56
+ text-align: right;
57
+ margin-right: 0.5rem;
58
+ }
59
+
60
+ /* ??? div ????????????? */
61
+ .mantine-List-item.themed-color > div:last-child {
62
+ flex: 1 1 auto;
63
+ }
64
+
65
+
66
+ /* 讓數學式的顏色跟外層 .themed-color 一樣 */
67
+ .themed-color .katex,
68
+ .themed-color .katex * {
69
+ color: inherit;
70
+ }
71
+
72
+
73
+ /* 只調整 \text{} 的字型,跟主題 body font 一致 */
74
+ .katex .text,
75
+ .katex .text * {
76
+ font-family: var(--mantine-font-family, system-ui, -apple-system,
77
+ BlinkMacSystemFont, "Segoe UI", sans-serif);
78
+ font-weight: 400;
79
+ }
80
+
81
+
82
+ /* 讓數學式整體跟隨主題色 */
83
+ .themed-color .katex {
84
+ color: inherit !important;
85
+ }
86
+
87
+ /* 特別是 \text{...} 產生的文字 span */
88
+ .themed-color .katex .text,
89
+ .themed-color .katex .mord.text,
90
+ .themed-color .katex .mathnormal {
91
+ color: inherit !important;
92
+ }
93
+
94
+
95
+ /* 只調整 KaTeX 裡 \text{} 的字型與大小 */
96
+ .katex .text,
97
+ .katex .mord.text,
98
+ .katex .text * {
99
+ font-family: var(--mantine-font-family,
100
+ system-ui, -apple-system, BlinkMacSystemFont,
101
+ "Segoe UI", "Microsoft JhengHei", sans-serif) !important;
102
+ font-size: 0.9em !important; /* 比周圍數學略小一點,自己可改 0.8em / 1em */
103
+ font-weight: 400; /* 回到正常粗細 */
104
+ font-style: normal; /* 不要斜體就設 normal */
105
+ }
assets/scroll.css ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ html {
2
+ scroll-behavior: smooth; /* 平滑滾動 */
3
+ }
4
+
5
+ /* heading 的捲動預留空間(依你的 header 高度調整,如 80~100px) */
6
+ [id^="h-"] {
7
+ scroll-margin-top: 90px;
8
+ }
9
+ /* 固定 AppShell Header 在頂部,並有底色與層級 */
10
+ .mantine-AppShell-header{
11
+ position: sticky;
12
+ top: 0;
13
+ z-index: 2000;
14
+ background: var(--mantine-color-body);
15
+ border-bottom: 1px solid var(--mantine-color-default-border);
16
+ }
17
+
18
+ /* 主內容預留 Header 高度(64px) */
19
+ .mantine-AppShell-main{
20
+ padding-top: 64px;
21
+ }
22
+
23
+ /* 圖片安全預設 */
24
+ img{ max-width: 100%; height: auto; }
25
+
26
+ html[data-mantine-color-scheme="light"] .themed-color,
27
+ #mantine-provider[data-mantine-color-scheme="light"] .themed-color {
28
+ --tc: var(--tc-light);
29
+ }
30
+
31
+ html[data-mantine-color-scheme="dark"] .themed-color,
32
+ #mantine-provider[data-mantine-color-scheme="dark"] .themed-color {
33
+ --tc: var(--tc-dark);
34
+ }
35
+
36
+ /* 讓 --tc 指向 light 或 dark,以 data-mantine-color-scheme 為準 */
37
+ [data-mantine-color-scheme="light"] .themed-color { --tc: var(--tc-light); }
38
+ [data-mantine-color-scheme="dark"] .themed-color { --tc: var(--tc-dark); }
39
+
40
+ /* === Math blocks center (KaTeX + MathJax) === */
41
+ .katex-display {
42
+ text-align: center;
43
+ overflow-x: auto; /* 數學式太長時橫向捲動,不截斷 */
44
+ overflow-y: hidden;
45
+ padding-bottom: 4px; /* 捲動條不擋到數學式 */
46
+ }
47
+ mjx-container[display="true"] {
48
+ display: block;
49
+ text-align: center;
50
+ margin: 8px 0;
51
+ overflow-x: auto; /* MathJax 同樣加橫向捲動 */
52
+ }
53
+
54
+
55
+ .math-block { display:block; width:100%; text-align:center; }
56
+
57
+ .mantine-List-item .math-block {
58
+ width: calc(100% + var(--li-outdent, 0px));
59
+ margin-left: calc(-1 * var(--li-outdent, 0px));
60
+ }
61
+
62
+ /* 主題色切換:背景與前景 */
63
+ html[data-mantine-color-scheme="light"] .themed-bg { --bg: var(--bg-light); }
64
+ html[data-mantine-color-scheme="dark"] .themed-bg { --bg: var(--bg-dark); }
65
+ html[data-mantine-color-scheme="light"] .themed-fg { --fg: var(--fg-light); }
66
+ html[data-mantine-color-scheme="dark"] .themed-fg { --fg: var(--fg-dark); }
67
+
68
+ /* 讓有這兩個 class 的元素吃到背景/字色 */
69
+ .themed-bg { background: var(--bg, var(--bg-light)); }
70
+ .themed-fg { color: var(--fg, var(--fg-light)); }
71
+
72
+ /* === 讓顏色標記(<!--c_xxx-->)真的套到文字上 === */
73
+ .themed-color { color: var(--tc, inherit); }
74
+
75
+
76
+
77
+ /* === 卡片用的背景/前景變數(若尚未加入)=== */
78
+ html[data-mantine-color-scheme="light"] .themed-bg { --bg: var(--bg-light); }
79
+ html[data-mantine-color-scheme="dark"] .themed-bg { --bg: var(--bg-dark); }
80
+ .themed-bg { background: var(--bg, transparent); }
81
+
82
+ html[data-mantine-color-scheme="light"] .themed-fg { --fg: var(--fg-light); }
83
+ html[data-mantine-color-scheme="dark"] .themed-fg { --fg: var(--fg-dark); }
84
+ .themed-fg { color: var(--fg, inherit); }
85
+
86
+ /* 統一卡片外殼造型 */
87
+ .card-shell {
88
+ overflow: visible; /* 改為 visible,避免截斷長數學式 */
89
+ border-radius: var(--mantine-radius-md);
90
+ }
91
+
92
+ /* 亮色:維持無外框 */
93
+ html[data-mantine-color-scheme="light"] .card-shell {
94
+ border: 0;
95
+ box-shadow: none;
96
+ }
97
+
98
+ /* 暗色:加一圈白色外框(半透明) */
99
+ html[data-mantine-color-scheme="dark"] .card-shell {
100
+ border: 1px solid rgba(255, 255, 255, 0.28); /* 想更亮改 0.4、更細可用 0.22 */
101
+ box-shadow: none; /* 不要陰影 */
102
+ }
103
+
104
+
assets/session_state.js ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * session_state.js
3
+ * 事件驅動版,不做定時掃描,只在互動時儲存。
4
+ */
5
+ (function () {
6
+ "use strict";
7
+
8
+ var STORE_KEY = "dash_math_page_state";
9
+
10
+ function loadState() {
11
+ try { return JSON.parse(sessionStorage.getItem(STORE_KEY) || "{}"); }
12
+ catch (e) { return {}; }
13
+ }
14
+ function saveState(s) {
15
+ try { sessionStorage.setItem(STORE_KEY, JSON.stringify(s)); }
16
+ catch (e) {}
17
+ }
18
+ function getPathname() { return window.location.pathname || "/"; }
19
+
20
+ function saveProp(key, value) {
21
+ var s = loadState(), p = getPathname();
22
+ if (!s[p]) s[p] = {};
23
+ s[p][key] = value;
24
+ saveState(s);
25
+ }
26
+ function saveNested(group, key, value) {
27
+ var s = loadState(), p = getPathname();
28
+ if (!s[p]) s[p] = {};
29
+ if (!s[p][group]) s[p][group] = {};
30
+ s[p][group][key] = value;
31
+ saveState(s);
32
+ }
33
+ function getPs() {
34
+ var s = loadState(), p = getPathname();
35
+ return s[p] || {};
36
+ }
37
+
38
+ // ── 恢復 ──────────────────────────────────────────────────────────────
39
+
40
+ var _restoring = false;
41
+
42
+ function restoreState() {
43
+ var ps = getPs();
44
+ _restoring = true;
45
+
46
+ // Section
47
+ if (ps.section) {
48
+ var b = document.querySelector('[data-sid="' + ps.section + '"]');
49
+ if (b) b.click();
50
+ }
51
+
52
+ // 填空題
53
+ var inputs = ps.quiz_inputs || {};
54
+ Object.keys(inputs).forEach(function (qid) {
55
+ var val = inputs[qid];
56
+ if (!val) return;
57
+ // 用 querySelectorAll 找包含此 qid 的 input
58
+ document.querySelectorAll("input").forEach(function (el) {
59
+ var w = el.closest("[id]");
60
+ if (!w || w.id.indexOf(qid) === -1 || w.id.indexOf("quiz-input") === -1) return;
61
+ var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
62
+ setter.call(el, val);
63
+ el.dispatchEvent(new Event("input", { bubbles: true }));
64
+ });
65
+ });
66
+
67
+ // 解法展開
68
+ var solutions = ps.solutions || {};
69
+ Object.keys(solutions).forEach(function (key) {
70
+ if (!solutions[key]) return;
71
+ document.querySelectorAll("button").forEach(function (btn) {
72
+ if (btn.id && btn.id.indexOf(key) > -1 && btn.id.indexOf("solution-toggle") > -1) {
73
+ btn.click();
74
+ }
75
+ });
76
+ });
77
+
78
+ setTimeout(function () { _restoring = false; }, 800);
79
+ }
80
+
81
+ // ── 掛載監聽 ─────────────────────────────────────────────────────────
82
+
83
+ var _attached = new Set();
84
+
85
+ function attachListeners() {
86
+ // Section 按鈕
87
+ document.querySelectorAll("[data-sid]").forEach(function (btn) {
88
+ var sid = btn.getAttribute("data-sid");
89
+ if (_attached.has("s:" + sid)) return;
90
+ _attached.add("s:" + sid);
91
+ btn.addEventListener("click", function () {
92
+ if (!_restoring) saveProp("section", sid);
93
+ });
94
+ });
95
+
96
+ // 填空題 input
97
+ document.querySelectorAll("input").forEach(function (el) {
98
+ if (_attached.has(el)) return;
99
+ var w = el.closest("[id]");
100
+ if (!w || !w.id || w.id.indexOf("quiz-input") === -1) return;
101
+ var m = w.id.match(/"qid":"([^"]+)"/);
102
+ if (!m) return;
103
+ var qid = m[1];
104
+ _attached.add(el);
105
+ el.addEventListener("change", function () {
106
+ if (!_restoring) saveNested("quiz_inputs", qid, el.value);
107
+ });
108
+ });
109
+
110
+ // 解法 toggle 按鈕
111
+ document.querySelectorAll("button").forEach(function (btn) {
112
+ if (_attached.has(btn)) return;
113
+ if (!btn.id || btn.id.indexOf("solution-toggle") === -1) return;
114
+ var m = btn.id.match(/"key":"([^"]+)"/);
115
+ if (!m) return;
116
+ var key = m[1];
117
+ _attached.add(btn);
118
+ btn.addEventListener("click", function () {
119
+ if (_restoring) return;
120
+ // 延遲判斷是否展開
121
+ setTimeout(function () {
122
+ var col = document.querySelector('[id*="' + key + '"][id*="solution-collapse"]');
123
+ var opened = col ? col.offsetHeight > 10 : false;
124
+ saveNested("solutions", key, opened);
125
+ }, 400);
126
+ });
127
+ });
128
+ }
129
+
130
+ // ── 換頁偵測 ─────────────────────────────────────────────────────────
131
+
132
+ var _lastPath = null, _timer = null;
133
+
134
+ function checkPath() {
135
+ var cur = getPathname();
136
+ if (cur !== _lastPath) {
137
+ _lastPath = cur;
138
+ _attached.clear();
139
+ clearTimeout(_timer);
140
+ _timer = setTimeout(function () {
141
+ attachListeners();
142
+ restoreState();
143
+ }, 900);
144
+ } else {
145
+ attachListeners();
146
+ }
147
+ }
148
+
149
+ // MutationObserver:只做 debounce checkPath,不掃描整個 DOM
150
+ var _mutTimer = null;
151
+ var obs = new MutationObserver(function () {
152
+ clearTimeout(_mutTimer);
153
+ _mutTimer = setTimeout(checkPath, 200);
154
+ });
155
+
156
+ function init() {
157
+ _lastPath = getPathname();
158
+ obs.observe(document.body, { childList: true, subtree: false }); // subtree:false 減少觸發
159
+ setTimeout(function () {
160
+ attachListeners();
161
+ restoreState();
162
+ }, 1000);
163
+ }
164
+
165
+ if (document.readyState === "loading") {
166
+ document.addEventListener("DOMContentLoaded", init);
167
+ } else {
168
+ init();
169
+ }
170
+
171
+ })();
assets/toc.css ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* === TOC 區塊:捲動與底部空白 === */
2
+
3
+ :root {
4
+ --toc-bottom-gap: 10px;
5
+ }
6
+
7
+ /* TOC 欄:sticky 定位,高度跟隨視窗 */
8
+ .toc-col-wrapper {
9
+ /* 桌機:顯示 */
10
+ display: block;
11
+ }
12
+
13
+ /* 內層容器:只負責間距,overflow 由外層 sticky 容器管理 */
14
+ .toc-scroll {
15
+ padding-right: 4px;
16
+ padding-bottom: var(--toc-bottom-gap);
17
+ }
18
+
19
+ /* 再塞一塊看不見的空白,讓最後一個項目可以捲到按鈕上方 */
20
+ .toc-scroll::after {
21
+ content: "";
22
+ display: block;
23
+ height: var(--toc-bottom-gap);
24
+ }
25
+
26
+ /* === TOC 連結 / 按鈕 顏色設定 === */
27
+
28
+ /* 淺色主題 */
29
+ .toc-container .toc-link,
30
+ .toc-container .toc-link:link,
31
+ .toc-container .toc-link:visited,
32
+ .toc-container .toc-link:active,
33
+ .toc-container .toc-button {
34
+ color: #228be6 !important;
35
+ text-decoration: none !important;
36
+ }
37
+
38
+ /* 淺色主題 hover / active */
39
+ .toc-container .toc-link:hover,
40
+ .toc-container .toc-button:hover,
41
+ .toc-container .toc-button:active,
42
+ .toc-container .toc-button:focus,
43
+ .toc-container .toc-button[data-active="true"] {
44
+ color: #228be6 !important;
45
+ background-color: transparent !important;
46
+ box-shadow: none !important;
47
+ }
48
+
49
+ /* 深色主題 */
50
+ [data-mantine-color-scheme="dark"] .toc-container .toc-link,
51
+ [data-mantine-color-scheme="dark"] .toc-container .toc-link:link,
52
+ [data-mantine-color-scheme="dark"] .toc-container .toc-link:visited,
53
+ [data-mantine-color-scheme="dark"] .toc-container .toc-link:active,
54
+ [data-mantine-color-scheme="dark"] .toc-container .toc-button {
55
+ color: #b197fc !important;
56
+ }
57
+
58
+ [data-mantine-color-scheme="dark"] .toc-container .toc-link:hover,
59
+ [data-mantine-color-scheme="dark"] .toc-container .toc-button:hover,
60
+ [data-mantine-color-scheme="dark"] .toc-container .toc-button:active,
61
+ [data-mantine-color-scheme="dark"] .toc-container .toc-button:focus,
62
+ [data-mantine-color-scheme="dark"] .toc-container .toc-button[data-active="true"] {
63
+ color: #b197fc !important;
64
+ background-color: transparent !important;
65
+ box-shadow: none !important;
66
+ }
67
+
68
+ /* TOC 內所有超連結都不要底線 */
69
+ .toc-scroll a,
70
+ .toc-scroll a:link,
71
+ .toc-scroll a:visited,
72
+ .toc-scroll a:hover,
73
+ .toc-scroll a:active {
74
+ text-decoration: none !important;
75
+ }
76
+
77
+ /* 目前閱讀的段落在 TOC 上高亮 */
78
+ .toc-item.toc-active .mantine-Button-root {
79
+ font-weight: 600;
80
+ border-left: 3px solid var(--mantine-color-teal-6);
81
+ padding-left: 10px;
82
+ background-color: rgba(56, 178, 172, 0.08);
83
+ }
84
+
85
+ /* 讓目錄按鈕的文字可以換行 */
86
+ .toc-button {
87
+ white-space: normal;
88
+ height: auto;
89
+ line-height: 1.4;
90
+ }
91
+
92
+ .toc-button .mantine-Button-label {
93
+ white-space: normal;
94
+ text-align: left;
95
+ overflow: visible;
96
+ text-overflow: clip;
97
+ }
98
+
99
+ /* ===================================================================
100
+ 響應式:小螢幕隱藏 TOC
101
+ =================================================================== */
102
+
103
+ /* < 768px:隱藏 TOC,主內容全寬 */
104
+ @media (max-width: 768px) {
105
+ .toc-col-wrapper {
106
+ display: none !important;
107
+ }
108
+ }
109
+
110
+ /* 768px ~ 992px:顯示但縮小 */
111
+ @media (min-width: 769px) and (max-width: 992px) {
112
+ .toc-scroll {
113
+ max-height: calc(100vh - 140px);
114
+ }
115
+ }
116
+
117
+ /* ===================================================================
118
+ 視窗過小(縮放超過 125%)時也隱藏 TOC
119
+ 依據實際視窗寬度,非 CSS zoom
120
+ =================================================================== */
121
+ @media (max-width: 900px) {
122
+ .toc-col-wrapper {
123
+ display: none !important;
124
+ }
125
+ }
assets/toc_scrollspy.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // assets/toc_scrollspy.js
2
+ console.log("[toc_scrollspy] loaded (v3-scroll-sync)");
3
+
4
+ (function () {
5
+ let ticking = false;
6
+
7
+ function getTocState() {
8
+ const root = document.getElementById("toc-root");
9
+ if (!root) return null;
10
+
11
+ const offset = parseInt(root.dataset.tocOffset || "80", 10);
12
+ const items = Array.from(root.querySelectorAll(".toc-item"));
13
+ if (!items.length) return null;
14
+
15
+ const pairs = items
16
+ .map((item) => {
17
+ const targetId = item.dataset.target;
18
+ if (!targetId) return null;
19
+ const el = document.getElementById(targetId);
20
+ if (!el) return null;
21
+ return { item, el, id: targetId };
22
+ })
23
+ .filter(Boolean);
24
+
25
+ if (!pairs.length) return null;
26
+
27
+ return { root, offset, pairs };
28
+ }
29
+
30
+ function keepItemVisible(root, item) {
31
+ if (!root || !item) return;
32
+
33
+ const rootTop = root.scrollTop;
34
+ const rootBottom = rootTop + root.clientHeight;
35
+
36
+ const itemTop = item.offsetTop;
37
+ const itemBottom = itemTop + item.offsetHeight;
38
+
39
+ if (itemTop < rootTop + 8) {
40
+ root.scrollTo({
41
+ top: Math.max(0, itemTop - 8),
42
+ behavior: "smooth",
43
+ });
44
+ } else if (itemBottom > rootBottom - 8) {
45
+ root.scrollTo({
46
+ top: itemBottom - root.clientHeight + 8,
47
+ behavior: "smooth",
48
+ });
49
+ }
50
+ }
51
+
52
+ function updateActiveToc() {
53
+ const state = getTocState();
54
+ if (!state) return;
55
+
56
+ const { root, offset, pairs } = state;
57
+
58
+ // 找目前最接近視窗頂端 offset 的 section
59
+ let active = pairs[0];
60
+ let bestDist = Infinity;
61
+
62
+ for (const pair of pairs) {
63
+ const rect = pair.el.getBoundingClientRect();
64
+ const dist = Math.abs(rect.top - offset - 8);
65
+
66
+ // 優先選在 offset 上方或接近 offset 的 section
67
+ if (rect.top - offset <= 20 && dist < bestDist) {
68
+ bestDist = dist;
69
+ active = pair;
70
+ }
71
+ }
72
+
73
+ // 如果上面都沒找到,就退回第一個可見 section
74
+ if (!active) {
75
+ for (const pair of pairs) {
76
+ const rect = pair.el.getBoundingClientRect();
77
+ if (rect.top >= offset) {
78
+ active = pair;
79
+ break;
80
+ }
81
+ }
82
+ }
83
+
84
+ pairs.forEach(({ item }) => item.classList.remove("toc-active"));
85
+
86
+ if (active) {
87
+ active.item.classList.add("toc-active");
88
+ keepItemVisible(root, active.item);
89
+ }
90
+ }
91
+
92
+ function requestUpdate() {
93
+ if (ticking) return;
94
+ ticking = true;
95
+ window.requestAnimationFrame(() => {
96
+ updateActiveToc();
97
+ ticking = false;
98
+ });
99
+ }
100
+
101
+ function start() {
102
+ requestUpdate();
103
+
104
+ window.addEventListener("scroll", requestUpdate, { passive: true });
105
+ window.addEventListener("resize", requestUpdate, { passive: true });
106
+
107
+ const mo = new MutationObserver(() => {
108
+ requestUpdate();
109
+ });
110
+
111
+ mo.observe(document.body, { childList: true, subtree: true });
112
+ }
113
+
114
+ if (document.readyState === "loading") {
115
+ document.addEventListener("DOMContentLoaded", start);
116
+ } else {
117
+ start();
118
+ }
119
+ })();
assets/unit_vector.svg ADDED

Git LFS Details

  • SHA256: 09acf636f2ed090d2f94881e631c0192ad8f682eb50014792086d62333f96316
  • Pointer size: 130 Bytes
  • Size of remote file: 15.4 kB
assets/unit_vector_theta.svg ADDED

Git LFS Details

  • SHA256: f3009f54924ebd9f7dd9a8f5667a0d5050c74322b50b36d9cc7a0f77708cd1e3
  • Pointer size: 130 Bytes
  • Size of remote file: 25.3 kB
build.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ build.py
3
+ --------
4
+ Markdown → JSON 的 build pipeline。
5
+
6
+ 職責:
7
+ 讀取 paths.json → 對每個任務呼叫 Test2ChMd._export_one → 輸出 JSON
8
+
9
+ 與 app.py 完全解耦,可用兩種方式執行:
10
+
11
+ A. 命令列(手動重建):
12
+ python build.py # 使用預設 paths.json
13
+ python build.py paths_ch15.json # 指定 config
14
+ python build.py paths.json --force # 強制重建全部
15
+
16
+ B. 由 app.py import 並在啟動時呼叫:
17
+ from build import build_if_needed
18
+ build_if_needed(CONFIG_PATH, force=False) # 只重建有異動的任務
19
+ """
20
+
21
+ from __future__ import annotations
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ # Test2ChMd 放在同層或 layout/ 下,依實際位置選一種
26
+ from layout.converter.md_pipeline import build_from_config
27
+
28
+
29
+ def build_if_needed(
30
+ config_path: str | Path = "paths.json",
31
+ *,
32
+ force: bool = False,
33
+ ) -> list[Path]:
34
+ """
35
+ 執行 build pipeline。
36
+
37
+ config_path:paths.json 路徑(絕對或相對於 CWD)
38
+ force:True → 強制重建全部任務;False → 只重建比 md 舊的 json
39
+ """
40
+ return build_from_config(config_path, force=force)
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # 命令列介面
45
+ # ---------------------------------------------------------------------------
46
+ if __name__ == "__main__":
47
+ import argparse
48
+
49
+ parser = argparse.ArgumentParser(
50
+ description="把 paths.json 中的 Markdown 轉換成 JSON"
51
+ )
52
+ parser.add_argument(
53
+ "config",
54
+ nargs="?",
55
+ default="paths.json",
56
+ help="paths.json 的路徑(預設:./paths.json)",
57
+ )
58
+ parser.add_argument(
59
+ "--force",
60
+ action="store_true",
61
+ help="強制重建全部任務(忽略 up_to_date 設定)",
62
+ )
63
+ args = parser.parse_args()
64
+
65
+ outputs = build_if_needed(args.config, force=args.force)
66
+ print(f"\n完成:共輸出 {len(outputs)} 個 JSON 檔案。")
components/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ components/
3
+ -----------
4
+ 純 UI 元件套件,所有元件均無 Dash callback 或 app 狀態。
5
+
6
+ 快速 import 範例:
7
+ from components.cards import create_chapter_card, render_card_from_node
8
+ from components.render_utils import _runs_to_children
9
+ """
components/cards.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ components/cards.py
3
+ --------------------
4
+ 所有卡片元件的單一來源。
5
+
6
+ 公開 API:
7
+ create_chapter_card(data) → dmc.Card (章節卡片,原 Chapter_card.py)
8
+ render_card_from_node(card_node) → dmc.Card (結構化 node 卡片,新版)
9
+ generate_custom_markdown_card(...) → dmc.Card (舊版 Markdown 字串卡片,保留供過渡期使用)
10
+
11
+ 遷移建議:
12
+ - 新頁面請使用 render_card_from_node(接受 JSON 結構化資料)
13
+ - generate_custom_markdown_card 標記為 deprecated,待舊頁面遷移完成後刪除
14
+ """
15
+
16
+ from __future__ import annotations
17
+ from typing import Any
18
+
19
+ from dash import html, dcc
20
+ import dash_mantine_components as dmc
21
+ from dash_iconify import DashIconify
22
+
23
+ from components.render_utils import _inline_math, _block_math, _runs_to_children
24
+
25
+ try:
26
+ import dash_latex
27
+ DashLatex = dash_latex.DashLatex
28
+ except Exception:
29
+ DashLatex = None
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # 1. 章節卡片(原 Chapter_card.py)
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def create_chapter_card(data: dict) -> dmc.Card:
37
+ """
38
+ 產生章節總覽用的卡片。
39
+
40
+ data 欄位:
41
+ title str 卡片標題
42
+ badges List[Tuple[str, str]] [(label, color), ...]
43
+ description List[str] 說明文字(最多 4 行)
44
+ link str 「單元學習」按鈕連結
45
+ test str 「小測驗」按鈕連結
46
+ trick str (選用) 「技巧學習」按鈕連結
47
+ """
48
+ buttons = [
49
+ dmc.Anchor(
50
+ href=data["link"],
51
+ children=dmc.Button(
52
+ "單元學習", variant="light", color="orange", size="sm", radius="xl"
53
+ ),
54
+ )
55
+ ]
56
+
57
+ if "trick" in data:
58
+ buttons.append(
59
+ dmc.Anchor(
60
+ href=data["trick"],
61
+ children=dmc.Button(
62
+ "技巧學習", variant="outline", color="gray", size="sm", radius="xl"
63
+ ),
64
+ )
65
+ )
66
+
67
+ buttons.append(
68
+ dmc.Anchor(
69
+ href=data["test"],
70
+ children=dmc.Button(
71
+ "小測驗", variant="filled", color="blue", size="sm", radius="xl"
72
+ ),
73
+ )
74
+ )
75
+
76
+ # 說明文字補足到 4 行,保持卡片高度一致
77
+ desc_items = [
78
+ dmc.Text(line, size="sm", c="dimmed") for line in data["description"]
79
+ ]
80
+ desc_items += [
81
+ dmc.Text(" ", size="sm")
82
+ for _ in range(4 - len(data["description"]))
83
+ ]
84
+
85
+ return dmc.Card(
86
+ children=[
87
+ dmc.Text(data["title"], fw=700, size="lg"),
88
+ dmc.Group(
89
+ gap="xs",
90
+ mt="xs",
91
+ mb="sm",
92
+ children=[
93
+ dmc.Badge(label, color=color, radius="md")
94
+ for label, color in data["badges"]
95
+ ],
96
+ ),
97
+ dmc.Stack(gap=0, style={"minHeight": 100}, children=desc_items),
98
+ dmc.Flex(
99
+ gap="sm",
100
+ wrap="wrap",
101
+ justify="flex-start",
102
+ mt="sm",
103
+ children=buttons,
104
+ ),
105
+ ],
106
+ withBorder=True,
107
+ shadow="sm",
108
+ radius="md",
109
+ style={
110
+ "width": {
111
+ "base": 280,
112
+ "sm": 320,
113
+ "md": 350,
114
+ "lg": 400,
115
+ }
116
+ },
117
+ )
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # 2. 結構化 node 卡片(原 generate_common_card.py:render_card_from_node)
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def render_card_from_node(card_node: dict) -> dmc.Card:
125
+ """
126
+ 把 Test2ChMd 產生的卡片 JSON node 渲染成 DMC Card。
127
+
128
+ card_node 格式:
129
+ {
130
+ "kind": str, # 卡片種類(例如「定義」「定理」)
131
+ "headline": str, # 標題文字
132
+ "body": List[block], # 內容 block 清單
133
+ }
134
+
135
+ body block 支援的 type:
136
+ paragraph / list / math / url / picture / horizontal_rule
137
+ """
138
+ kind = (card_node.get("kind") or "卡片").strip()
139
+ headline = (card_node.get("headline") or "").strip()
140
+
141
+ header = dmc.CardSection(
142
+ dmc.Group(
143
+ [
144
+ dmc.Badge(kind, color="violet", variant="filled"),
145
+ dmc.Text(headline, fw=700, style={"whiteSpace": "pre-wrap"}),
146
+ ],
147
+ gap="sm",
148
+ ),
149
+ withBorder=True,
150
+ inheritPadding=True,
151
+ py=6,
152
+ style={
153
+ "backgroundColor": dmc.DEFAULT_THEME["colors"]["violet"][7],
154
+ "color": "white",
155
+ },
156
+ )
157
+
158
+ body_children: list[Any] = []
159
+ for b in card_node.get("body", []):
160
+ t = (b.get("type") or "").lower()
161
+
162
+ if t == "paragraph":
163
+ body_children.append(
164
+ html.Div(_runs_to_children(b.get("runs") or []))
165
+ )
166
+ elif t == "list":
167
+ items = [
168
+ dmc.ListItem(_runs_to_children(it.get("runs") or []))
169
+ for it in b.get("content", [])
170
+ ]
171
+ body_children.append(
172
+ dmc.List(items, listStyleType="none", spacing="xs", withPadding=True)
173
+ )
174
+ elif t == "math":
175
+ body_children.append(_block_math(b.get("latex", "")))
176
+ elif t == "url":
177
+ body_children.append(
178
+ dcc.Markdown(
179
+ f"[{b.get('text')}]({b.get('href')})", link_target="_blank"
180
+ )
181
+ )
182
+ elif t == "picture":
183
+ body_children.append(
184
+ html.Img(
185
+ src=b.get("src"),
186
+ title=b.get("alt") or "",
187
+ style={
188
+ "display": "inline-block",
189
+ "margin": "6px",
190
+ "maxWidth": "100%",
191
+ "height": "auto",
192
+ },
193
+ )
194
+ )
195
+ elif t == "horizontal_rule":
196
+ body_children.append(dmc.Divider(my=12))
197
+
198
+ return dmc.Card(
199
+ [header, dmc.Stack(body_children, gap="xs", px="md", py="sm")],
200
+ withBorder=True,
201
+ shadow="md",
202
+ radius="md",
203
+ mb=14,
204
+ )
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # 3. 舊版 Markdown 字串卡片(deprecated,保留至頁面遷移完成)
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def generate_custom_markdown_card(
212
+ title: str,
213
+ paragraphs: list[dict],
214
+ card_color: str = "#f8f9fa",
215
+ title_bg_color: str = dmc.DEFAULT_THEME["colors"]["green"][7],
216
+ latex_list: list[str] | None = None,
217
+ ) -> dmc.Card:
218
+ """
219
+ .. deprecated::
220
+ 請改用 render_card_from_node。
221
+ 此函式將在所有舊頁面遷移完成後刪除。
222
+
223
+ paragraphs 格式:
224
+ [{"text": str, "align": "left|center|right", "color": str}, ...]
225
+ latex_list 格式:
226
+ ["latex_str_1", "latex_str_2", ...] (有序清單)
227
+ """
228
+ def _render_paragraph(p: dict) -> html.Div:
229
+ return html.Div(
230
+ dcc.Markdown(p["text"], mathjax=True),
231
+ style={
232
+ "textAlign": p.get("align", "left"),
233
+ "color": p.get("color", "black"),
234
+ "marginBottom": "0.5rem",
235
+ },
236
+ )
237
+
238
+ content: list[Any] = [_render_paragraph(p) for p in paragraphs]
239
+
240
+ if latex_list:
241
+ # 需要 dash_latex;若未安裝則退回純文字
242
+ list_items = []
243
+ for latex in latex_list:
244
+ if DashLatex:
245
+ list_items.append(
246
+ dmc.ListItem(DashLatex(f"${latex}$"), mt=10)
247
+ )
248
+ else:
249
+ list_items.append(
250
+ dmc.ListItem(dcc.Markdown(f"${latex}$", mathjax=True), mt=10)
251
+ )
252
+ content.append(
253
+ dmc.List(
254
+ children=list_items,
255
+ listStyleType="ordered",
256
+ spacing="sm",
257
+ )
258
+ )
259
+
260
+ return dmc.Card(
261
+ children=[
262
+ dmc.CardSection(
263
+ dmc.Group(
264
+ children=[
265
+ dmc.Text(
266
+ title,
267
+ fw=500,
268
+ style={"color": dmc.DEFAULT_THEME["colors"]["gray"][0]},
269
+ )
270
+ ]
271
+ ),
272
+ withBorder=True,
273
+ inheritPadding=True,
274
+ py=3,
275
+ style={"backgroundColor": title_bg_color},
276
+ ),
277
+ html.Div(children=content, style={"padding": "10px"}),
278
+ ],
279
+ withBorder=True,
280
+ shadow="md",
281
+ radius="md",
282
+ style={"backgroundColor": card_color},
283
+ )
components/render_utils.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ components/render_utils.py
3
+ --------------------------
4
+ 純渲染工具函式,不依賴任何 Dash callback 或 app 狀態。
5
+ 供 cards.py、dashTest.py(未來統一後)等元件共用。
6
+
7
+ 公開 API:
8
+ _inline_math(latex, style=None) → Dash element
9
+ _block_math(latex, style=None) → Dash element
10
+ _runs_to_children(runs) → List[Dash element]
11
+ """
12
+
13
+ from __future__ import annotations
14
+ from typing import Any
15
+
16
+ from dash import html, dcc
17
+ import dash_mantine_components as dmc
18
+
19
+ try:
20
+ from dash_latex import DashLatex
21
+ except Exception:
22
+ DashLatex = None
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # 數學渲染
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _inline_math(latex: str, style: dict | None = None) -> Any:
30
+ """行內數學式。優先使用 DashLatex(KaTeX),退回 dcc.Markdown MathJax。"""
31
+ merged = {"display": "inline-block", "margin": "0 2px", **(style or {})}
32
+ if DashLatex:
33
+ return html.Span(DashLatex(latex, displayMode=False), style=merged)
34
+ src = latex if latex.strip().startswith("$") else f"${latex}$"
35
+ return dcc.Markdown(src, mathjax=True, style=merged)
36
+
37
+
38
+ def _block_math(latex: str, style: dict | None = None) -> Any:
39
+ """區塊數學式。優先使用 DashLatex(KaTeX),退回 dcc.Markdown MathJax。"""
40
+ merged = {"margin": "8px 0", **(style or {})}
41
+ if DashLatex:
42
+ return html.Div(DashLatex(latex, displayMode=True), style=merged)
43
+ src = latex.strip()
44
+ if not (src.startswith("$$") and src.endswith("$$")):
45
+ src = f"$$\n{src}\n$$"
46
+ return dcc.Markdown(src, mathjax=True, style=merged)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Run 列表轉 Dash 元素
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def _runs_to_children(runs: list[dict] | None) -> list[Any]:
54
+ """
55
+ 把 JSON block 中的 runs 陣列轉成 Dash 子元素清單。
56
+
57
+ Run 格式:
58
+ {"kind": "math", "latex": "..."} → 行內數學
59
+ {"text": "...", "style": "bold|italic|bold-italic",
60
+ "color_light": "#xxx", "color_dark": "#xxx"} → 文字 span
61
+ """
62
+ out: list[Any] = []
63
+ for r in runs or []:
64
+ # 數學 run
65
+ if r.get("kind") == "math" and "latex" in r:
66
+ out.append(_inline_math(r["latex"]))
67
+ continue
68
+
69
+ # 文字 run
70
+ sty: dict[str, Any] = {}
71
+ s = r.get("style", "")
72
+ if s == "bold":
73
+ sty["fontWeight"] = 700
74
+ elif s == "italic":
75
+ sty["fontStyle"] = "italic"
76
+ elif s == "bold-italic":
77
+ sty.update({"fontWeight": 700, "fontStyle": "italic"})
78
+
79
+ # 主題雙色(light / dark)
80
+ cls = None
81
+ if r.get("color_light") or r.get("color_dark"):
82
+ if r.get("color_light"):
83
+ sty["--tc-light"] = r["color_light"]
84
+ if r.get("color_dark"):
85
+ sty["--tc-dark"] = r["color_dark"]
86
+ sty["color"] = "var(--tc, var(--tc-light))"
87
+ cls = "themed-color"
88
+
89
+ out.append(html.Span(r.get("text", ""), style=sty, className=cls))
90
+
91
+ return out
firebase_init.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ firebase_init.py
3
+ ----------------
4
+ Firebase Admin SDK 初始化(單例模式)。
5
+ 放在專案根目錄,其他模組 import 即可。
6
+ """
7
+ from __future__ import annotations
8
+ import os, json
9
+ from pathlib import Path
10
+
11
+ _db = None # 全域單例
12
+
13
+ def get_db():
14
+ """取得 Firestore client(延遲初始化)。"""
15
+ global _db
16
+ if _db is not None:
17
+ return _db
18
+
19
+ try:
20
+ import firebase_admin
21
+ from firebase_admin import credentials, firestore
22
+ except ImportError:
23
+ raise RuntimeError(
24
+ "請先安裝 firebase-admin:pip install firebase-admin --break-system-packages"
25
+ )
26
+
27
+ if firebase_admin._apps:
28
+ _db = firestore.client()
29
+ return _db
30
+
31
+ # 優先讀取 firebase_key.json(本機開發)
32
+ key_file = Path(__file__).resolve().parent / "firebase_key.json"
33
+ if key_file.exists():
34
+ cred = credentials.Certificate(str(key_file))
35
+ else:
36
+ # 從環境變數讀取(Hugging Face Secrets)
37
+ sa_json = os.getenv("FIREBASE_SERVICE_ACCOUNT")
38
+ if not sa_json:
39
+ raise RuntimeError(
40
+ "找不到 Firebase 憑證。請放置 firebase_key.json 或設定環境變數 FIREBASE_SERVICE_ACCOUNT。"
41
+ )
42
+ cred = credentials.Certificate(json.loads(sa_json))
43
+
44
+ firebase_admin.initialize_app(cred)
45
+ _db = firestore.client()
46
+ return _db
firebase_progress.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ firebase_progress.py
3
+ --------------------
4
+ 學生學習進度的讀寫邏輯。
5
+ 放在專案根目錄,由 dt_callbacks.py 和頁面呼叫。
6
+
7
+ Firestore 資料結構:
8
+ students/{student_id}/
9
+ profile: { name, student_id, last_seen }
10
+ progress: { quiz_correct, solutions_viewed, ai_hints_used, ... }
11
+ events: [ { type, unit, qid, ts, ... }, ... ] ← 子集合
12
+ """
13
+ from __future__ import annotations
14
+ import datetime
15
+ from firebase_init import get_db
16
+
17
+ # ─── 時間戳 ────────────────────────────────────────────────────────────────
18
+ def _now() -> str:
19
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
20
+
21
+
22
+ # ─── 學生個人資料 ──────────────────────────────────────────────────────────
23
+
24
+ def upsert_student(student_id: str, name: str = "") -> None:
25
+ """建立或更新學生個人資料。"""
26
+ db = get_db()
27
+ ref = db.collection("students").document(student_id)
28
+ ref.set({
29
+ "student_id": student_id,
30
+ "name": name or student_id,
31
+ "last_seen": _now(),
32
+ }, merge=True)
33
+
34
+
35
+ # ─── 記錄事件 ─────────────────────────────────────────────────────────────
36
+
37
+ def log_event(student_id: str, event_type: str, **kwargs) -> None:
38
+ """
39
+ 記錄一筆學習事件到 students/{student_id}/events 子集合。
40
+
41
+ event_type 範例:
42
+ "quiz_correct" - 填空/選擇題答對
43
+ "quiz_wrong" - 答錯
44
+ "solution_viewed" - 展開詳細解法
45
+ "hint_used" - 按下 AI 提示
46
+ "quiz_ai_done" - AI 批改完成
47
+ """
48
+ db = get_db()
49
+ payload = {
50
+ "type": event_type,
51
+ "ts": _now(),
52
+ **kwargs, # unit, qid, answer, ...
53
+ }
54
+ db.collection("students").document(student_id)\
55
+ .collection("events").add(payload)
56
+
57
+
58
+ def log_quiz_result(
59
+ student_id: str,
60
+ unit: str,
61
+ qid: str,
62
+ correct: bool,
63
+ answer: str = "",
64
+ ) -> None:
65
+ """記錄一題答題結果,同時更新 progress 計數。"""
66
+ event_type = "quiz_correct" if correct else "quiz_wrong"
67
+ log_event(student_id, event_type, unit=unit, qid=qid, answer=answer)
68
+
69
+ # 更新統計計數
70
+ db = get_db()
71
+ ref = db.collection("students").document(student_id)
72
+ field = "quiz_correct_count" if correct else "quiz_wrong_count"
73
+ ref.set({field: _increment(1), "last_seen": _now()}, merge=True)
74
+
75
+
76
+ def log_solution_viewed(student_id: str, unit: str, solution_id: str) -> None:
77
+ """記錄學生展開了某個解法區塊。"""
78
+ log_event(student_id, "solution_viewed", unit=unit, solution_id=solution_id)
79
+ db = get_db()
80
+ db.collection("students").document(student_id)\
81
+ .set({"solutions_viewed_count": _increment(1), "last_seen": _now()}, merge=True)
82
+
83
+
84
+ def log_hint_used(student_id: str, unit: str, qid: str) -> None:
85
+ """記錄學生使用了 AI 提示。"""
86
+ log_event(student_id, "hint_used", unit=unit, qid=qid)
87
+ db = get_db()
88
+ db.collection("students").document(student_id)\
89
+ .set({"ai_hints_used_count": _increment(1), "last_seen": _now()}, merge=True)
90
+
91
+
92
+ # ─── 讀取 ────────────────────────────────────────────────────────────────
93
+
94
+ def get_student_progress(student_id: str) -> dict:
95
+ """讀取單一學生的 progress 計數(老師後台用)。"""
96
+ db = get_db()
97
+ doc = db.collection("students").document(student_id).get()
98
+ if doc.exists:
99
+ return doc.to_dict()
100
+ return {}
101
+
102
+
103
+ def get_all_students() -> list[dict]:
104
+ """讀取所有學生的 profile(老師後台列表用)。"""
105
+ db = get_db()
106
+ docs = db.collection("students").stream()
107
+ return [d.to_dict() for d in docs]
108
+
109
+
110
+ def get_student_events(student_id: str, limit: int = 100) -> list[dict]:
111
+ """讀取某學生最近的事件記錄。"""
112
+ db = get_db()
113
+ docs = (
114
+ db.collection("students").document(student_id)
115
+ .collection("events")
116
+ .order_by("ts", direction="DESCENDING")
117
+ .limit(limit)
118
+ .stream()
119
+ )
120
+ return [d.to_dict() for d in docs]
121
+
122
+
123
+ # ─── Firestore 遞增輔助 ───────────────────────────────────────────────────
124
+
125
+ def _increment(n: int):
126
+ from google.cloud.firestore import Increment
127
+ return Increment(n)
firebase_quiz_hooks.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ firebase_quiz_hooks.py
3
+ ----------------------
4
+ 答題記錄已移至 dt_callbacks.py 直接處理。
5
+ 這裡只保留解法展開和 AI 提示的記錄。
6
+ """
7
+ from __future__ import annotations
8
+ from dash import callback, Input, Output, State, ALL, no_update, ctx, dcc
9
+
10
+
11
+ def create_dummy_stores():
12
+ return [dcc.Store(id="_fq-log", data={})]
13
+
14
+
15
+ @callback(
16
+ Output("_fq-log", "data"),
17
+ Input({"type": "solution-toggle", "key": ALL}, "n_clicks"),
18
+ State({"type": "solution-toggle", "key": ALL}, "id"),
19
+ State("student-id-store", "data"),
20
+ State("page-url", "pathname"),
21
+ prevent_initial_call=True,
22
+ )
23
+ def record_solution(n_clicks_list, ids, student_id, pathname):
24
+ if not student_id:
25
+ return no_update
26
+ trigger = ctx.triggered_id
27
+ if not trigger or not isinstance(trigger, dict):
28
+ return no_update
29
+ key = trigger.get("key", "")
30
+ unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
31
+ for i, pid in enumerate(ids):
32
+ if isinstance(pid, dict) and pid.get("key") == key:
33
+ if (n_clicks_list[i] or 0) == 1:
34
+ try:
35
+ from firebase_progress import log_solution_viewed
36
+ log_solution_viewed(student_id, unit, key)
37
+ except Exception as e:
38
+ print(f"[Firebase] log_solution_viewed 失敗:{e}")
39
+ break
40
+ return no_update
41
+
42
+
43
+ @callback(
44
+ Output("_fq-log", "data", allow_duplicate=True),
45
+ Input({"type": "quiz-hint", "qid": ALL}, "n_clicks"),
46
+ Input({"type": "quiz-hint-choice", "qid": ALL}, "n_clicks"),
47
+ State({"type": "quiz-hint", "qid": ALL}, "id"),
48
+ State({"type": "quiz-hint-choice", "qid": ALL}, "id"),
49
+ State("student-id-store", "data"),
50
+ State("page-url", "pathname"),
51
+ prevent_initial_call=True,
52
+ )
53
+ def record_hint(fill_clicks, choice_clicks, fill_ids, choice_ids, student_id, pathname):
54
+ if not student_id:
55
+ return no_update
56
+ trigger = ctx.triggered_id
57
+ if not trigger or not isinstance(trigger, dict):
58
+ return no_update
59
+ qid = trigger.get("qid", "")
60
+ qtype = trigger.get("type", "")
61
+ if not qid or "hint" not in qtype:
62
+ return no_update
63
+ unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
64
+ ids = fill_ids if qtype == "quiz-hint" else choice_ids
65
+ clicks = fill_clicks if qtype == "quiz-hint" else choice_clicks
66
+ for i, pid in enumerate(ids):
67
+ if isinstance(pid, dict) and pid.get("qid") == qid:
68
+ if (clicks[i] or 0) == 1:
69
+ try:
70
+ from firebase_progress import log_hint_used
71
+ log_hint_used(student_id, unit, qid)
72
+ except Exception as e:
73
+ print(f"[Firebase] log_hint_used 失敗:{e}")
74
+ break
75
+ return no_update
ggb.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import dash
2
+ from dash import dcc, html, Input, Output
3
+ import plotly.graph_objects as go
4
+ import numpy as np
5
+
6
+ # --- 1. ?????? ---
7
+ def f(x, y):
8
+ return 4 - x**2 - 2*y**2
9
+
10
+ def df_dx(x, y): # ? x ??
11
+ return -2 * x
12
+
13
+ def df_dy(x, y): # ? y ??
14
+ return -4 * y
15
+
16
+ # --- 2. ??? Dash App ---
17
+ app = dash.Dash(__name__)
18
+
19
+ # --- 3. ???? (Layout) ---
20
+ app.layout = html.Div([
21
+ html.H2("????????? (Python?)", style={'textAlign': 'center'}),
22
+
23
+ # ????
24
+ html.Div([
25
+ html.Div([
26
+ html.Label("?? a ?:"),
27
+ dcc.Input(id='input-a', type='number', value=1, step=0.1),
28
+ ], style={'marginRight': '20px', 'display': 'inline-block'}),
29
+
30
+ html.Div([
31
+ html.Label("?? b ?:"),
32
+ dcc.Input(id='input-b', type='number', value=1, step=0.1),
33
+ ], style={'marginRight': '20px', 'display': 'inline-block'}),
34
+
35
+ # ?? Checklist ??????
36
+ dcc.Checklist(
37
+ id='toggle-switches',
38
+ options=[
39
+ {'label': ' ???? C1 (x??)', 'value': 'show_c1'},
40
+ {'label': ' ???? T1', 'value': 'show_t1'},
41
+ {'label': ' ???? C2 (y??)', 'value': 'show_c2'},
42
+ {'label': ' ???? T2', 'value': 'show_t2'},
43
+ ],
44
+ value=[], # ?????
45
+ inline=True
46
+ )
47
+ ], style={'padding': '20px', 'backgroundColor': '#f9f9f9'}),
48
+
49
+ # ??????
50
+ html.Div(id='slope-info', style={'padding': '10px', 'fontSize': '18px', 'fontWeight': 'bold'}),
51
+
52
+ # 3D ???
53
+ dcc.Graph(id='3d-surface-plot', style={'height': '80vh'})
54
+ ])
55
+
56
+ # --- 4. ???? (??????) ---
57
+ @app.callback(
58
+ [Output('3d-surface-plot', 'figure'),
59
+ Output('slope-info', 'children')],
60
+ [Input('input-a', 'value'),
61
+ Input('input-b', 'value'),
62
+ Input('toggle-switches', 'value')]
63
+ )
64
+ def update_graph(a, b, toggles):
65
+ # ??????????????????
66
+ if a is None: a = 1
67
+ if b is None: b = 1
68
+
69
+ # ?? P ???
70
+ z_val = f(a, b)
71
+
72
+ # --- ?????? ---
73
+ x_range = np.linspace(-3, 3, 50)
74
+ y_range = np.linspace(-3, 3, 50)
75
+ X, Y = np.meshgrid(x_range, y_range)
76
+ Z = f(X, Y)
77
+
78
+ fig = go.Figure()
79
+
80
+ # 1. ??? (???)
81
+ fig.add_trace(go.Surface(z=Z, x=X, y=Y, colorscale='Viridis', opacity=0.6, showscale=False, name='??'))
82
+
83
+ # 2. ? P ?
84
+ fig.add_trace(go.Scatter3d(
85
+ x=[a], y=[b], z=[z_val],
86
+ mode='markers', marker=dict(size=6, color='black'),
87
+ name=f'P({a},{b})'
88
+ ))
89
+
90
+ # --- ?? C1 ? T1 (? x ??) ---
91
+ slope_x = df_dx(a, b)
92
+ info_text = []
93
+
94
+ if 'show_c1' in toggles:
95
+ # C1: ?? y=b, x ??
96
+ t = np.linspace(-3, 3, 50)
97
+ fig.add_trace(go.Scatter3d(
98
+ x=t, y=[b]*50, z=f(t, b),
99
+ mode='lines', line=dict(color='red', width=5),
100
+ name='C1 (?? y=b)'
101
+ ))
102
+
103
+ if 'show_t1' in toggles:
104
+ # T1: ?? (?????????)
105
+ # ????? (1, 0, slope_x)
106
+ t_tan = np.linspace(-1.5, 1.5, 10) # ????
107
+ x_tan = a + t_tan
108
+ y_tan = [b] * 10
109
+ z_tan = z_val + slope_x * t_tan
110
+
111
+ fig.add_trace(go.Scatter3d(
112
+ x=x_tan, y=y_tan, z=z_tan,
113
+ mode='lines', line=dict(color='red', width=4, dash='dash'),
114
+ name=f'T1 ?? (m={slope_x:.2f})'
115
+ ))
116
+ info_text.append(f"x???? (fx) = {slope_x:.2f}")
117
+
118
+ # --- ?? C2 ? T2 (? y ??) ---
119
+ slope_y = df_dy(a, b)
120
+
121
+ if 'show_c2' in toggles:
122
+ # C2: ?? x=a, y ??
123
+ t = np.linspace(-3, 3, 50)
124
+ fig.add_trace(go.Scatter3d(
125
+ x=[a]*50, y=t, z=f(a, t),
126
+ mode='lines', line=dict(color='blue', width=5),
127
+ name='C2 (?? x=a)'
128
+ ))
129
+
130
+ if 'show_t2' in toggles:
131
+ # T2 ??
132
+ # ????? (0, 1, slope_y)
133
+ t_tan = np.linspace(-1.5, 1.5, 10)
134
+ x_tan = [a] * 10
135
+ y_tan = b + t_tan
136
+ z_tan = z_val + slope_y * t_tan
137
+
138
+ fig.add_trace(go.Scatter3d(
139
+ x=x_tan, y=y_tan, z=z_tan,
140
+ mode='lines', line=dict(color='blue', width=4, dash='dash'),
141
+ name=f'T2 ?? (m={slope_y:.2f})'
142
+ ))
143
+ info_text.append(f"y???? (fy) = {slope_y:.2f}")
144
+
145
+ # --- ????????? ---
146
+ fig.update_layout(
147
+ scene=dict(
148
+ xaxis=dict(range=[-3, 3]),
149
+ yaxis=dict(range=[-3, 3]),
150
+ zaxis=dict(range=[-5, 5]),
151
+ aspectratio=dict(x=1, y=1, z=0.7)
152
+ ),
153
+ margin=dict(l=0, r=0, b=0, t=0)
154
+ )
155
+
156
+ return fig, " | ".join(info_text) if info_text else "????????????"
157
+
158
+ # --- 5. ????? ---
159
+ if __name__ == '__main__':
160
+ app.run(debug=True)
layout/Chapter_card.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ layout/Chapter_card.py ← 向下相容 shim
3
+ ----------------------------------------
4
+ create_card 已整合進 layout/renderer/ 的 cards 模組。
5
+ 此 shim 讓舊 import 繼續正常運作:
6
+
7
+ from layout.Chapter_card import create_card ← 仍可用
8
+ """
9
+ from .renderer.renderer import render_doc as _ # 確保 renderer 已初始化 # noqa: F401
10
+
11
+ # Chapter_card 的原始功能直接在此定義(避免 components/ 路徑問題)
12
+ from dash import html
13
+ import dash_mantine_components as dmc
14
+ from dash_iconify import DashIconify
15
+
16
+
17
+ def create_card(data: dict) -> dmc.Card:
18
+ """章節總覽卡片(原 Chapter_card.py)。"""
19
+ buttons = [
20
+ dmc.Anchor(
21
+ href=data["link"],
22
+ children=dmc.Button("單元學習", variant="light", color="orange", size="sm", radius="xl"),
23
+ )
24
+ ]
25
+ if "trick" in data:
26
+ buttons.append(
27
+ dmc.Anchor(
28
+ href=data["trick"],
29
+ children=dmc.Button("技巧學習", variant="outline", color="gray", size="sm", radius="xl"),
30
+ )
31
+ )
32
+ buttons.append(
33
+ dmc.Anchor(
34
+ href=data["test"],
35
+ children=dmc.Button("小測驗", variant="filled", color="blue", size="sm", radius="xl"),
36
+ )
37
+ )
38
+ desc_items = [dmc.Text(line, size="sm", c="dimmed") for line in data["description"]]
39
+ desc_items += [dmc.Text(" ", size="sm") for _ in range(4 - len(data["description"]))]
40
+
41
+ return dmc.Card(
42
+ children=[
43
+ dmc.Text(data["title"], fw=700, size="lg"),
44
+ dmc.Group(
45
+ gap="xs", mt="xs", mb="sm",
46
+ children=[dmc.Badge(label, color=color, radius="md") for label, color in data["badges"]],
47
+ ),
48
+ dmc.Stack(gap=0, style={"minHeight": 100}, children=desc_items),
49
+ dmc.Flex(gap="sm", wrap="wrap", justify="flex-start", mt="sm", children=buttons),
50
+ ],
51
+ withBorder=True, shadow="sm", radius="md",
52
+ style={"width": {"base": 280, "sm": 320, "md": 350, "lg": 400}},
53
+ )
54
+
55
+
56
+ __all__ = ["create_card"]
layout/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ layout/__init__.py
3
+ ------------------
4
+ 統一 re-export 各子資料夾的常用 API,
5
+ 讓 pages/ 裡的 import 路徑保持簡短。
6
+ """
7
+
8
+ # Shell 元件
9
+ from .shell.header import generate_header # noqa: F401
10
+ from .shell.navbar import generate_navbar # noqa: F401
11
+ from .shell.main import generate_main # noqa: F401
12
+ from .shell.footer import generate_footer # noqa: F401
13
+ from .shell.callbacks import generate_toggle_navbar, switch_light_dark # noqa: F401
14
+
15
+ # 轉換器
16
+ from .converter.md_pipeline import build_from_config # noqa: F401
17
+
18
+ # 渲染引擎
19
+ from .renderer.renderer import ( # noqa: F401
20
+ render_doc,
21
+ install_callbacks_once,
22
+ )
23
+
24
+ # 頁面組裝
25
+ from .page.page_layout import make_test_page # noqa: F401
layout/converter/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """
2
+ converter/ — Markdown → JSON 轉換器
3
+ 外部可直接用:from layout.converter import build_from_config
4
+ """
5
+ from .md_io import build_from_config # noqa: F401
6
+
7
+ # 進階用途(頁面直接解析 MD)
8
+ from .md_blocks import parse_document # noqa: F401
layout/converter/md_attrs.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ md_attrs.py
3
+ -----------
4
+ HTML 註解屬性字串解析。
5
+
6
+ 公開 API:
7
+ _attrs_to_dict(s) → dict (通用版,支援帶連字號的 key)
8
+ _parse_attrs(s) → dict (quiz block 變體,支援 bare flag)
9
+ _slug_ascii(t) → str (ASCII 化 slug,供 quiz id 使用)
10
+ """
11
+ from __future__ import annotations
12
+ import re
13
+ import hashlib
14
+ import unicodedata
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # 正規表達式
18
+ # ---------------------------------------------------------------------------
19
+
20
+ # 通用版:支援帶連字號的 key,例如 hint_a="..."
21
+ _Q_ATTR = re.compile(
22
+ r'([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*"([^"]*)"'
23
+ r'|([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*([^\s">]+)'
24
+ )
25
+
26
+ # quiz block 變體(_parse_attrs 用):額外支援 bare flag(沒有值的 key)
27
+ _QUIZ_ATTR = re.compile(
28
+ r'([A-Za-z_][\w-]*)\s*=\s*"([^"]*)"|([A-Za-z_][\w-]*)\s*=\s*([^\s">]+)|([A-Za-z_][\w-]*)'
29
+ )
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # 公開函式
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def _attrs_to_dict(s: str) -> dict[str, str]:
36
+ """
37
+ 把 HTML 註解屬性字串解析為 dict。
38
+
39
+ 支援:
40
+ key="value" → {key: value}
41
+ key=value → {key: value}
42
+ """
43
+ out: dict[str, str] = {}
44
+ if not s:
45
+ return out
46
+ for m in _Q_ATTR.finditer(s):
47
+ if m.group(1) and m.group(2) is not None:
48
+ out[m.group(1)] = m.group(2)
49
+ elif m.group(3) and m.group(4) is not None:
50
+ out[m.group(3)] = m.group(4)
51
+ return out
52
+
53
+
54
+ def _parse_attrs(s: str) -> dict[str, str]:
55
+ """
56
+ quiz block 屬性解析變體,額外支援 bare flag(key 無值時設為 "1")。
57
+ """
58
+ out: dict[str, str] = {}
59
+ for m in _QUIZ_ATTR.finditer(s or ""):
60
+ if m.group(1):
61
+ out[m.group(1)] = m.group(2)
62
+ elif m.group(3):
63
+ out[m.group(3)] = m.group(4)
64
+ elif m.group(5):
65
+ out[m.group(5)] = "1"
66
+ return out
67
+
68
+
69
+ def _slug_ascii(t: str, prefix: str = "order") -> str:
70
+ """
71
+ 把任意字串轉成 ASCII slug(供 quiz id 使用)。
72
+ 無法轉換時用 sha1 hash 作為 fallback。
73
+ """
74
+ t_norm = unicodedata.normalize("NFKD", t)
75
+ t_ascii = "".join(
76
+ ch for ch in t_norm if ch.isascii() and (ch.isalnum() or ch in "-_ ")
77
+ )
78
+ t_ascii = "-".join(t_ascii.strip().lower().split())
79
+ if t_ascii:
80
+ return t_ascii
81
+ h = hashlib.sha1(t.encode("utf-8")).hexdigest()[:8]
82
+ return f"{prefix}-{h}"
layout/converter/md_blocks.py ADDED
@@ -0,0 +1,970 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ md_blocks.py
3
+ ------------
4
+ 段落 / 清單 / 特殊區塊(card、quiz、flow、solution、ggb、icon)解析,
5
+ 以及文件級 parse_document 與 TOC 指派。
6
+
7
+ 依賴:md_color, md_tex, md_attrs, md_runs
8
+
9
+ 公開 API:
10
+ parse_document(text) → List[block] 主要入口
11
+ parse_content_block(text) → List[block]
12
+ parse_solution_block(src) → block
13
+ parse_card_block(src) → block
14
+ parse_interactive_block(src) → block
15
+ parse_flow_block(src) → block
16
+ parse_quizzes(md) → List[block]
17
+ parse_geogebra_marker(s) → block
18
+ parse_icon_marker(s) → block
19
+ parse_pyfunc_marker(s) → block
20
+ parse_list_items(list_block) → List[item]
21
+ parse_paragraph_runs_color_aware → List[run]
22
+ parse_line_color_aware → List[run]
23
+ assign_ids_and_build_toc(nodes) → List[toc_entry]
24
+ split_blocks_into_sections(blocks)→ List[section]
25
+ build_ai_prompt_for_example(...) → str
26
+ build_ai_prompt_for_pos(...) → str
27
+ build_ai_prompt_for_solution_pos(...)→ str
28
+ """
29
+ from __future__ import annotations
30
+ import re
31
+ import hashlib
32
+ import unicodedata
33
+ import json
34
+
35
+ from .md_color import attach_theme_colors, _norm_hex
36
+ from .md_tex import _ensure_inline_dollars, _ensure_block_dollars
37
+ from .md_attrs import _attrs_to_dict, _parse_attrs, _slug_ascii
38
+ from .md_runs import (
39
+ build_inline_runs, normalize_runs,
40
+ _has_newline_token, _plain_text_from_runs,
41
+ _append_hard_break,
42
+ _split_text_and_any_display_math,
43
+ _split_text_links_images, _split_text_and_ggb, _split_text_and_icon,
44
+ )
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # 模組級正規表達式(只保留 md_blocks 內部用的)
48
+ # ---------------------------------------------------------------------------
49
+ _COLOR_TAG = re.compile(r'<!--c_([A-Za-z0-9#]+)-->|\s*<!--c_([A-Za-z0-9#]+)_end-->')
50
+ _SOL_OPEN = re.compile(r'<!--\s*solution(?P<attrs>[^>]*)-->', re.IGNORECASE)
51
+ _SOL_CLOSE = re.compile(r'<!--\s*solution_end\s*-->', re.IGNORECASE)
52
+ _SOL_END_RE = re.compile(r'<!--\s*solution_end\s*-->', re.IGNORECASE)
53
+ _EXAMPLE_RE = re.compile(r'^\s*###\s*Example[^\n]*', re.MULTILINE)
54
+ _PROBLEM_RE = re.compile(r'<!--\s*problem\s*-->', re.IGNORECASE)
55
+ _SOLUTION_OPEN_RE = re.compile(r'<!--\s*solution\s*-->')
56
+ _TAG_ONLY_RE = re.compile(r'^<!--\s*([A-Za-z0-9_-]+)\s*-->$')
57
+ _SPLIT_AFTER_COLOR_CLOSE_TO_NEW_ITEM = re.compile(
58
+ r'^(?P<left>.*?<!--c_[A-Za-z0-9#]+_end-->)(?:\s*)(?P<right>(?P<indent>\s*)(?P<marker>\d+\.)\s+.*)$'
59
+ )
60
+ _BLANKPARA_SPLIT = re.compile(r'(\n\s*\n+)')
61
+ _PYFUNC_RE = re.compile(r'<!--\s*pyfunc(?P<attrs>[^>]*)-->')
62
+ _GGB_SINGLE_RE = re.compile(r'^\s*<!--\s*ggb(?P<attrs>[^>]*)-->\s*$', re.S)
63
+ _ICON_SINGLE_RE = re.compile(r'^\s*<!--\s*icon(?P<attrs>[^>]*)-->\s*$', re.S | re.I)
64
+ _CARD_RE = re.compile(r'<!--\s*card(?P<attrs>[^>]*)-->(?P<body>.*?)<!--\s*card_end\s*-->', re.S)
65
+ _FLOW_RE = re.compile(r'<!--\s*flow(?P<attrs>[^>]*)-->(?P<body>.*?)<!--\s*flow_end\s*-->', re.S)
66
+ _STEP_RE = re.compile(r'<!--\s*step(?P<attrs>[^>]*)-->(?P<body>.*?)<!--\s*step_end\s*-->', re.S)
67
+ QUIZ_OPEN = re.compile(r'<!--\s*quiz\s+(?P<attrs>[^>]*)-->')
68
+ QUIZ_END = re.compile(r'<!--\s*quiz_end\s*-->')
69
+ STEP_RE_SIMPLE = re.compile(r'<!--step-->\s*(.*)')
70
+ _FILE_MARKER_RE = re.compile(r'<!--\s*file(?:_|[\s]+)(?P<path>[^>\r\n]+?)\s*-->', re.IGNORECASE)
71
+
72
+ # 全域原始 MD(供 AI prompt 查詢用,由 md_io._export_one 注入)
73
+ _ROOT_MD: str = ""
74
+
75
+ # flow id 計數器
76
+ _FLOW_SEQ: int = 0
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # AI Prompt 輔助
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def build_ai_prompt_for_example(full_md: str, example_seq: int) -> str:
83
+ """回傳第 example_seq 個 Example 標題到第一個 <!--solution--> 之前的文字。"""
84
+ if not full_md or example_seq <= 0:
85
+ return ""
86
+ ex_iter = list(_EXAMPLE_RE.finditer(full_md))
87
+ if example_seq > len(ex_iter):
88
+ return ""
89
+ ex_m = ex_iter[example_seq - 1]
90
+ sol_m = _SOLUTION_OPEN_RE.search(full_md, pos=ex_m.start())
91
+ end = sol_m.start() if sol_m else len(full_md)
92
+ return full_md[ex_m.start():end].strip()
93
+
94
+
95
+ def build_ai_prompt_for_solution_pos(full_md: str, pos: int) -> str:
96
+ """solution block 的 AI prompt:從最近的 Example 到 pos,若中間有 solution_end 則回空。"""
97
+ if not full_md or pos is None or pos < 0:
98
+ return ""
99
+ search_region = full_md[:pos]
100
+ last_example = None
101
+ for m in _EXAMPLE_RE.finditer(search_region):
102
+ last_example = m
103
+ if not last_example:
104
+ return ""
105
+ seg = search_region[last_example.end():]
106
+ if _SOL_END_RE.search(seg):
107
+ return ""
108
+ return full_md[last_example.start():pos].strip()
109
+
110
+
111
+ def build_ai_prompt_for_pos(full_md: str, pos: int) -> str:
112
+ """給定 pos,往上��最近的 ### Example 或 <!--problem-->,抽出 prompt 文字。"""
113
+ if not full_md or pos is None or pos < 0:
114
+ return ""
115
+ search_region = full_md[:pos]
116
+ last_example = None
117
+ for m in _EXAMPLE_RE.finditer(search_region):
118
+ last_example = m
119
+ last_problem = None
120
+ for m in _PROBLEM_RE.finditer(search_region):
121
+ last_problem = m
122
+ if not last_example and not last_problem:
123
+ return ""
124
+ if last_example:
125
+ seg = search_region[last_example.end():]
126
+ if _SOL_END_RE.search(seg):
127
+ last_example = None
128
+ if not last_example and not last_problem:
129
+ return ""
130
+ if last_example and (not last_problem or last_example.start() > last_problem.start()):
131
+ start = last_example.start()
132
+ else:
133
+ start = last_problem.end()
134
+ return full_md[start:pos].strip()
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # TOC / ID 相關
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def _make_unique_id(base: str, used: set) -> str:
141
+ hid, i = base, 1
142
+ while hid in used:
143
+ i += 1
144
+ hid = f"{base}-{i}"
145
+ used.add(hid)
146
+ return hid
147
+
148
+
149
+ def _slugify_heading(text: str) -> str:
150
+ t = unicodedata.normalize("NFKC", text)
151
+ t = re.sub(r'\s+', '-', t.strip())
152
+ t = re.sub(r'[^A-Za-z0-9\-_]+', '-', t)
153
+ t = re.sub(r'-{2,}', '-', t).strip('-')
154
+ return t or "sec"
155
+
156
+
157
+ def assign_ids_and_build_toc(nodes: list[dict]) -> list[dict]:
158
+ """為 heading 節點指派 id,並回傳 TOC 清單。"""
159
+ current_tag, tag_counters, used_ids, toc = None, {}, set(), []
160
+ for n in nodes:
161
+ if n.get('type') == 'comment':
162
+ m = _TAG_ONLY_RE.match(n.get('content', '').strip())
163
+ if m:
164
+ current_tag = m.group(1)
165
+ tag_counters.setdefault(current_tag, 0)
166
+ continue
167
+ if n.get('type') == 'heading':
168
+ title = n.get('content', '').strip()
169
+ if current_tag:
170
+ tag_counters[current_tag] += 1
171
+ base = f"{current_tag}_id{tag_counters[current_tag]}"
172
+ else:
173
+ base = _slugify_heading(title)
174
+ hid = _make_unique_id(base, used_ids)
175
+ n['id'] = hid
176
+ toc.append({'label': title, 'id': hid, 'level': n.get('level', 2)})
177
+ return toc
178
+
179
+
180
+ def split_blocks_into_sections(blocks: list[dict]) -> list[dict]:
181
+ """按 H2 heading 把 blocks 切成 sections,第一個為「全部」。"""
182
+ sections: list[dict] = [{"id": "sec-all", "title": "全部", "blocks": blocks[:]}]
183
+ current, sec_idx = None, 0
184
+ for b in blocks:
185
+ if b.get("type") == "heading" and int(b.get("level", 0)) == 2:
186
+ if current:
187
+ sections.append(current)
188
+ sec_idx += 1
189
+ current = {
190
+ "id": f"sec-{sec_idx}",
191
+ "title": (b.get("content") or f"段落 {sec_idx}").strip(),
192
+ "blocks": [b],
193
+ }
194
+ else:
195
+ if current is not None:
196
+ current["blocks"].append(b)
197
+ if current:
198
+ sections.append(current)
199
+ return sections
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # 段落 / 清單解析
203
+ # ---------------------------------------------------------------------------
204
+
205
+ def _split_midline_new_item(line: str) -> tuple[str | None, str | None]:
206
+ m = _SPLIT_AFTER_COLOR_CLOSE_TO_NEW_ITEM.match(line)
207
+ if not m:
208
+ return None, None
209
+ return m.group('left'), m.group('right')
210
+
211
+
212
+ def _emit_blank_paragraph(parsed_content: list) -> None:
213
+ parsed_content.append({'type': 'paragraph', 'content': '', 'runs': [], 'newline': 'true'})
214
+
215
+
216
+ def _append_blankline_runs(blank_token: str, out_runs: list) -> None:
217
+ n = max(1, blank_token.count('\n') - 1)
218
+ for _ in range(n):
219
+ out_runs.append({'text': '', 'newline': 'true', '_blank': '1'})
220
+
221
+
222
+ def parse_line_color_aware(line: str, color_stack: list) -> list[dict]:
223
+ """把一行切成 runs,依 color_stack 上色,處理 <!--c_xxx--> 標記。"""
224
+ pos, out = 0, []
225
+
226
+ def cur_color():
227
+ return color_stack[-1] if color_stack else None
228
+
229
+ for m in _COLOR_TAG.finditer(line):
230
+ chunk = line[pos:m.start()]
231
+ if chunk:
232
+ for tk in _split_text_and_any_display_math(chunk):
233
+ if tk.get("kind") == "math_block":
234
+ run = {'latex': tk["latex"], 'kind': 'math_block'}
235
+ if cur_color():
236
+ run['color'] = cur_color()
237
+ attach_theme_colors(run)
238
+ out.append(run)
239
+ else:
240
+ runs = build_inline_runs(tk["text"] or "")
241
+ if cur_color():
242
+ for r in runs:
243
+ r.setdefault('color', cur_color())
244
+ attach_theme_colors(r)
245
+ out.extend(runs)
246
+ if m.group(1):
247
+ color_stack.append(m.group(1))
248
+ else:
249
+ name = m.group(2)
250
+ for k in range(len(color_stack) - 1, -1, -1):
251
+ if color_stack[k] == name:
252
+ color_stack.pop(k); break
253
+ pos = m.end()
254
+
255
+ tail = line[pos:]
256
+ if tail:
257
+ for tk in _split_text_and_any_display_math(tail):
258
+ if tk.get("kind") == "math_block":
259
+ run = {'latex': tk["latex"], 'kind': 'math_block'}
260
+ if cur_color():
261
+ run['color'] = cur_color()
262
+ attach_theme_colors(run)
263
+ out.append(run)
264
+ else:
265
+ runs = build_inline_runs(tk["text"] or "")
266
+ if cur_color():
267
+ for r in runs:
268
+ r.setdefault('color', cur_color())
269
+ attach_theme_colors(r)
270
+ out.extend(runs)
271
+ return out
272
+
273
+
274
+ def parse_paragraph_runs_color_aware(block_text: str) -> list[dict]:
275
+ """多行段落 → runs(含 $$ block math、硬換行)。"""
276
+ lines = (block_text or "").splitlines(keepends=True)
277
+ i, color_stack, runs = 0, [], []
278
+ PARA_DOLLAR_LINE = re.compile(r'^\s*\$\$(.*)$')
279
+
280
+ while i < len(lines):
281
+ ln = lines[i]
282
+ m_dollar = PARA_DOLLAR_LINE.match(ln.rstrip('\r\n'))
283
+ if m_dollar:
284
+ latex_lines, first = [], m_dollar.group(1)
285
+ if re.search(r'\$\$\s*$', first):
286
+ latex_lines.append(re.sub(r'\$\$\s*$', '', first))
287
+ else:
288
+ i += 1
289
+ while i < len(lines):
290
+ seg = lines[i].rstrip('\r\n')
291
+ if re.search(r'\$\$\s*$', seg):
292
+ latex_lines.append(re.sub(r'\$\$\s*$', '', seg)); break
293
+ latex_lines.append(seg); i += 1
294
+ latex = "\n".join(latex_lines).strip()
295
+ run = {'latex': _ensure_block_dollars(latex), 'kind': 'math_block'}
296
+ if color_stack:
297
+ run['color'] = color_stack[-1]
298
+ attach_theme_colors(run)
299
+ runs.append(run); i += 1; continue
300
+
301
+ raw = ln
302
+ if re.search(r'[ ]{2,}\r?\n$', raw):
303
+ body = re.sub(r'[ ]{2,}\r?\n$', '', raw)
304
+ runs.extend(parse_line_color_aware(body, color_stack))
305
+ _append_hard_break(runs)
306
+ else:
307
+ runs.extend(parse_line_color_aware(raw.rstrip('\r\n'), color_stack))
308
+ i += 1
309
+
310
+ return normalize_runs(runs)
311
+
312
+
313
+ def parse_list_items(list_block: str) -> list[dict]:
314
+ """解析清單 block,回傳帶 indent / marker / runs / content 的 item 清單。"""
315
+ lines, i = list_block.splitlines(), 0
316
+ list_items, color_stack = [], []
317
+ INDENT_PER_LEVEL = 2
318
+ item_re = re.compile(r'^(?P<indent>\s*)(?P<marker>[*+\-]|\d+\.)\s+(?P<content>.*)$')
319
+
320
+ def _parse_dollar_block(start_i):
321
+ """從 lines[start_i] 開始讀 $$...$$ block,回傳 (latex_str, next_i)。"""
322
+ m_d = re.match(r'^\s*\$\$(.*)$', lines[start_i])
323
+ if not m_d:
324
+ return None, start_i
325
+ latex_lines, first = [], m_d.group(1)
326
+ if re.search(r'\$\$\s*$', first):
327
+ latex_lines.append(re.sub(r'\$\$\s*$', '', first))
328
+ return "\n".join(latex_lines).strip(), start_i + 1
329
+ j = start_i + 1
330
+ while j < len(lines):
331
+ seg = lines[j]
332
+ if re.search(r'\$\$\s*$', seg):
333
+ latex_lines.append(re.sub(r'\$\$\s*$', '', seg)); j += 1; break
334
+ latex_lines.append(seg); j += 1
335
+ return "\n".join(latex_lines).strip(), j
336
+
337
+ while i < len(lines):
338
+ line = lines[i]
339
+ m = item_re.match(line)
340
+
341
+ if not m:
342
+ if list_items and line.strip():
343
+ left, right = _split_midline_new_item(line)
344
+ if right:
345
+ list_items[-1]['runs'].extend(parse_line_color_aware((left or '').strip(), color_stack))
346
+ list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
347
+ lines.insert(i + 1, right); i += 1; continue
348
+
349
+ latex, next_i = _parse_dollar_block(i)
350
+ if latex is not None:
351
+ run = {'latex': _ensure_block_dollars(latex), 'kind': 'math_block'}
352
+ if color_stack:
353
+ run['color'] = color_stack[-1]; attach_theme_colors(run)
354
+ list_items[-1]['runs'].append(run)
355
+ list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
356
+ i = next_i; continue
357
+
358
+ list_items[-1]['runs'].extend(parse_line_color_aware(line.strip(), color_stack))
359
+ list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
360
+ i += 1; continue
361
+
362
+ color_stack = []
363
+ raw_indent = m.group('indent')
364
+ indent_spaces = raw_indent.expandtabs(INDENT_PER_LEVEL)
365
+ indent = len(indent_spaces) // INDENT_PER_LEVEL
366
+ marker = m.group('marker')
367
+ content = m.group('content').rstrip()
368
+ runs = parse_line_color_aware(content, color_stack)
369
+ list_items.append({'indent': indent, 'marker': marker, 'runs': runs, 'content': _plain_text_from_runs(runs)})
370
+ i += 1
371
+
372
+ while i < len(lines) and not item_re.match(lines[i]):
373
+ ln = lines[i]
374
+ if not ln.strip():
375
+ i += 1; continue
376
+ left, right = _split_midline_new_item(ln)
377
+ if right:
378
+ list_items[-1]['runs'].extend(parse_line_color_aware((left or '').strip(), color_stack))
379
+ list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
380
+ lines.insert(i + 1, right); i += 1; continue
381
+ latex, next_i = _parse_dollar_block(i)
382
+ if latex is not None:
383
+ run = {'latex': _ensure_block_dollars(latex), 'kind': 'math_block'}
384
+ if color_stack:
385
+ run['color'] = color_stack[-1]; attach_theme_colors(run)
386
+ list_items[-1]['runs'].append(run)
387
+ list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
388
+ i = next_i; continue
389
+ list_items[-1]['runs'].extend(parse_line_color_aware(ln.strip(), color_stack))
390
+ list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
391
+ i += 1
392
+
393
+ return list_items
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # 特殊 marker 解析
397
+ # ---------------------------------------------------------------------------
398
+
399
+ def _ggb_id_from_url(u: str) -> str | None:
400
+ m = re.search(r'/m/([A-Za-z0-9]+)', u or '')
401
+ return m.group(1) if m else None
402
+
403
+
404
+ def _ggb_node_from_attrs(attrs: dict) -> dict:
405
+ gid = (attrs.get("id") or "").strip()
406
+ if not gid and "url" in attrs:
407
+ gid = _ggb_id_from_url(attrs.get("url", "")) or ""
408
+ return {
409
+ "type": "geogebra",
410
+ "id": gid,
411
+ "width": attrs.get("w") or attrs.get("width") or "100%",
412
+ "height": attrs.get("h") or attrs.get("height") or "480",
413
+ "border": attrs.get("border") or "0",
414
+ "caption": attrs.get("caption") or "",
415
+ "card": attrs.get("card") or "0",
416
+ }
417
+
418
+
419
+ def parse_geogebra_marker(s: str) -> dict:
420
+ m = _GGB_SINGLE_RE.search(s or '')
421
+ if not m:
422
+ return {"type": "paragraph", "content": s}
423
+ attrs = _attrs_to_dict(m.group('attrs') or "")
424
+ gid = attrs.get("id") or _ggb_id_from_url(attrs.get("url", ""))
425
+ return {
426
+ "type": "geogebra",
427
+ "id": (gid or "").strip(),
428
+ "width": attrs.get("w") or attrs.get("width") or "100%",
429
+ "height": attrs.get("h") or attrs.get("height") or "480",
430
+ "border": attrs.get("border") or "0",
431
+ "caption": attrs.get("caption") or "",
432
+ "card": attrs.get("card") or "0",
433
+ }
434
+
435
+
436
+ def parse_icon_marker(s: str) -> dict:
437
+ """解析 <!--icon ...--> 為 icon 節點。"""
438
+ m = _ICON_SINGLE_RE.search(s or "")
439
+ if not m:
440
+ return {"type": "paragraph", "content": s}
441
+ attrs_str = (m.group("attrs") or "").strip()
442
+ attrs = _attrs_to_dict(attrs_str)
443
+ icon = (attrs.get("icon") or attrs.get("name") or "").strip()
444
+ height = (attrs.get("height") or attrs.get("h") or "18").strip()
445
+ if not icon:
446
+ m2 = re.search(r'=\s*"([^"]+)"', attrs_str)
447
+ if m2:
448
+ icon = m2.group(1).strip()
449
+ elif attrs_str:
450
+ icon = attrs_str.strip()
451
+ return {"type": "icon", "icon": icon, "height": height}
452
+
453
+
454
+ def parse_pyfunc_marker(s: str) -> dict:
455
+ m = _PYFUNC_RE.search(s or '')
456
+ if not m:
457
+ return {"type": "paragraph", "content": s}
458
+ attrs = _attrs_to_dict(m.group('attrs') or "")
459
+ kwargs: dict = {}
460
+ if 'params' in attrs:
461
+ try:
462
+ kwargs = json.loads(attrs['params'])
463
+ except Exception:
464
+ kwargs = {}
465
+ for k, v in attrs.items():
466
+ if k not in ('path', 'call', 'params'):
467
+ kwargs.setdefault(k, v)
468
+ return {
469
+ "type": "pyfunc",
470
+ "path": (attrs.get("path") or "").strip(),
471
+ "call": (attrs.get("call") or "make_view").strip(),
472
+ "kwargs": kwargs,
473
+ }
474
+
475
+
476
+ def _parse_color_pair(token: str) -> tuple[str, str]:
477
+ s = (token or "").strip()
478
+ if not s:
479
+ return "", ""
480
+ if "|" in s:
481
+ a, b = s.split("|", 1)
482
+ return _norm_hex(a.strip()), _norm_hex(b.strip())
483
+ c = _norm_hex(s)
484
+ return c, c
485
+
486
+
487
+ def parse_card_block(src: str) -> dict:
488
+ m = _CARD_RE.search(src)
489
+ if not m:
490
+ return {"type": "card", "content": src}
491
+ attrs = _attrs_to_dict(m.group("attrs") or "")
492
+ inner = (m.group("body") or "").strip()
493
+ kind = attrs.get("type") or attrs.get("kind") or ""
494
+ headline = attrs.get("headline") or attrs.get("title") or ""
495
+
496
+ if not (kind and headline):
497
+ lines = inner.splitlines()
498
+ nz = [i for i, ln in enumerate(lines) if ln.strip()]
499
+ if nz:
500
+ if not kind:
501
+ kind = lines[nz[0]].strip(); lines[nz[0]] = ""
502
+ if len(nz) >= 2 and not headline:
503
+ headline = lines[nz[1]].strip(); lines[nz[1]] = ""
504
+ inner = "\n".join(lines).lstrip()
505
+
506
+ bg_l, bg_d = _parse_color_pair(attrs.get("bg") or attrs.get("background") or attrs.get("body_bg") or "")
507
+ fg_l, fg_d = _parse_color_pair(attrs.get("fg") or attrs.get("text") or attrs.get("header_fg") or "")
508
+ accent = (attrs.get("accent") or attrs.get("badge") or attrs.get("palette") or "").strip()
509
+ body_blocks = parse_content_block(inner)
510
+ node = {"type": "card", "kind": kind, "headline": headline, "body": body_blocks}
511
+ if bg_l or bg_d: node["bg_light"], node["bg_dark"] = bg_l, bg_d
512
+ if fg_l or fg_d: node["fg_light"], node["fg_dark"] = fg_l, fg_d
513
+ if accent: node["accent"] = accent
514
+ return node
515
+
516
+
517
+ def _split_mcq_answers(ans_raw: str) -> list[str]:
518
+ raw = (ans_raw or "").strip().lower()
519
+ if not raw:
520
+ return []
521
+ raw = raw.replace(",", ",").replace("、", ",").replace(";", ",").replace("|", ",").replace(" ", ",")
522
+ raw = raw.strip(",")
523
+ if not raw:
524
+ return []
525
+ if re.fullmatch(r"[abcd]+", raw) and "," not in raw:
526
+ tokens = list(raw)
527
+ else:
528
+ tokens = [t for t in re.split(r"[,\s]+", raw) if t]
529
+ idxmap = {"1": "a", "2": "b", "3": "c", "4": "d"}
530
+ out = []
531
+ for t in tokens:
532
+ t = t.strip().lower().rstrip(".")
533
+ t = idxmap.get(t, t)
534
+ if t in ("a", "b", "c", "d") and t not in out:
535
+ out.append(t)
536
+ return out
537
+
538
+
539
+ def parse_interactive_block(src: str) -> dict:
540
+ inner = re.sub(r'^\s*<!--\s*互動\s*-->\s*', '', src, flags=re.S)
541
+ inner = re.sub(r'<!---?\s*互動end\s*-->\s*$', '', inner, flags=re.S)
542
+
543
+ m_fill_zh = re.search(r'<!--\s*填空([^>]*)-->(.*?)<!--\s*填空end\s*-->', inner, re.S)
544
+ m_fill_en = re.search(r'<!--\s*fill([^>]*)-->(.*?)<!--\s*fill(?:_end|end)\s*-->', inner, re.S)
545
+ m_fill = m_fill_en or m_fill_zh
546
+ m_mcq_old = re.search(r'<!--\s*選擇([^>]*)-->(.*?)<!--\s*選擇end\s*-->', inner, re.S)
547
+ m_mcq_new = re.search(r'<!--\s*(?:mcq|choice)([^>]*)-->(.*?)<!--\s*(?:mcq|choice)(?:_end|end)\s*-->', inner, re.S)
548
+ m_mcq = m_mcq_new or m_mcq_old
549
+
550
+ beg = min([m.start() for m in [m_fill, m_mcq] if m] or [len(inner)])
551
+ prompt_md = inner[:beg].rstrip()
552
+ prompt_runs: list[dict] = []
553
+ if prompt_md:
554
+ for part in _BLANKPARA_SPLIT.split(prompt_md):
555
+ if not part: continue
556
+ if _BLANKPARA_SPLIT.fullmatch(part):
557
+ _append_blankline_runs(part, prompt_runs)
558
+ else:
559
+ runs = parse_paragraph_runs_color_aware(part) or []
560
+ for r in runs: r['newline'] = 'false'
561
+ prompt_runs.extend(runs)
562
+
563
+ if m_fill:
564
+ attrs = _attrs_to_dict(m_fill.group(1) or "")
565
+ body = (m_fill.group(2) or "").strip()
566
+ right = attrs.get("right", ""); wrong = attrs.get("wrong", "")
567
+ ans = attrs.get("ans") or (body.splitlines()[0].strip() if body else "")
568
+ answers = [s.strip() for s in ans.split("|") if s.strip()]
569
+ normalize = [s.strip() for s in (attrs.get("normalize") or "sym,numeric,nospace").split(",") if s.strip()]
570
+ if not prompt_runs and body:
571
+ for p_idx, para in enumerate(re.split(r'\n\s*\n', body)):
572
+ runs = parse_paragraph_runs_color_aware(para)
573
+ is_last = (p_idx == len(re.split(r'\n\s*\n', body)) - 1)
574
+ for r in runs: r.setdefault("newline", "false" if is_last else "true")
575
+ prompt_runs.extend(runs)
576
+ return {
577
+ "type": "quiz", "qtype": "fill", "id": attrs.get("id", ""),
578
+ "prompt_runs": prompt_runs, "answers": answers, "normalize": normalize,
579
+ "placeholder": attrs.get("placeholder", ""), "border": attrs.get("border", "1"),
580
+ "inline_math": attrs.get("inline_math", "1"), "right_msg": right, "wrong_msg": wrong,
581
+ "card": attrs.get("card", "1"),
582
+ }
583
+
584
+ if m_mcq:
585
+ attrs = _attrs_to_dict(m_mcq.group(1) or "")
586
+ body = (m_mcq.group(2) or "").strip()
587
+ right = attrs.get("right", ""); wrong = attrs.get("wrong", "")
588
+ hints = {}
589
+ for k, v in attrs.items():
590
+ kk = (k or "").strip().lower()
591
+ if kk.startswith("hint_"):
592
+ opt = kk.split("_", 1)[1].strip().lower()
593
+ if opt in ("a", "b", "c", "d") and str(v).strip():
594
+ hints[opt] = str(v).strip()
595
+ indent_flag = attrs.get("indent") or attrs.get("indent_options")
596
+ lines = body.splitlines()
597
+ lead_lines, opt_lines, seen_option = [], [], False
598
+ for ln in lines:
599
+ t = ln.strip()
600
+ if re.match(r'^[aAdDbBcC]\s*\.', t):
601
+ seen_option = True; opt_lines.append(t)
602
+ else:
603
+ (opt_lines if seen_option else lead_lines).append(ln)
604
+ if not prompt_runs and lead_lines:
605
+ paragraphs = re.split(r'\n\s*\n', "\n".join(lead_lines).rstrip())
606
+ for p_idx, para in enumerate(paragraphs):
607
+ runs = parse_paragraph_runs_color_aware(para)
608
+ is_last = (p_idx == len(paragraphs) - 1)
609
+ for r in runs: r.setdefault("newline", "false" if is_last else "true")
610
+ prompt_runs.extend(runs)
611
+ opts = []
612
+ for t in (opt_lines if opt_lines else lines):
613
+ tt = t.strip()
614
+ for key, pat in [("a", r'^[aA]\s*\.'), ("b", r'^[bB]\s*\.'), ("c", r'^[cC]\s*\.'), ("d", r'^[dD]\s*\.')]:
615
+ if re.match(pat, tt):
616
+ opts.append({"key": key, "text": tt.split('.', 1)[1].strip()})
617
+ multi_flag = attrs.get("multi") or attrs.get("multiple") or attrs.get("checkbox")
618
+ is_multi = str(multi_flag).strip().lower() in ("1", "true", "yes", "y", "on")
619
+ ans_raw = (attrs.get("ans", "") or "").strip().lower().rstrip(".")
620
+ ans_list = _split_mcq_answers(ans_raw)
621
+ ans_one = ans_list[0] if ans_list else ""
622
+ node = {
623
+ "type": "quiz", "qtype": "multi" if is_multi else "choice",
624
+ "id": attrs.get("id", ""), "prompt_runs": prompt_runs, "options": opts,
625
+ "shuffle": attrs.get("shuffle", "0") in ("1", "true", "yes"),
626
+ "explain": attrs.get("explain", ""), "right_msg": right, "wrong_msg": wrong,
627
+ "wrong_hints": hints, "card": attrs.get("card", "1"),
628
+ }
629
+ if is_multi:
630
+ node["answers"] = ans_list; node["answer"] = ""
631
+ else:
632
+ node["answer"] = ans_one
633
+ if indent_flag is not None:
634
+ node["indent_options"] = indent_flag
635
+ return node
636
+
637
+ return {"type": "paragraph", "content": prompt_md, "runs": prompt_runs}
638
+
639
+
640
+ def _next_flow_id() -> str:
641
+ global _FLOW_SEQ
642
+ _FLOW_SEQ += 1
643
+ return f"flow-{_FLOW_SEQ}"
644
+
645
+
646
+ def parse_flow_block(src: str) -> dict:
647
+ m = _FLOW_RE.search(src)
648
+ if not m:
649
+ return {"type": "paragraph", "content": src}
650
+ fattrs = _attrs_to_dict(m.group("attrs") or "")
651
+ body = (m.group("body") or "").strip()
652
+ flow_id = fattrs.get("id") or _next_flow_id()
653
+ persist = (fattrs.get("persist") or "").strip()
654
+
655
+ steps_raw = list(_STEP_RE.finditer(body))
656
+ if not steps_raw:
657
+ steps_raw_iter = [("", body)]
658
+ def _unwrap(x): return x[0], x[1]
659
+ else:
660
+ steps_raw_iter = steps_raw
661
+ def _unwrap(mm): return (mm.group("attrs") or ""), (mm.group("body") or "").strip()
662
+
663
+ steps, quiz_seq, prev_gate_qid = [], 0, None
664
+ for i, raw in enumerate(steps_raw_iter, start=1):
665
+ sattrs_raw, sbody = _unwrap(raw)
666
+ sattrs = _attrs_to_dict(sattrs_raw)
667
+ step_id = sattrs.get("id") or f"step-{i}"
668
+ title = sattrs.get("title") or f"步驟 {i}"
669
+ cond = (sattrs.get("cond") or "all").lower().strip()
670
+ content_blocks = parse_document(sbody)
671
+ first_quiz_id = None
672
+ for b in content_blocks:
673
+ if b.get("type") == "quiz":
674
+ if not b.get("id"):
675
+ quiz_seq += 1
676
+ b["id"] = f"{flow_id}::{step_id}::q{quiz_seq}"
677
+ b["flow"] = flow_id; b["step"] = step_id
678
+ if first_quiz_id is None:
679
+ first_quiz_id = b["id"]
680
+ steps.append({"id": step_id, "title": title, "cond": cond, "content": content_blocks, "gate_from": prev_gate_qid})
681
+ if first_quiz_id is not None:
682
+ prev_gate_qid = first_quiz_id
683
+
684
+ return {"type": "flow", "id": flow_id, "persist": persist, "steps": steps}
685
+
686
+
687
+ def parse_solution_block(src: str) -> dict:
688
+ m_open = _SOL_OPEN.search(src)
689
+ m_close = _SOL_CLOSE.search(src, m_open.end() if m_open else 0)
690
+ if not (m_open and m_close):
691
+ return {"type": "paragraph", "content": src}
692
+
693
+ attrs_raw = (m_open.group("attrs") or "").strip()
694
+ shortcut_label = ""
695
+ attrs_for_dict = attrs_raw
696
+ m_short = re.match(r'^_([^\s]+)(.*)$', attrs_raw)
697
+ if m_short:
698
+ shortcut_label = (m_short.group(1) or "").strip()
699
+ attrs_for_dict = (m_short.group(2) or "").strip()
700
+ attrs = _attrs_to_dict(attrs_for_dict)
701
+ inner = src[m_open.end():m_close.start()]
702
+ body_blocks = [b for b in parse_document(inner) if b.get('type') != 'comment']
703
+
704
+ label = ((attrs.get("label") or shortcut_label or "").strip()) or "看解法"
705
+ default = (attrs.get("default", "closed") or "closed").lower()
706
+ persist = (attrs.get("persist", "none") or "none").lower()
707
+ card = str(attrs.get("card", "0"))
708
+ accent = attrs.get("accent", "")
709
+ after = attrs.get("after", "")
710
+
711
+ h = hashlib.md5()
712
+ for part in [inner, label, default, persist, card, accent, after]:
713
+ h.update((part or "").encode("utf-8"))
714
+ h.update(b"|")
715
+ uid = f"sol_{h.hexdigest()[:10]}"
716
+
717
+ return {
718
+ "type": "solution", "uid": uid, "label": label,
719
+ "default": default, "persist": persist, "card": card,
720
+ "accent": accent, "after": after, "body": body_blocks,
721
+ }
722
+
723
+
724
+ def parse_quizzes(md: str) -> list[dict]:
725
+ """解析 <!--quiz ...--> ... <!--quiz_end--> 區塊(order 題型)。"""
726
+ out, i = [], 0
727
+ while True:
728
+ m = QUIZ_OPEN.search(md, i)
729
+ if not m: break
730
+ n = QUIZ_END.search(md, m.end())
731
+ if not n: break
732
+ block = md[m.end():n.start()]
733
+ attrs = _parse_attrs(m.group('attrs') or "")
734
+ qtype = (attrs.get("qtype") or "").strip().lower() or "multi"
735
+ title = (attrs.get("title") or "練習題").strip()
736
+ card = 1 if str(attrs.get("card", "1")).lower() in ("1", "true", "yes") else 0
737
+ shuffle = str(attrs.get("shuffle", "0")).lower() in ("1", "true", "yes")
738
+ prompt_md = ""
739
+ for ln in block.splitlines():
740
+ s = ln.strip()
741
+ if s and not s.startswith("<!--"):
742
+ prompt_md = s; break
743
+ if qtype == "order":
744
+ steps = [s.strip() for s in STEP_RE_SIMPLE.findall(block)]
745
+ qid = (attrs.get("id") or _slug_ascii(title, "order"))
746
+ out.append({
747
+ "type": "quiz", "qtype": "order", "id": qid,
748
+ "title": title, "card": card, "shuffle": shuffle,
749
+ "prompt_md": prompt_md, "steps": steps, "answer": list(range(len(steps))),
750
+ "msg_right": attrs.get("msg_right") or "✔ 正確!",
751
+ "msg_wrong": attrs.get("msg_wrong") or "✘ 再想想。",
752
+ })
753
+ i = n.end()
754
+ return out
755
+
756
+ # ---------------------------------------------------------------------------
757
+ # 段落 block 解析(核心)
758
+ # ---------------------------------------------------------------------------
759
+
760
+ def parse_content_block(text: str) -> list[dict]:
761
+ return _parse_plain_blocks(text)
762
+
763
+
764
+ def _parse_plain_blocks(text: str) -> list[dict]:
765
+ parsed_content: list[dict] = []
766
+ pattern = r'(^\s*#+.*?\n|\n{2,}|^\s*[-_]{3,}\s*$)'
767
+ blocks = re.split(pattern, text, flags=re.MULTILINE | re.DOTALL)
768
+
769
+ for block_text in blocks:
770
+ m_blank = re.fullmatch(r'\s*\n{2,}\s*', block_text, flags=re.DOTALL)
771
+ if m_blank:
772
+ for _ in range(max(1, block_text.count('\n') - 1)):
773
+ _emit_blank_paragraph(parsed_content)
774
+ continue
775
+ if not block_text:
776
+ continue
777
+
778
+ heading_match = re.match(r'^\s*#+', block_text)
779
+ if heading_match:
780
+ level = len(heading_match.group(0).strip())
781
+ has_newline = block_text.endswith('\n')
782
+ clean_content = re.sub(r'^\s*#+\s*', '', block_text).strip()
783
+ runs = build_inline_runs(clean_content)
784
+ # 標題預設套 teal
785
+ if not any(('color' in r) or ('color_light' in r) or ('color_dark' in r) for r in runs):
786
+ for r in runs:
787
+ r['color'] = 'teal'; r['color_light'] = 'teal'; r['color_dark'] = "#49F0C3"
788
+ attach_theme_colors(r)
789
+ node = {
790
+ 'type': 'heading', 'level': level,
791
+ 'content': _plain_text_from_runs(runs),
792
+ 'newline': 'true' if has_newline else 'false', 'runs': runs,
793
+ }
794
+ if len(runs) == 1:
795
+ if 'color' in runs[0]: node['color'] = runs[0]['color']
796
+ if 'style' in runs[0]: node['style'] = runs[0]['style']
797
+ parsed_content.append(node)
798
+ continue
799
+
800
+ elif re.match(r'^\s*([*-]|\d+\.)\s+', block_text):
801
+ parsed_content.append({'type': 'list', 'content': parse_list_items(block_text)})
802
+
803
+ elif re.fullmatch(r'^\s*([-]{3,}|[_-]{3,})\s*$', block_text, re.DOTALL):
804
+ parsed_content.append({'type': 'horizontal_rule', 'content': block_text.strip()})
805
+
806
+ else:
807
+ ITEM_LINE = re.compile(r'(?m)^\s*(?:[*-]|\d+\.)\s+')
808
+ first_item = ITEM_LINE.search(block_text)
809
+ if first_item and len(ITEM_LINE.findall(block_text)) >= 2:
810
+ preface = block_text[:first_item.start()]
811
+ rest = block_text[first_item.start():]
812
+ m_end = re.search(r'\n\s*\n(?!\s*(?:[*-]|\d+\.)\s+)', rest)
813
+ if m_end:
814
+ list_src = rest[:m_end.start()]; tail = rest[m_end.end():]
815
+ else:
816
+ list_src = rest; tail = ""
817
+ if preface.strip():
818
+ runs = parse_paragraph_runs_color_aware(preface)
819
+ parsed_content.append({
820
+ 'type': 'paragraph', 'content': _plain_text_from_runs(runs),
821
+ 'newline': 'true' if _has_newline_token(runs) else 'false', 'runs': runs,
822
+ })
823
+ parsed_content.append({'type': 'list', 'content': parse_list_items(list_src)})
824
+ if tail.strip():
825
+ parsed_content.extend(_parse_plain_blocks(tail))
826
+ continue
827
+
828
+ para_text = block_text
829
+ m_file_only = _FILE_MARKER_RE.fullmatch(para_text.strip())
830
+ if m_file_only:
831
+ parsed_content.append({"type": "file_embed", "relpath": (m_file_only.group("path") or "").strip()})
832
+ continue
833
+
834
+ nodes = _split_text_links_images(para_text)
835
+ buf_runs: list[dict] = []
836
+
837
+ def flush_buf():
838
+ nonlocal buf_runs
839
+ if buf_runs:
840
+ parsed_content.append({
841
+ 'type': 'paragraph',
842
+ 'content': _plain_text_from_runs(buf_runs),
843
+ 'newline': 'true' if _has_newline_token(buf_runs) else 'false',
844
+ 'runs': buf_runs,
845
+ })
846
+ buf_runs = []
847
+
848
+ for node in nodes:
849
+ if node["type"] == "text":
850
+ for part in _split_text_and_ggb(node["content"]):
851
+ if part["kind"] == "ggb":
852
+ flush_buf()
853
+ parsed_content.append(_ggb_node_from_attrs(part["attrs"]))
854
+ else:
855
+ buf_runs.extend(parse_paragraph_runs_color_aware(part["text"]))
856
+ elif node["type"] == "URL":
857
+ flush_buf()
858
+ parsed_content.append({'type': 'URL', 'text': node['text'], 'href': node['href']})
859
+ elif node["type"] == "picture":
860
+ flush_buf()
861
+ parsed_content.append({'type': 'picture', 'alt': node['alt'], 'src': node['src']})
862
+ flush_buf()
863
+
864
+ return parsed_content
865
+
866
+ # ---------------------------------------------------------------------------
867
+ # 文件級解析
868
+ # ---------------------------------------------------------------------------
869
+
870
+ def parse_document(entire_markdown_text: str, *, root_md: str = "") -> list[dict]:
871
+ """
872
+ 最頂層解析器:識別所有特殊區塊(card/solution/quiz/flow/ggb...),
873
+ 其餘文字交給 parse_content_block。
874
+
875
+ root_md:整份原始 MD(供 AI prompt 用);若空則用 entire_markdown_text。
876
+ """
877
+ _full_md = root_md or _ROOT_MD or entire_markdown_text
878
+
879
+ parsed_results: list[dict] = []
880
+ last_match_end = 0
881
+
882
+ pattern = re.compile(
883
+ r'<!--\s*flow[^>]*-->.*?<!--\s*flow_end\s*-->|'
884
+ r'<!--\s*quiz[^>]*-->.*?<!--\s*quiz_end\s*-->|'
885
+ r'<!--\s*card[^>]*-->.*?<!--\s*card_end\s*-->|'
886
+ r'<!--\s*solution[^>]*-->.*?<!--\s*solution_end\s*-->|'
887
+ r'<!--\s*pyfunc[^>]*-->|'
888
+ r'<!--CH\d+-P\d+-->|'
889
+ r'<!--\s*互動\s*-->.*?<!---?\s*互動end\s*-->|'
890
+ r'<!--\s*選擇[^>]*-->.*?<!--\s*選擇end\s*-->|'
891
+ r'<!--\s*(?:mcq|choice)[^>]*-->.*?<!--\s*(?:mcq|choice)(?:_end|end)\s*-->|'
892
+ r'<!--\s*填空[^>]*-->.*?<!--\s*填空end\s*-->|'
893
+ r'<!--\s*fill[^>]*-->.*?<!--\s*fill(?:_end|end)\s*-->|'
894
+ r'<!--\s*problem[^>]*-->|'
895
+ r'^\s*<!--\s*icon[^>]*-->\s*$|'
896
+ r'<!--\s*ggb[^>]*-->',
897
+ re.DOTALL,
898
+ )
899
+
900
+ for match in re.finditer(pattern, entire_markdown_text):
901
+ start, end = match.span()
902
+ pre_text = entire_markdown_text[last_match_end:start]
903
+ if pre_text:
904
+ parsed_results.extend(parse_content_block(pre_text))
905
+ content = match.group(0)
906
+
907
+ if content.lstrip().startswith('<!--card'):
908
+ parsed_results.append(parse_card_block(content))
909
+ elif content.lstrip().startswith('<!--solution'):
910
+ sol_node = parse_solution_block(content)
911
+ if isinstance(sol_node, dict) and sol_node.get("type") == "solution":
912
+ ai_md = build_ai_prompt_for_solution_pos(_full_md, start)
913
+ if ai_md:
914
+ sol_node["ai_prompt_md"] = ai_md
915
+ parsed_results.append(sol_node)
916
+ elif content.lstrip().startswith('<!--CH'):
917
+ parsed_results.append({'type': 'comment', 'content': content})
918
+ elif content.lstrip().startswith('<!--problem'):
919
+ parsed_results.append({'type': 'comment', 'content': content})
920
+ elif content.lstrip().startswith('<!--pyfunc'):
921
+ parsed_results.append(parse_pyfunc_marker(content))
922
+ elif re.match(r'^\s*<!--\s*(互動|選擇|填空|fill|choice|mcq)\b', content):
923
+ quiz_node = parse_interactive_block(content)
924
+ if isinstance(quiz_node, dict) and quiz_node.get("type") == "quiz":
925
+ ai_md = build_ai_prompt_for_pos(_full_md, start)
926
+ if ai_md:
927
+ quiz_node["ai_prompt_md"] = ai_md
928
+ if not quiz_node.get('prompt_runs') and parsed_results:
929
+ i = len(parsed_results) - 1
930
+ while i >= 0 and parsed_results[i].get('type') == 'br':
931
+ i -= 1
932
+ if i >= 0 and parsed_results[i].get('type') == 'paragraph':
933
+ prev = parsed_results[i]
934
+ runs = prev.get('runs') or build_inline_runs(prev.get('content', ''))
935
+ if prev.get('problem_tag'):
936
+ quiz_node['prompt_runs'] = runs
937
+ else:
938
+ prev = parsed_results.pop(i)
939
+ quiz_node['prompt_runs'] = prev.get('runs') or build_inline_runs(prev.get('content', ''))
940
+ parsed_results.append(quiz_node)
941
+ elif content.lstrip().startswith('<!--quiz'):
942
+ parsed_results.extend(parse_quizzes(content))
943
+ elif content.lstrip().startswith('<!--flow'):
944
+ parsed_results.append(parse_flow_block(content))
945
+ elif content.lstrip().startswith('<!--icon'):
946
+ parsed_results.append(parse_icon_marker(content))
947
+ elif content.lstrip().startswith('<!--ggb'):
948
+ parsed_results.append(parse_geogebra_marker(content))
949
+ else:
950
+ parsed_results.append({'type': 'unknown', 'content': content})
951
+
952
+ last_match_end = end
953
+
954
+ tail_text = entire_markdown_text[last_match_end:]
955
+ if tail_text:
956
+ parsed_results.extend(parse_content_block(tail_text))
957
+
958
+ # 補 Example heading 的 example_seq + ai_prompt_md
959
+ example_seq = 0
960
+ for node in parsed_results:
961
+ if node.get("type") != "heading":
962
+ continue
963
+ level = int(node.get("level", 0) or 0)
964
+ txt = (node.get("content") or "").strip()
965
+ if level == 3 and txt.lower().startswith("example"):
966
+ example_seq += 1
967
+ node["example_seq"] = example_seq
968
+ node["ai_prompt_md"] = build_ai_prompt_for_example(_full_md, example_seq)
969
+
970
+ return parsed_results
layout/converter/md_color.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ md_color.py
3
+ -----------
4
+ 顏色相關工具:CSS 命名色 → HEX、HEX ↔ HSL、亮暗色對計算。
5
+
6
+ 公開 API:
7
+ attach_theme_colors(run) ← 主要對外接口,補齊 color_light / color_dark
8
+ """
9
+ from __future__ import annotations
10
+ import re
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # 顏色標記 Regex(供 md_runs.py 使用)
14
+ # ---------------------------------------------------------------------------
15
+ COLOR_OPEN = re.compile(r'<!--c_([A-Za-z0-9#]+)-->')
16
+ COLOR_CLOSE_ANY = re.compile(r'<!--c_([A-Za-z0-9#]+)_end-->')
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # CSS 命名色表
20
+ # ---------------------------------------------------------------------------
21
+ _CSS_NAMED_TO_HEX: dict[str, str] = {
22
+ "black": "#000000", "white": "#ffffff", "gray": "#808080", "grey": "#808080",
23
+ "silver": "#c0c0c0", "red": "#ff0000", "green": "#008000", "blue": "#0000ff",
24
+ "yellow": "#ffff00", "orange": "#ffa500", "brown": "#a52a2a", "purple": "#800080",
25
+ "violet": "#8a2be2", "indigo": "#4b0082", "pink": "#ffc0cb", "magenta": "#ff00ff",
26
+ "cyan": "#00ffff", "teal": "#008080", "lime": "#00ff00", "gold": "#ffd700",
27
+ }
28
+
29
+ # 特殊顏色例外對照(不走一般 HSL 公式)
30
+ _COLOR_PAIR_TABLE: dict[str, tuple[str, str]] = {
31
+ "#0000ff": ("#A6B7FF", "#4DFFFF"),
32
+ "#4dffff": ("#4DFFFF", "#003B66"),
33
+ }
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # HEX 正規化
37
+ # ---------------------------------------------------------------------------
38
+ def _norm_hex(c: str) -> str:
39
+ """把 CSS 顏色值統一成 7 位 #rrggbb 格式;命名色也轉換。"""
40
+ if not isinstance(c, str):
41
+ return ""
42
+ s = c.strip()
43
+ if not s:
44
+ return ""
45
+ if s.startswith("#"):
46
+ if len(s) == 4: # #rgb → #rrggbb
47
+ return "#" + "".join(ch * 2 for ch in s[1:])
48
+ return s[:7] if len(s) >= 7 else s
49
+ lo = s.lower()
50
+ if lo in _CSS_NAMED_TO_HEX:
51
+ return _CSS_NAMED_TO_HEX[lo]
52
+ return s
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # HEX ↔ HSL
56
+ # ---------------------------------------------------------------------------
57
+ def _hex_to_hsl(hx: str) -> tuple[float, float, float]:
58
+ hx = _norm_hex(hx)
59
+ if not (hx.startswith("#") and len(hx) == 7):
60
+ return (0.0, 0.0, 0.0)
61
+ r = int(hx[1:3], 16) / 255
62
+ g = int(hx[3:5], 16) / 255
63
+ b = int(hx[5:7], 16) / 255
64
+ mx, mn = max(r, g, b), min(r, g, b)
65
+ l = (mx + mn) / 2
66
+ if mx == mn:
67
+ return (0.0, 0.0, l)
68
+ d = mx - mn
69
+ s = d / (2 - mx - mn) if l > 0.5 else d / (mx + mn)
70
+ if mx == r:
71
+ h = (g - b) / d + (6 if g < b else 0)
72
+ elif mx == g:
73
+ h = (b - r) / d + 2
74
+ else:
75
+ h = (r - g) / d + 4
76
+ return (h / 6, s, l)
77
+
78
+
79
+ def _hsl_to_hex(h: float, s: float, l: float) -> str:
80
+ def _f(p: float, q: float, t: float) -> float:
81
+ if t < 0: t += 1
82
+ if t > 1: t -= 1
83
+ if t < 1 / 6: return p + (q - p) * 6 * t
84
+ if t < 1 / 2: return q
85
+ if t < 2 / 3: return p + (q - p) * (2 / 3 - t) * 6
86
+ return p
87
+
88
+ if s == 0:
89
+ r = g = b = l
90
+ else:
91
+ q = l + s - l * s if l >= 0.5 else l * (1 + s)
92
+ p = 2 * l - q
93
+ r = _f(p, q, h + 1 / 3)
94
+ g = _f(p, q, h)
95
+ b = _f(p, q, h - 1 / 3)
96
+ return "#{:02x}{:02x}{:02x}".format(int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5))
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # 亮 / 暗色計算
100
+ # ---------------------------------------------------------------------------
101
+ def _to_dark_color(hex_light: str) -> str:
102
+ """把亮色調暗,作為 dark mode 用色。"""
103
+ hx_low = _norm_hex(hex_light).lower()
104
+ if hx_low in _COLOR_PAIR_TABLE:
105
+ _, dark_hint = _COLOR_PAIR_TABLE[hx_low]
106
+ if dark_hint:
107
+ return _norm_hex(dark_hint)
108
+ h, s, l = _hex_to_hsl(hex_light)
109
+ if l > 0.7:
110
+ l2 = 0.50
111
+ elif l > 0.55:
112
+ l2 = 0.46 + (l - 0.55) * 0.10
113
+ else:
114
+ l2 = max(0.42, l * 0.75 + 0.10)
115
+ return _hsl_to_hex(h, max(0.10, s * 0.80), l2)
116
+
117
+
118
+ def _to_light_color(hex_dark: str) -> str:
119
+ """把暗色調亮,作為 light mode 用色。"""
120
+ h, s, l = _hex_to_hsl(hex_dark)
121
+ if l < 0.35:
122
+ l2 = min(0.82, l * 0.3 + 0.65)
123
+ s2 = max(0.10, s * 0.75)
124
+ else:
125
+ l2 = min(0.86, 1 - (1 - l) * 0.5)
126
+ s2 = max(0.10, s * 0.80)
127
+ return _hsl_to_hex(h, s2, l2)
128
+
129
+
130
+ def _is_named_color(tok: str) -> bool:
131
+ return isinstance(tok, str) and tok.strip().lower() in _CSS_NAMED_TO_HEX
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # 主要對外 API
135
+ # ---------------------------------------------------------------------------
136
+ def attach_theme_colors(run: dict) -> None:
137
+ """
138
+ 根據 run 裡的 color / color_light / color_dark,
139
+ 補齊 color_light 與 color_dark 兩個欄位(就地修改)。
140
+ """
141
+ if run.get("color_light") and run.get("color_dark"):
142
+ return
143
+ if run.get("color_light") and not run.get("color_dark"):
144
+ run["color_dark"] = _to_dark_color(run["color_light"])
145
+ return
146
+ if run.get("color_dark") and not run.get("color_light"):
147
+ run["color_light"] = _to_light_color(run["color_dark"])
148
+ return
149
+
150
+ col = run.get("color")
151
+ if not col:
152
+ return
153
+
154
+ if _is_named_color(col):
155
+ run["color_light"] = col
156
+ run["color_dark"] = _to_dark_color(_norm_hex(col))
157
+ return
158
+
159
+ c = _norm_hex(col)
160
+ _, _, L = _hex_to_hsl(c)
161
+ if L >= 0.5:
162
+ run["color_light"] = col
163
+ run["color_dark"] = _to_dark_color(c)
164
+ else:
165
+ run["color_dark"] = col
166
+ run["color_light"] = _to_light_color(c)
layout/converter/md_io.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ md_io.py
3
+ --------
4
+ 檔案讀寫與 build pipeline(最頂層,依賴全部其他模組)。
5
+
6
+ 公開 API:
7
+ build_from_config(config_path, force=False) → List[Path]
8
+ """
9
+ from __future__ import annotations
10
+ from pathlib import Path
11
+ from typing import List
12
+ import json
13
+
14
+ from . import md_blocks # 注入 _ROOT_MD 用
15
+ from .md_blocks import (
16
+ parse_document,
17
+ assign_ids_and_build_toc,
18
+ split_blocks_into_sections,
19
+ )
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # 副檔名 → 語言對照
23
+ # ---------------------------------------------------------------------------
24
+ _EXT2LANG: dict[str, str] = {
25
+ ".py": "python", ".js": "javascript", ".ts": "typescript",
26
+ ".css": "css", ".html": "markup", ".md": "markdown",
27
+ ".json": "json", ".yml": "yaml", ".yaml": "yaml",
28
+ ".tex": "latex", ".r": "r", ".cpp": "cpp", ".c": "c",
29
+ }
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # 檔案嵌入工具
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def _parse_file_spec(spec: str) -> tuple[str, str | None]:
36
+ spec = (spec or "").strip()
37
+ if "::" in spec:
38
+ p, f = spec.split("::", 1)
39
+ return p.strip(), (f.strip() or None)
40
+ return spec, None
41
+
42
+
43
+ def _split_md_by_file_markers(md_text: str) -> list[tuple[str, str]]:
44
+ import re
45
+ FILE_MARKER_RE = re.compile(
46
+ r'<!--\s*file(?:_|[\s]+)(?P<path>[^>\r\n]+?)\s*-->', re.IGNORECASE
47
+ )
48
+ segs, pos = [], 0
49
+ for m in FILE_MARKER_RE.finditer(md_text or ""):
50
+ if m.start() > pos:
51
+ segs.append(("text", md_text[pos:m.start()]))
52
+ gd = m.groupdict() if hasattr(m, "groupdict") else {}
53
+ file_token = (gd.get("path") or "").strip()
54
+ if not file_token:
55
+ file_token = md_text[m.start():m.end()]
56
+ segs.append(("file", file_token))
57
+ pos = m.end()
58
+ if pos < len(md_text or ""):
59
+ segs.append(("text", md_text[pos:]))
60
+ return segs
61
+
62
+
63
+ def _resolve_code_file(md_dir: Path, file_token: str) -> Path | None:
64
+ p = Path(file_token)
65
+ if not p.is_absolute():
66
+ p = (md_dir / file_token).resolve()
67
+ if not p.exists():
68
+ alt = (Path.cwd() / "assets" / "files" / file_token).resolve()
69
+ if alt.exists():
70
+ p = alt
71
+ return p if p.exists() and p.is_file() else None
72
+
73
+
74
+ def _read_code_for_embed(p: Path, max_bytes: int = 300_000) -> tuple[str, str]:
75
+ ext = p.suffix.lower()
76
+ lang = _EXT2LANG.get(ext, "text")
77
+ data = p.read_bytes()
78
+ note = ""
79
+ if len(data) > max_bytes:
80
+ data = data[:max_bytes]
81
+ note = f"\n\n# [truncated] file too large; showing first {max_bytes} bytes\n"
82
+ code = data.decode("utf-8", errors="replace") + note
83
+ return code, lang
84
+
85
+
86
+ def _materialize_file_embeds(nodes: list[dict], base_dir: Path) -> list[dict]:
87
+ out: list[dict] = []
88
+ for n in nodes:
89
+ if not isinstance(n, dict):
90
+ out.append(n); continue
91
+ if n.get("type") == "file_embed":
92
+ file_token = (n.get("relpath") or "").strip()
93
+ p = _resolve_code_file(base_dir, file_token)
94
+ if not p:
95
+ out.append({"type": "code", "filename": file_token, "lang": "text",
96
+ "code": f"# [missing] cannot locate file: {file_token}"})
97
+ else:
98
+ code, lang = _read_code_for_embed(p)
99
+ out.append({"type": "code", "filename": p.name, "path": str(p), "lang": lang, "code": code})
100
+ elif n.get("type") == "card":
101
+ n["body"] = _materialize_file_embeds(n.get("body") or [], base_dir)
102
+ out.append(n)
103
+ elif n.get("type") == "flow":
104
+ steps = []
105
+ for s in n.get("steps") or []:
106
+ s = dict(s)
107
+ s["content"] = _materialize_file_embeds(s.get("content") or [], base_dir)
108
+ steps.append(s)
109
+ n["steps"] = steps
110
+ out.append(n)
111
+ elif n.get("type") == "solution":
112
+ n["body"] = _materialize_file_embeds(n.get("body") or [], base_dir)
113
+ out.append(n)
114
+ else:
115
+ out.append(n)
116
+ return out
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # 單檔轉換
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def _export_one(md_path: Path, out_path: Path) -> Path:
123
+ text = md_path.read_text(encoding="utf-8")
124
+ md_dir = md_path.parent
125
+
126
+ md_blocks._ROOT_MD = text
127
+
128
+ segments = _split_md_by_file_markers(text)
129
+ blocks: list[dict] = []
130
+
131
+ for kind, payload in segments:
132
+ if kind == "text":
133
+ if payload.strip():
134
+ blocks.extend(parse_document(payload, root_md=text))
135
+ else:
136
+ file_token, func = _parse_file_spec(payload)
137
+ p = _resolve_code_file(md_dir, file_token)
138
+ if not p:
139
+ blocks.append({"type": "code", "filename": file_token, "lang": "text",
140
+ "code": f"# [missing] cannot locate file: {file_token}"})
141
+ else:
142
+ code, lang = _read_code_for_embed(p)
143
+ if func:
144
+ blocks.append({"type": "pycall", "filename": p.name, "path": str(p),
145
+ "func": func, "lang": lang, "code": code})
146
+ else:
147
+ blocks.append({"type": "code", "filename": p.name, "path": str(p),
148
+ "lang": lang, "code": code})
149
+
150
+ blocks = _materialize_file_embeds(blocks, md_dir)
151
+ toc = assign_ids_and_build_toc(blocks)
152
+ sections = split_blocks_into_sections(blocks)
153
+
154
+ payload = {"toc": toc, "blocks": blocks, "sections": sections}
155
+ out_path.parent.mkdir(parents=True, exist_ok=True)
156
+ out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=4), encoding="utf-8")
157
+ return out_path
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Build pipeline(對外 API)
161
+ # ---------------------------------------------------------------------------
162
+
163
+ def build_from_config(
164
+ config_path: str | Path,
165
+ force: bool = False,
166
+ ) -> List[Path]:
167
+ cfgp = Path(config_path).resolve()
168
+ base = cfgp.parent
169
+ cfg = json.loads(cfgp.read_text(encoding="utf-8"))
170
+
171
+ up_to_date = False if force else bool(cfg.get("up_to_date", True))
172
+ outputs: List[Path] = []
173
+
174
+ for t in cfg.get("tasks", []):
175
+ md = (base / t["md"]).resolve()
176
+ out = (base / t["out"]).resolve()
177
+
178
+ if not md.exists():
179
+ raise FileNotFoundError(f"[build] Markdown not found: {md}")
180
+
181
+ need = True
182
+ if not force and up_to_date and out.exists():
183
+ need = md.stat().st_mtime > out.stat().st_mtime
184
+
185
+ if need or not out.exists():
186
+ p = _export_one(md, out)
187
+ print(f"[build] wrote: {p}")
188
+ else:
189
+ print(f"[build] up-to-date: {out}")
190
+ p = out
191
+
192
+ outputs.append(p)
193
+
194
+ return outputs
195
+
196
+
197
+ if __name__ == "__main__":
198
+ import sys
199
+ if len(sys.argv) >= 2:
200
+ build_from_config(sys.argv[1])
201
+ else:
202
+ print("Usage: python md_io.py <config.json>")
layout/converter/md_pipeline.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test2ChMd.py ← 向下相容 shim
3
+ -------------------------------
4
+ 原始 2097 行已拆分至以下六個模組(均放在同一 layout/ 資料夾下):
5
+
6
+ md_color.py 顏色工具
7
+ md_tex.py TeX 分隔符
8
+ md_attrs.py 屬性解析
9
+ md_runs.py 行內 run 組裝
10
+ md_blocks.py block / 文件解析
11
+ md_io.py I/O + build pipeline
12
+
13
+ 此 shim 重新匯出所有公開 API,確保現有程式碼不需要修改:
14
+
15
+ from layout.Test2ChMd import build_from_config ← 仍可用
16
+ from layout.Test2ChMd import parse_document ← 仍可用
17
+ """
18
+
19
+ # ── Layer 0:顏色 ──────────────────────────────────────────────────────────
20
+ from .md_color import ( # noqa: F401
21
+ attach_theme_colors,
22
+ _norm_hex,
23
+ _hex_to_hsl, _hsl_to_hex,
24
+ _to_dark_color, _to_light_color,
25
+ _is_named_color,
26
+ _CSS_NAMED_TO_HEX,
27
+ _COLOR_PAIR_TABLE,
28
+ )
29
+
30
+ # ── Layer 0:TeX ──────────────────────────────────────────────────────────
31
+ from .md_tex import ( # noqa: F401
32
+ _strip_outer_tex_delims,
33
+ _ensure_inline_dollars,
34
+ _ensure_block_dollars,
35
+ )
36
+
37
+ # ── Layer 0:屬性解析 ──────────────────────────────────────────────────────
38
+ from .md_attrs import ( # noqa: F401
39
+ _attrs_to_dict,
40
+ _parse_attrs,
41
+ _slug_ascii,
42
+ )
43
+
44
+ # ── Layer 1-2:行內 run ────────────────────────────────────────────────────
45
+ from .md_runs import ( # noqa: F401
46
+ split_color_runs,
47
+ split_math_runs,
48
+ split_emphasis_runs,
49
+ normalize_runs,
50
+ build_inline_runs,
51
+ emit_text_runs_color_aware,
52
+ _has_newline_token,
53
+ _plain_text_from_runs,
54
+ _append_hard_break,
55
+ _materialize_hard_break_tokens,
56
+ _split_text_and_any_display_math,
57
+ _split_text_and_icon,
58
+ _split_text_links_images,
59
+ _split_text_and_ggb,
60
+ )
61
+
62
+ # ── Layer 2-5:block / 文件解析 ────────────────────────────────────────────
63
+ from .md_blocks import ( # noqa: F401
64
+ # 段落 / 清單
65
+ parse_line_color_aware,
66
+ parse_paragraph_runs_color_aware,
67
+ parse_list_items,
68
+ _emit_blank_paragraph,
69
+ _append_blankline_runs,
70
+ _split_midline_new_item,
71
+ # 特殊 marker
72
+ parse_geogebra_marker,
73
+ parse_icon_marker,
74
+ parse_pyfunc_marker,
75
+ parse_card_block,
76
+ parse_interactive_block,
77
+ parse_flow_block,
78
+ parse_solution_block,
79
+ parse_quizzes,
80
+ # 頂層
81
+ parse_content_block,
82
+ parse_document,
83
+ # TOC / sections
84
+ assign_ids_and_build_toc,
85
+ split_blocks_into_sections,
86
+ # AI prompt
87
+ build_ai_prompt_for_example,
88
+ build_ai_prompt_for_pos,
89
+ build_ai_prompt_for_solution_pos,
90
+ )
91
+
92
+ # ── Layer 6:I/O ───────────────────────────────────────────────────────────
93
+ from .md_io import ( # noqa: F401
94
+ build_from_config,
95
+ _export_one,
96
+ _parse_file_spec,
97
+ _split_md_by_file_markers,
98
+ _resolve_code_file,
99
+ _read_code_for_embed,
100
+ _EXT2LANG,
101
+ )
layout/converter/md_runs.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ md_runs.py
3
+ ----------
4
+ 行內 run(inline run)的切分、組裝與正規化。
5
+
6
+ 依賴:md_color, md_tex, md_attrs(相對 import)
7
+
8
+ 公開 API:
9
+ build_inline_runs(text) → List[run] 主要入口
10
+ emit_text_runs_color_aware(text, cs) → List[run]
11
+ normalize_runs(runs) → List[run]
12
+ split_color_runs(text) → List[run]
13
+ split_math_runs(text) → List[run]
14
+ split_emphasis_runs(text) → List[run]
15
+ _has_newline_token(runs) → bool
16
+ _plain_text_from_runs(runs) → str
17
+ _append_hard_break(runs) → None
18
+ _materialize_hard_break_tokens(runs) → runs
19
+ _split_text_and_any_display_math(s) → List[dict]
20
+ _split_text_and_icon(text) → List[tuple]
21
+ _split_text_links_images(line) → List[dict]
22
+ _split_text_and_ggb(s) → List[dict]
23
+ """
24
+ from __future__ import annotations
25
+ import re
26
+
27
+ from .md_color import attach_theme_colors, COLOR_OPEN, COLOR_CLOSE_ANY
28
+ from .md_tex import _ensure_inline_dollars, _ensure_block_dollars
29
+ from .md_attrs import _attrs_to_dict
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # 正規表達式
33
+ # ---------------------------------------------------------------------------
34
+ _SPLIT_DOLLAR2 = re.compile(r'(\$\$.*?\$\$)', re.S)
35
+ _WRAP_DOLLAR2 = re.compile(r'^\s*\$\$(.*?)\$\$\s*$', re.S)
36
+
37
+ _GGB_ANY_RE = re.compile(r'<!--\s*ggb(?P<attrs>[^>]*)-->')
38
+ _ICON_INLINE_RE = re.compile(r'<!--\s*icon(?P<attrs>[^>]*)-->', re.I)
39
+
40
+ _IMG_RE = re.compile(r'!\[([^\]]*)\]\(([^)\s]+)\)')
41
+ _LINK_RE = re.compile(r'(?<!!)\[([^\]]+)\]\(([^)\s]+)\)')
42
+
43
+ _MATH_INLINE_RE = re.compile(
44
+ r'(?<!\\)(?<!\$)\$(?!\$)(.+?)(?<!\\)(?<!\$)\$(?!\$)'
45
+ r'|(\\dfrac\{[^{}]+\}\{[^{}]+\})',
46
+ re.DOTALL,
47
+ )
48
+ _EMPH_RE = re.compile(
49
+ r'(\*{3}[^\n*]+?\*{3}|\*{2}[^\n*]+?\*{2}|\*[^\n*]+?\*)'
50
+ )
51
+ _COLOR_TAG = re.compile(r'<!--c_([A-Za-z0-9#]+)-->|\s*<!--c_([A-Za-z0-9#]+)_end-->')
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # 文字切分工具
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def _split_text_and_any_display_math(s: str) -> list[dict]:
58
+ parts = _SPLIT_DOLLAR2.split(s or "")
59
+ out = []
60
+ for p in parts:
61
+ if not p: continue
62
+ m = _WRAP_DOLLAR2.match(p)
63
+ if m:
64
+ out.append({"kind": "math_block", "latex": _ensure_block_dollars(m.group(1).strip())})
65
+ else:
66
+ out.append({"kind": "text", "text": p})
67
+ return out
68
+
69
+
70
+ def _split_text_and_icon(text: str) -> list[tuple[str, str]]:
71
+ parts, pos = [], 0
72
+ for m in _ICON_INLINE_RE.finditer(text):
73
+ if m.start() > pos:
74
+ parts.append(("text", text[pos:m.start()]))
75
+ parts.append(("icon", m.group(0)))
76
+ pos = m.end()
77
+ if pos < len(text):
78
+ parts.append(("text", text[pos:]))
79
+ return parts
80
+
81
+
82
+ def _split_text_links_images(line: str) -> list[dict]:
83
+ nodes, i = [], 0
84
+ img_matches = list(_IMG_RE.finditer(line))
85
+ img_spans = [(m.start(), m.end()) for m in img_matches]
86
+ matches = [("img", m.start(), m.end(), m) for m in img_matches]
87
+ for m in _LINK_RE.finditer(line):
88
+ st, ed = m.start(), m.end()
89
+ if any(st >= a and ed <= b for a, b in img_spans):
90
+ continue
91
+ matches.append(("a", st, ed, m))
92
+ if not matches:
93
+ return [{"type": "text", "content": line}]
94
+ matches.sort(key=lambda x: x[1])
95
+ for kind, start, end, m in matches:
96
+ if start > i:
97
+ chunk = line[i:start]
98
+ if chunk:
99
+ nodes.append({"type": "text", "content": chunk})
100
+ if kind == "img":
101
+ nodes.append({"type": "picture", "alt": m.group(1).strip(), "src": m.group(2).strip()})
102
+ else:
103
+ nodes.append({"type": "URL", "text": m.group(1).strip(), "href": m.group(2).strip()})
104
+ i = end
105
+ if i < len(line):
106
+ tail = line[i:]
107
+ if tail:
108
+ nodes.append({"type": "text", "content": tail})
109
+ return nodes
110
+
111
+
112
+ def _split_text_and_ggb(s: str) -> list[dict]:
113
+ out, i = [], 0
114
+ for m in _GGB_ANY_RE.finditer(s or ""):
115
+ if m.start() > i:
116
+ out.append({"kind": "text", "text": s[i:m.start()]})
117
+ attrs = _attrs_to_dict(m.group("attrs") or "")
118
+ out.append({"kind": "ggb", "attrs": attrs})
119
+ i = m.end()
120
+ if i < len(s or ""):
121
+ out.append({"kind": "text", "text": s[i:]})
122
+ return out
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Run 切分器
126
+ # ---------------------------------------------------------------------------
127
+
128
+ def split_color_runs(text: str) -> list[dict]:
129
+ runs, i = [], 0
130
+ while True:
131
+ m_open = COLOR_OPEN.search(text, i)
132
+ if not m_open:
133
+ if i < len(text):
134
+ runs.append({"text": text[i:]})
135
+ break
136
+ if m_open.start() > i:
137
+ runs.append({"text": text[i:m_open.start()]})
138
+ name = m_open.group(1)
139
+ m_close = re.compile(rf'<!--c_{re.escape(name)}_end-->').search(text, m_open.end())
140
+ if not m_close:
141
+ runs.append({"text": text[m_open.start():]})
142
+ break
143
+ inner = text[m_open.end():m_close.start()]
144
+ runs.append({"text": inner, "color": name})
145
+ i = m_close.end()
146
+ return runs
147
+
148
+
149
+ def split_math_runs(text: str) -> list[dict]:
150
+ from .md_blocks import parse_icon_marker
151
+
152
+ runs, i = [], 0
153
+ for m in _MATH_INLINE_RE.finditer(text or ""):
154
+ if m.start() > i:
155
+ before = text[i:m.start()]
156
+ for k2, frag in _split_text_and_icon(before):
157
+ if k2 == "text":
158
+ if frag: runs.append({"text": frag})
159
+ else:
160
+ icon_info = parse_icon_marker(frag)
161
+ runs.append({"kind": "icon", "icon": icon_info.get("icon", ""), "height": icon_info.get("height", "18")})
162
+
163
+ latex = (m.group(1) or m.group(2) or "").strip()
164
+ runs.append({"latex": _ensure_inline_dollars(latex), "kind": "math"})
165
+ i = m.end()
166
+
167
+ if i < len(text or ""):
168
+ from .md_blocks import parse_icon_marker as _pim
169
+ tail = text[i:]
170
+ for k2, frag in _split_text_and_icon(tail):
171
+ if k2 == "text":
172
+ if frag: runs.append({"text": frag})
173
+ else:
174
+ icon_info = _pim(frag)
175
+ runs.append({"kind": "icon", "icon": icon_info.get("icon", ""), "height": icon_info.get("height", "18")})
176
+
177
+ return runs
178
+
179
+
180
+ def split_emphasis_runs(text: str) -> list[dict]:
181
+ runs, i = [], 0
182
+ for m in _EMPH_RE.finditer(text):
183
+ if m.start() > i:
184
+ runs.append({"text": text[i:m.start()]})
185
+ chunk = m.group(0)
186
+ if chunk.startswith("***") and chunk.endswith("***"):
187
+ runs.append({"text": chunk[3:-3], "style": "bold-italic"})
188
+ elif chunk.startswith("**") and chunk.endswith("**"):
189
+ runs.append({"text": chunk[2:-2], "style": "bold"})
190
+ elif chunk.startswith("*") and chunk.endswith("*"):
191
+ runs.append({"text": chunk[1:-1], "style": "italic"})
192
+ else:
193
+ runs.append({"text": chunk})
194
+ i = m.end()
195
+ if i < len(text):
196
+ runs.append({"text": text[i:]})
197
+ return runs
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Run 正規化與組裝
201
+ # ---------------------------------------------------------------------------
202
+
203
+ def normalize_runs(runs: list[dict]) -> list[dict]:
204
+ out: list[dict] = []
205
+ for r in runs:
206
+ if "latex" in r and r.get("kind") in ("math", "math_block"):
207
+ out.append(r); continue
208
+ if r.get("newline") == "true" and "text" not in r:
209
+ out.append({"newline": "true"}); continue
210
+ if r.get("kind") == "icon":
211
+ out.append(r); continue
212
+
213
+ t = r.get("text", "")
214
+ if t == "":
215
+ continue
216
+
217
+ style = r.get("style")
218
+ color = r.get("color")
219
+ cl = r.get("color_light")
220
+ cd = r.get("color_dark")
221
+ prev = out[-1] if out else None
222
+ can_merge = (
223
+ prev is not None
224
+ and "latex" not in prev
225
+ and prev.get("kind") != "icon"
226
+ and prev.get("newline") != "true"
227
+ and "text" in prev
228
+ and prev.get("style") == style
229
+ and prev.get("color") == color
230
+ )
231
+ if can_merge:
232
+ prev["text"] += t
233
+ if cl and "color_light" not in prev: prev["color_light"] = cl
234
+ if cd and "color_dark" not in prev: prev["color_dark"] = cd
235
+ else:
236
+ keep = ("color", "style", "color_light", "color_dark")
237
+ out.append({"text": t, **{k: r[k] for k in keep if k in r}})
238
+
239
+ for rr in out:
240
+ attach_theme_colors(rr)
241
+ return out
242
+
243
+
244
+ def build_inline_runs(text: str) -> list[dict]:
245
+ out: list[dict] = []
246
+ for cr in split_color_runs(text):
247
+ base_color = cr.get("color")
248
+ for er in split_emphasis_runs(cr["text"]):
249
+ style = er.get("style")
250
+ seg = er["text"]
251
+ for mr in split_math_runs(seg):
252
+ if mr.get("kind") == "icon":
253
+ run = {"kind": "icon", "icon": mr.get("icon", ""), "height": mr.get("height", 18)}
254
+ elif "latex" in mr:
255
+ run = {"latex": mr["latex"], "kind": "math"}
256
+ if style: run["style"] = style
257
+ else:
258
+ run = {"text": mr.get("text", "")}
259
+ if style: run["style"] = style
260
+ if base_color: run["color"] = base_color
261
+ out.append(run)
262
+
263
+ runs = normalize_runs(out)
264
+ for r in runs:
265
+ attach_theme_colors(r)
266
+ return runs
267
+
268
+
269
+ def emit_text_runs_color_aware(text: str, color_stack: list) -> list[dict]:
270
+ runs_out, i, s = [], 0, (text or "")
271
+ while i <= len(s):
272
+ m_open = COLOR_OPEN.search(s, i)
273
+ m_close = COLOR_CLOSE_ANY.search(s, i)
274
+ if m_open and m_close:
275
+ ev = m_open if m_open.start() < m_close.start() else m_close
276
+ typ = "open" if ev is m_open else "close"
277
+ else:
278
+ ev = m_open or m_close
279
+ typ = "open" if ev is m_open else ("close" if m_close else None)
280
+
281
+ j = ev.start() if ev else len(s)
282
+ visible = s[i:j]
283
+ if visible:
284
+ seg_runs = build_inline_runs(visible)
285
+ if color_stack:
286
+ for r in seg_runs:
287
+ r["color"] = color_stack[-1]
288
+ attach_theme_colors(r)
289
+ runs_out.extend(seg_runs)
290
+
291
+ if not ev: break
292
+ name = ev.group(1)
293
+ if typ == "open":
294
+ color_stack.append(name)
295
+ else:
296
+ if color_stack and color_stack[-1] == name: color_stack.pop()
297
+ else:
298
+ try: color_stack.remove(name)
299
+ except ValueError: pass
300
+ i = ev.end()
301
+ return runs_out
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Run 工具函式
305
+ # ---------------------------------------------------------------------------
306
+
307
+ def _has_newline_token(runs: list[dict]) -> bool:
308
+ return any(r.get("newline") == "true" and "text" not in r for r in runs)
309
+
310
+
311
+ def _plain_text_from_runs(runs: list[dict]) -> str:
312
+ parts = []
313
+ for r in runs:
314
+ if r.get("newline") == "true" and "text" not in r:
315
+ parts.append("\n"); continue
316
+ if "latex" in r:
317
+ if r.get("kind") == "math":
318
+ parts.append(_ensure_inline_dollars(r.get("latex", "")))
319
+ elif r.get("kind") in ("math_block", "block_math", "display_math"):
320
+ parts.append(_ensure_block_dollars(r.get("latex", "")))
321
+ else:
322
+ parts.append(r.get("latex", ""))
323
+ else:
324
+ parts.append(r.get("text", ""))
325
+ return "".join(parts)
326
+
327
+
328
+ def _append_hard_break(runs: list[dict]) -> None:
329
+ if runs and runs[-1].get("newline") == "true" and "text" not in runs[-1]:
330
+ return
331
+ runs.append({"newline": "true"})
332
+
333
+
334
+ def _materialize_hard_break_tokens(runs: list[dict]) -> list[dict]:
335
+ for r in runs:
336
+ if "text" in r and r["text"]:
337
+ r["text"] = r["text"].replace(" \r\n", "\n").replace(" \n", "\n")
338
+ return runs
layout/converter/md_tex.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ md_tex.py
3
+ ---------
4
+ LaTeX 分隔符正規化工具。
5
+
6
+ 公開 API:
7
+ _strip_outer_tex_delims(s) → (core_latex, kind)
8
+ _ensure_inline_dollars(s) → "$core$"
9
+ _ensure_block_dollars(s) → "$$\ncore\n$$"
10
+ """
11
+ from __future__ import annotations
12
+ import re
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # 正規表達式
16
+ # ---------------------------------------------------------------------------
17
+ _re_outer_block = re.compile(r'^\s*\${2}(.*)\${2}\s*$', re.S)
18
+ _re_outer_inline = re.compile(r'^\s*\$(.*)\$\s*$', re.S)
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # 公開函式
22
+ # ---------------------------------------------------------------------------
23
+ def _strip_outer_tex_delims(s: str) -> tuple[str, str]:
24
+ """
25
+ 移除最外層的 TeX 分隔符。
26
+
27
+ 回傳 (core_latex, kind),kind 為 "block" / "inline" / "none"。
28
+ """
29
+ if s is None:
30
+ return "", "none"
31
+ t = str(s).strip()
32
+ m = _re_outer_block.match(t)
33
+ if m:
34
+ return m.group(1).strip(), "block"
35
+ m = _re_outer_inline.match(t)
36
+ if m:
37
+ return m.group(1).strip(), "inline"
38
+ return t, "none"
39
+
40
+
41
+ def _ensure_inline_dollars(s: str) -> str:
42
+ """確保字串以 $...$ 包裹(行內數學)。"""
43
+ core, _ = _strip_outer_tex_delims(s)
44
+ return f"${core}$"
45
+
46
+
47
+ def _ensure_block_dollars(s: str) -> str:
48
+ """確保字串以 $$....$$ 包裹(區塊數學)。"""
49
+ core, _ = _strip_outer_tex_delims(s)
50
+ return f"$$\n{core}\n$$"
layout/generate_common_card.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ layout/generate_common_card.py ← 遷移橋接(shim)
3
+ ----------------------------------------------------
4
+ 此檔案已廢棄,請改從 components.cards 匯入。
5
+
6
+ 舊:from layout.generate_common_card import generate_custom_markdown_card
7
+ 新:from components.cards import generate_custom_markdown_card
8
+
9
+ 舊:from layout.generate_common_card import render_card_from_node
10
+ 新:from components.cards import render_card_from_node
11
+
12
+ 此 shim 讓舊 import 繼續正常運作,不影響現有頁面。
13
+ 待所有頁面完成遷移後,直接刪除此檔即可。
14
+ """
15
+ import warnings
16
+ warnings.warn(
17
+ "layout.generate_common_card 已廢棄,請改用 components.cards",
18
+ DeprecationWarning,
19
+ stacklevel=2,
20
+ )
21
+
22
+ from components.cards import ( # noqa: F401
23
+ generate_custom_markdown_card,
24
+ render_card_from_node,
25
+ )
26
+ from components.render_utils import ( # noqa: F401
27
+ _inline_math,
28
+ _block_math,
29
+ _runs_to_children,
30
+ )
31
+
32
+ __all__ = [
33
+ "generate_custom_markdown_card",
34
+ "render_card_from_node",
35
+ "_inline_math",
36
+ "_block_math",
37
+ "_runs_to_children",
38
+ ]
layout/page/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """
2
+ page/ — 頁面組裝
3
+ 外部可直接用:from layout.page import make_test_page
4
+ """
5
+ from .page_layout import make_test_page # noqa: F401