Spaces:
Sleeping
Sleeping
Commit ·
e66cfb4
0
Parent(s):
initial deploy
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +18 -0
- .gitattributes +40 -0
- .gitignore +18 -0
- Dockerfile +20 -0
- README.md +10 -0
- app.py +128 -0
- assets/Ch14_5_ex4.svg +3 -0
- assets/Ch14_5_ex5.svg +3 -0
- assets/Ch14_5_ex7.svg +3 -0
- assets/Ch14_6_8.svg +3 -0
- assets/Ch14_6_ex1.svg +3 -0
- assets/Ch14_6_ex3.svg +3 -0
- assets/Ch14_6_ex5.svg +3 -0
- assets/Ch14_6_ex6.svg +3 -0
- assets/SaddlePoint.svg +3 -0
- assets/anchor-scroll.js +43 -0
- assets/chain_tree.svg +3 -0
- assets/chain_tree_card1.svg +3 -0
- assets/ggb.css +10 -0
- assets/logo.svg +3 -0
- assets/mathjax-plotly.js +36 -0
- assets/maximum.svg +3 -0
- assets/partial_derivative.svg +3 -0
- assets/quiz.css +105 -0
- assets/scroll.css +104 -0
- assets/session_state.js +171 -0
- assets/toc.css +125 -0
- assets/toc_scrollspy.js +119 -0
- assets/unit_vector.svg +3 -0
- assets/unit_vector_theta.svg +3 -0
- build.py +66 -0
- components/__init__.py +9 -0
- components/cards.py +283 -0
- components/render_utils.py +91 -0
- firebase_init.py +46 -0
- firebase_progress.py +127 -0
- firebase_quiz_hooks.py +75 -0
- ggb.py +160 -0
- layout/Chapter_card.py +56 -0
- layout/__init__.py +25 -0
- layout/converter/__init__.py +8 -0
- layout/converter/md_attrs.py +82 -0
- layout/converter/md_blocks.py +970 -0
- layout/converter/md_color.py +166 -0
- layout/converter/md_io.py +202 -0
- layout/converter/md_pipeline.py +101 -0
- layout/converter/md_runs.py +338 -0
- layout/converter/md_tex.py +50 -0
- layout/generate_common_card.py +38 -0
- layout/page/__init__.py +5 -0
.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
|
assets/Ch14_5_ex5.svg
ADDED
|
|
Git LFS Details
|
assets/Ch14_5_ex7.svg
ADDED
|
|
Git LFS Details
|
assets/Ch14_6_8.svg
ADDED
|
|
Git LFS Details
|
assets/Ch14_6_ex1.svg
ADDED
|
|
Git LFS Details
|
assets/Ch14_6_ex3.svg
ADDED
|
|
Git LFS Details
|
assets/Ch14_6_ex5.svg
ADDED
|
|
Git LFS Details
|
assets/Ch14_6_ex6.svg
ADDED
|
|
Git LFS Details
|
assets/SaddlePoint.svg
ADDED
|
|
Git LFS Details
|
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
|
assets/chain_tree_card1.svg
ADDED
|
|
Git LFS Details
|
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
|
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
|
assets/partial_derivative.svg
ADDED
|
|
Git LFS Details
|
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
|
assets/unit_vector_theta.svg
ADDED
|
|
Git LFS Details
|
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
|