Mochi0622's picture
initial deploy
e66cfb4
Raw
History Blame Contribute Delete
41.4 kB
"""
md_blocks.py
------------
段落 / 清單 / 特殊區塊(card、quiz、flow、solution、ggb、icon)解析,
以及文件級 parse_document 與 TOC 指派。
依賴:md_color, md_tex, md_attrs, md_runs
公開 API:
parse_document(text) → List[block] 主要入口
parse_content_block(text) → List[block]
parse_solution_block(src) → block
parse_card_block(src) → block
parse_interactive_block(src) → block
parse_flow_block(src) → block
parse_quizzes(md) → List[block]
parse_geogebra_marker(s) → block
parse_icon_marker(s) → block
parse_pyfunc_marker(s) → block
parse_list_items(list_block) → List[item]
parse_paragraph_runs_color_aware → List[run]
parse_line_color_aware → List[run]
assign_ids_and_build_toc(nodes) → List[toc_entry]
split_blocks_into_sections(blocks)→ List[section]
build_ai_prompt_for_example(...) → str
build_ai_prompt_for_pos(...) → str
build_ai_prompt_for_solution_pos(...)→ str
"""
from __future__ import annotations
import re
import hashlib
import unicodedata
import json
from .md_color import attach_theme_colors, _norm_hex
from .md_tex import _ensure_inline_dollars, _ensure_block_dollars
from .md_attrs import _attrs_to_dict, _parse_attrs, _slug_ascii
from .md_runs import (
build_inline_runs, normalize_runs,
_has_newline_token, _plain_text_from_runs,
_append_hard_break,
_split_text_and_any_display_math,
_split_text_links_images, _split_text_and_ggb, _split_text_and_icon,
)
# ---------------------------------------------------------------------------
# 模組級正規表達式(只保留 md_blocks 內部用的)
# ---------------------------------------------------------------------------
_COLOR_TAG = re.compile(r'<!--c_([A-Za-z0-9#]+)-->|\s*<!--c_([A-Za-z0-9#]+)_end-->')
_SOL_OPEN = re.compile(r'<!--\s*solution(?P<attrs>[^>]*)-->', re.IGNORECASE)
_SOL_CLOSE = re.compile(r'<!--\s*solution_end\s*-->', re.IGNORECASE)
_SOL_END_RE = re.compile(r'<!--\s*solution_end\s*-->', re.IGNORECASE)
_EXAMPLE_RE = re.compile(r'^\s*###\s*Example[^\n]*', re.MULTILINE)
_PROBLEM_RE = re.compile(r'<!--\s*problem\s*-->', re.IGNORECASE)
_SOLUTION_OPEN_RE = re.compile(r'<!--\s*solution\s*-->')
_TAG_ONLY_RE = re.compile(r'^<!--\s*([A-Za-z0-9_-]+)\s*-->$')
_SPLIT_AFTER_COLOR_CLOSE_TO_NEW_ITEM = re.compile(
r'^(?P<left>.*?<!--c_[A-Za-z0-9#]+_end-->)(?:\s*)(?P<right>(?P<indent>\s*)(?P<marker>\d+\.)\s+.*)$'
)
_BLANKPARA_SPLIT = re.compile(r'(\n\s*\n+)')
_PYFUNC_RE = re.compile(r'<!--\s*pyfunc(?P<attrs>[^>]*)-->')
_GGB_SINGLE_RE = re.compile(r'^\s*<!--\s*ggb(?P<attrs>[^>]*)-->\s*$', re.S)
_ICON_SINGLE_RE = re.compile(r'^\s*<!--\s*icon(?P<attrs>[^>]*)-->\s*$', re.S | re.I)
_CARD_RE = re.compile(r'<!--\s*card(?P<attrs>[^>]*)-->(?P<body>.*?)<!--\s*card_end\s*-->', re.S)
_FLOW_RE = re.compile(r'<!--\s*flow(?P<attrs>[^>]*)-->(?P<body>.*?)<!--\s*flow_end\s*-->', re.S)
_STEP_RE = re.compile(r'<!--\s*step(?P<attrs>[^>]*)-->(?P<body>.*?)<!--\s*step_end\s*-->', re.S)
QUIZ_OPEN = re.compile(r'<!--\s*quiz\s+(?P<attrs>[^>]*)-->')
QUIZ_END = re.compile(r'<!--\s*quiz_end\s*-->')
STEP_RE_SIMPLE = re.compile(r'<!--step-->\s*(.*)')
_FILE_MARKER_RE = re.compile(r'<!--\s*file(?:_|[\s]+)(?P<path>[^>\r\n]+?)\s*-->', re.IGNORECASE)
# 全域原始 MD(供 AI prompt 查詢用,由 md_io._export_one 注入)
_ROOT_MD: str = ""
# flow id 計數器
_FLOW_SEQ: int = 0
# ---------------------------------------------------------------------------
# AI Prompt 輔助
# ---------------------------------------------------------------------------
def build_ai_prompt_for_example(full_md: str, example_seq: int) -> str:
"""回傳第 example_seq 個 Example 標題到第一個 <!--solution--> 之前的文字。"""
if not full_md or example_seq <= 0:
return ""
ex_iter = list(_EXAMPLE_RE.finditer(full_md))
if example_seq > len(ex_iter):
return ""
ex_m = ex_iter[example_seq - 1]
sol_m = _SOLUTION_OPEN_RE.search(full_md, pos=ex_m.start())
end = sol_m.start() if sol_m else len(full_md)
return full_md[ex_m.start():end].strip()
def build_ai_prompt_for_solution_pos(full_md: str, pos: int) -> str:
"""solution block 的 AI prompt:從最近的 Example 到 pos,若中間有 solution_end 則回空。"""
if not full_md or pos is None or pos < 0:
return ""
search_region = full_md[:pos]
last_example = None
for m in _EXAMPLE_RE.finditer(search_region):
last_example = m
if not last_example:
return ""
seg = search_region[last_example.end():]
if _SOL_END_RE.search(seg):
return ""
return full_md[last_example.start():pos].strip()
def build_ai_prompt_for_pos(full_md: str, pos: int) -> str:
"""給定 pos,往上找最近的 ### Example 或 <!--problem-->,抽出 prompt 文字。"""
if not full_md or pos is None or pos < 0:
return ""
search_region = full_md[:pos]
last_example = None
for m in _EXAMPLE_RE.finditer(search_region):
last_example = m
last_problem = None
for m in _PROBLEM_RE.finditer(search_region):
last_problem = m
if not last_example and not last_problem:
return ""
if last_example:
seg = search_region[last_example.end():]
if _SOL_END_RE.search(seg):
last_example = None
if not last_example and not last_problem:
return ""
if last_example and (not last_problem or last_example.start() > last_problem.start()):
start = last_example.start()
else:
start = last_problem.end()
return full_md[start:pos].strip()
# ---------------------------------------------------------------------------
# TOC / ID 相關
# ---------------------------------------------------------------------------
def _make_unique_id(base: str, used: set) -> str:
hid, i = base, 1
while hid in used:
i += 1
hid = f"{base}-{i}"
used.add(hid)
return hid
def _slugify_heading(text: str) -> str:
t = unicodedata.normalize("NFKC", text)
t = re.sub(r'\s+', '-', t.strip())
t = re.sub(r'[^A-Za-z0-9\-_]+', '-', t)
t = re.sub(r'-{2,}', '-', t).strip('-')
return t or "sec"
def assign_ids_and_build_toc(nodes: list[dict]) -> list[dict]:
"""為 heading 節點指派 id,並回傳 TOC 清單。"""
current_tag, tag_counters, used_ids, toc = None, {}, set(), []
for n in nodes:
if n.get('type') == 'comment':
m = _TAG_ONLY_RE.match(n.get('content', '').strip())
if m:
current_tag = m.group(1)
tag_counters.setdefault(current_tag, 0)
continue
if n.get('type') == 'heading':
title = n.get('content', '').strip()
if current_tag:
tag_counters[current_tag] += 1
base = f"{current_tag}_id{tag_counters[current_tag]}"
else:
base = _slugify_heading(title)
hid = _make_unique_id(base, used_ids)
n['id'] = hid
toc.append({'label': title, 'id': hid, 'level': n.get('level', 2)})
return toc
def split_blocks_into_sections(blocks: list[dict]) -> list[dict]:
"""按 H2 heading 把 blocks 切成 sections,第一個為「全部」。"""
sections: list[dict] = [{"id": "sec-all", "title": "全部", "blocks": blocks[:]}]
current, sec_idx = None, 0
for b in blocks:
if b.get("type") == "heading" and int(b.get("level", 0)) == 2:
if current:
sections.append(current)
sec_idx += 1
current = {
"id": f"sec-{sec_idx}",
"title": (b.get("content") or f"段落 {sec_idx}").strip(),
"blocks": [b],
}
else:
if current is not None:
current["blocks"].append(b)
if current:
sections.append(current)
return sections
# ---------------------------------------------------------------------------
# 段落 / 清單解析
# ---------------------------------------------------------------------------
def _split_midline_new_item(line: str) -> tuple[str | None, str | None]:
m = _SPLIT_AFTER_COLOR_CLOSE_TO_NEW_ITEM.match(line)
if not m:
return None, None
return m.group('left'), m.group('right')
def _emit_blank_paragraph(parsed_content: list) -> None:
parsed_content.append({'type': 'paragraph', 'content': '', 'runs': [], 'newline': 'true'})
def _append_blankline_runs(blank_token: str, out_runs: list) -> None:
n = max(1, blank_token.count('\n') - 1)
for _ in range(n):
out_runs.append({'text': '', 'newline': 'true', '_blank': '1'})
def parse_line_color_aware(line: str, color_stack: list) -> list[dict]:
"""把一行切成 runs,依 color_stack 上色,處理 <!--c_xxx--> 標記。"""
pos, out = 0, []
def cur_color():
return color_stack[-1] if color_stack else None
for m in _COLOR_TAG.finditer(line):
chunk = line[pos:m.start()]
if chunk:
for tk in _split_text_and_any_display_math(chunk):
if tk.get("kind") == "math_block":
run = {'latex': tk["latex"], 'kind': 'math_block'}
if cur_color():
run['color'] = cur_color()
attach_theme_colors(run)
out.append(run)
else:
runs = build_inline_runs(tk["text"] or "")
if cur_color():
for r in runs:
r.setdefault('color', cur_color())
attach_theme_colors(r)
out.extend(runs)
if m.group(1):
color_stack.append(m.group(1))
else:
name = m.group(2)
for k in range(len(color_stack) - 1, -1, -1):
if color_stack[k] == name:
color_stack.pop(k); break
pos = m.end()
tail = line[pos:]
if tail:
for tk in _split_text_and_any_display_math(tail):
if tk.get("kind") == "math_block":
run = {'latex': tk["latex"], 'kind': 'math_block'}
if cur_color():
run['color'] = cur_color()
attach_theme_colors(run)
out.append(run)
else:
runs = build_inline_runs(tk["text"] or "")
if cur_color():
for r in runs:
r.setdefault('color', cur_color())
attach_theme_colors(r)
out.extend(runs)
return out
def parse_paragraph_runs_color_aware(block_text: str) -> list[dict]:
"""多行段落 → runs(含 $$ block math、硬換行)。"""
lines = (block_text or "").splitlines(keepends=True)
i, color_stack, runs = 0, [], []
PARA_DOLLAR_LINE = re.compile(r'^\s*\$\$(.*)$')
while i < len(lines):
ln = lines[i]
m_dollar = PARA_DOLLAR_LINE.match(ln.rstrip('\r\n'))
if m_dollar:
latex_lines, first = [], m_dollar.group(1)
if re.search(r'\$\$\s*$', first):
latex_lines.append(re.sub(r'\$\$\s*$', '', first))
else:
i += 1
while i < len(lines):
seg = lines[i].rstrip('\r\n')
if re.search(r'\$\$\s*$', seg):
latex_lines.append(re.sub(r'\$\$\s*$', '', seg)); break
latex_lines.append(seg); i += 1
latex = "\n".join(latex_lines).strip()
run = {'latex': _ensure_block_dollars(latex), 'kind': 'math_block'}
if color_stack:
run['color'] = color_stack[-1]
attach_theme_colors(run)
runs.append(run); i += 1; continue
raw = ln
if re.search(r'[ ]{2,}\r?\n$', raw):
body = re.sub(r'[ ]{2,}\r?\n$', '', raw)
runs.extend(parse_line_color_aware(body, color_stack))
_append_hard_break(runs)
else:
runs.extend(parse_line_color_aware(raw.rstrip('\r\n'), color_stack))
i += 1
return normalize_runs(runs)
def parse_list_items(list_block: str) -> list[dict]:
"""解析清單 block,回傳帶 indent / marker / runs / content 的 item 清單。"""
lines, i = list_block.splitlines(), 0
list_items, color_stack = [], []
INDENT_PER_LEVEL = 2
item_re = re.compile(r'^(?P<indent>\s*)(?P<marker>[*+\-]|\d+\.)\s+(?P<content>.*)$')
def _parse_dollar_block(start_i):
"""從 lines[start_i] 開始讀 $$...$$ block,回傳 (latex_str, next_i)。"""
m_d = re.match(r'^\s*\$\$(.*)$', lines[start_i])
if not m_d:
return None, start_i
latex_lines, first = [], m_d.group(1)
if re.search(r'\$\$\s*$', first):
latex_lines.append(re.sub(r'\$\$\s*$', '', first))
return "\n".join(latex_lines).strip(), start_i + 1
j = start_i + 1
while j < len(lines):
seg = lines[j]
if re.search(r'\$\$\s*$', seg):
latex_lines.append(re.sub(r'\$\$\s*$', '', seg)); j += 1; break
latex_lines.append(seg); j += 1
return "\n".join(latex_lines).strip(), j
while i < len(lines):
line = lines[i]
m = item_re.match(line)
if not m:
if list_items and line.strip():
left, right = _split_midline_new_item(line)
if right:
list_items[-1]['runs'].extend(parse_line_color_aware((left or '').strip(), color_stack))
list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
lines.insert(i + 1, right); i += 1; continue
latex, next_i = _parse_dollar_block(i)
if latex is not None:
run = {'latex': _ensure_block_dollars(latex), 'kind': 'math_block'}
if color_stack:
run['color'] = color_stack[-1]; attach_theme_colors(run)
list_items[-1]['runs'].append(run)
list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
i = next_i; continue
list_items[-1]['runs'].extend(parse_line_color_aware(line.strip(), color_stack))
list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
i += 1; continue
color_stack = []
raw_indent = m.group('indent')
indent_spaces = raw_indent.expandtabs(INDENT_PER_LEVEL)
indent = len(indent_spaces) // INDENT_PER_LEVEL
marker = m.group('marker')
content = m.group('content').rstrip()
runs = parse_line_color_aware(content, color_stack)
list_items.append({'indent': indent, 'marker': marker, 'runs': runs, 'content': _plain_text_from_runs(runs)})
i += 1
while i < len(lines) and not item_re.match(lines[i]):
ln = lines[i]
if not ln.strip():
i += 1; continue
left, right = _split_midline_new_item(ln)
if right:
list_items[-1]['runs'].extend(parse_line_color_aware((left or '').strip(), color_stack))
list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
lines.insert(i + 1, right); i += 1; continue
latex, next_i = _parse_dollar_block(i)
if latex is not None:
run = {'latex': _ensure_block_dollars(latex), 'kind': 'math_block'}
if color_stack:
run['color'] = color_stack[-1]; attach_theme_colors(run)
list_items[-1]['runs'].append(run)
list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
i = next_i; continue
list_items[-1]['runs'].extend(parse_line_color_aware(ln.strip(), color_stack))
list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs'])
i += 1
return list_items
# ---------------------------------------------------------------------------
# 特殊 marker 解析
# ---------------------------------------------------------------------------
def _ggb_id_from_url(u: str) -> str | None:
m = re.search(r'/m/([A-Za-z0-9]+)', u or '')
return m.group(1) if m else None
def _ggb_node_from_attrs(attrs: dict) -> dict:
gid = (attrs.get("id") or "").strip()
if not gid and "url" in attrs:
gid = _ggb_id_from_url(attrs.get("url", "")) or ""
return {
"type": "geogebra",
"id": gid,
"width": attrs.get("w") or attrs.get("width") or "100%",
"height": attrs.get("h") or attrs.get("height") or "480",
"border": attrs.get("border") or "0",
"caption": attrs.get("caption") or "",
"card": attrs.get("card") or "0",
}
def parse_geogebra_marker(s: str) -> dict:
m = _GGB_SINGLE_RE.search(s or '')
if not m:
return {"type": "paragraph", "content": s}
attrs = _attrs_to_dict(m.group('attrs') or "")
gid = attrs.get("id") or _ggb_id_from_url(attrs.get("url", ""))
return {
"type": "geogebra",
"id": (gid or "").strip(),
"width": attrs.get("w") or attrs.get("width") or "100%",
"height": attrs.get("h") or attrs.get("height") or "480",
"border": attrs.get("border") or "0",
"caption": attrs.get("caption") or "",
"card": attrs.get("card") or "0",
}
def parse_icon_marker(s: str) -> dict:
"""解析 <!--icon ...--> 為 icon 節點。"""
m = _ICON_SINGLE_RE.search(s or "")
if not m:
return {"type": "paragraph", "content": s}
attrs_str = (m.group("attrs") or "").strip()
attrs = _attrs_to_dict(attrs_str)
icon = (attrs.get("icon") or attrs.get("name") or "").strip()
height = (attrs.get("height") or attrs.get("h") or "18").strip()
if not icon:
m2 = re.search(r'=\s*"([^"]+)"', attrs_str)
if m2:
icon = m2.group(1).strip()
elif attrs_str:
icon = attrs_str.strip()
return {"type": "icon", "icon": icon, "height": height}
def parse_pyfunc_marker(s: str) -> dict:
m = _PYFUNC_RE.search(s or '')
if not m:
return {"type": "paragraph", "content": s}
attrs = _attrs_to_dict(m.group('attrs') or "")
kwargs: dict = {}
if 'params' in attrs:
try:
kwargs = json.loads(attrs['params'])
except Exception:
kwargs = {}
for k, v in attrs.items():
if k not in ('path', 'call', 'params'):
kwargs.setdefault(k, v)
return {
"type": "pyfunc",
"path": (attrs.get("path") or "").strip(),
"call": (attrs.get("call") or "make_view").strip(),
"kwargs": kwargs,
}
def _parse_color_pair(token: str) -> tuple[str, str]:
s = (token or "").strip()
if not s:
return "", ""
if "|" in s:
a, b = s.split("|", 1)
return _norm_hex(a.strip()), _norm_hex(b.strip())
c = _norm_hex(s)
return c, c
def parse_card_block(src: str) -> dict:
m = _CARD_RE.search(src)
if not m:
return {"type": "card", "content": src}
attrs = _attrs_to_dict(m.group("attrs") or "")
inner = (m.group("body") or "").strip()
kind = attrs.get("type") or attrs.get("kind") or ""
headline = attrs.get("headline") or attrs.get("title") or ""
if not (kind and headline):
lines = inner.splitlines()
nz = [i for i, ln in enumerate(lines) if ln.strip()]
if nz:
if not kind:
kind = lines[nz[0]].strip(); lines[nz[0]] = ""
if len(nz) >= 2 and not headline:
headline = lines[nz[1]].strip(); lines[nz[1]] = ""
inner = "\n".join(lines).lstrip()
bg_l, bg_d = _parse_color_pair(attrs.get("bg") or attrs.get("background") or attrs.get("body_bg") or "")
fg_l, fg_d = _parse_color_pair(attrs.get("fg") or attrs.get("text") or attrs.get("header_fg") or "")
accent = (attrs.get("accent") or attrs.get("badge") or attrs.get("palette") or "").strip()
body_blocks = parse_content_block(inner)
node = {"type": "card", "kind": kind, "headline": headline, "body": body_blocks}
if bg_l or bg_d: node["bg_light"], node["bg_dark"] = bg_l, bg_d
if fg_l or fg_d: node["fg_light"], node["fg_dark"] = fg_l, fg_d
if accent: node["accent"] = accent
return node
def _split_mcq_answers(ans_raw: str) -> list[str]:
raw = (ans_raw or "").strip().lower()
if not raw:
return []
raw = raw.replace(",", ",").replace("、", ",").replace(";", ",").replace("|", ",").replace(" ", ",")
raw = raw.strip(",")
if not raw:
return []
if re.fullmatch(r"[abcd]+", raw) and "," not in raw:
tokens = list(raw)
else:
tokens = [t for t in re.split(r"[,\s]+", raw) if t]
idxmap = {"1": "a", "2": "b", "3": "c", "4": "d"}
out = []
for t in tokens:
t = t.strip().lower().rstrip(".")
t = idxmap.get(t, t)
if t in ("a", "b", "c", "d") and t not in out:
out.append(t)
return out
def parse_interactive_block(src: str) -> dict:
inner = re.sub(r'^\s*<!--\s*互動\s*-->\s*', '', src, flags=re.S)
inner = re.sub(r'<!---?\s*互動end\s*-->\s*$', '', inner, flags=re.S)
m_fill_zh = re.search(r'<!--\s*填空([^>]*)-->(.*?)<!--\s*填空end\s*-->', inner, re.S)
m_fill_en = re.search(r'<!--\s*fill([^>]*)-->(.*?)<!--\s*fill(?:_end|end)\s*-->', inner, re.S)
m_fill = m_fill_en or m_fill_zh
m_mcq_old = re.search(r'<!--\s*選擇([^>]*)-->(.*?)<!--\s*選擇end\s*-->', inner, re.S)
m_mcq_new = re.search(r'<!--\s*(?:mcq|choice)([^>]*)-->(.*?)<!--\s*(?:mcq|choice)(?:_end|end)\s*-->', inner, re.S)
m_mcq = m_mcq_new or m_mcq_old
beg = min([m.start() for m in [m_fill, m_mcq] if m] or [len(inner)])
prompt_md = inner[:beg].rstrip()
prompt_runs: list[dict] = []
if prompt_md:
for part in _BLANKPARA_SPLIT.split(prompt_md):
if not part: continue
if _BLANKPARA_SPLIT.fullmatch(part):
_append_blankline_runs(part, prompt_runs)
else:
runs = parse_paragraph_runs_color_aware(part) or []
for r in runs: r['newline'] = 'false'
prompt_runs.extend(runs)
if m_fill:
attrs = _attrs_to_dict(m_fill.group(1) or "")
body = (m_fill.group(2) or "").strip()
right = attrs.get("right", ""); wrong = attrs.get("wrong", "")
ans = attrs.get("ans") or (body.splitlines()[0].strip() if body else "")
answers = [s.strip() for s in ans.split("|") if s.strip()]
normalize = [s.strip() for s in (attrs.get("normalize") or "sym,numeric,nospace").split(",") if s.strip()]
if not prompt_runs and body:
for p_idx, para in enumerate(re.split(r'\n\s*\n', body)):
runs = parse_paragraph_runs_color_aware(para)
is_last = (p_idx == len(re.split(r'\n\s*\n', body)) - 1)
for r in runs: r.setdefault("newline", "false" if is_last else "true")
prompt_runs.extend(runs)
return {
"type": "quiz", "qtype": "fill", "id": attrs.get("id", ""),
"prompt_runs": prompt_runs, "answers": answers, "normalize": normalize,
"placeholder": attrs.get("placeholder", ""), "border": attrs.get("border", "1"),
"inline_math": attrs.get("inline_math", "1"), "right_msg": right, "wrong_msg": wrong,
"card": attrs.get("card", "1"),
}
if m_mcq:
attrs = _attrs_to_dict(m_mcq.group(1) or "")
body = (m_mcq.group(2) or "").strip()
right = attrs.get("right", ""); wrong = attrs.get("wrong", "")
hints = {}
for k, v in attrs.items():
kk = (k or "").strip().lower()
if kk.startswith("hint_"):
opt = kk.split("_", 1)[1].strip().lower()
if opt in ("a", "b", "c", "d") and str(v).strip():
hints[opt] = str(v).strip()
indent_flag = attrs.get("indent") or attrs.get("indent_options")
lines = body.splitlines()
lead_lines, opt_lines, seen_option = [], [], False
for ln in lines:
t = ln.strip()
if re.match(r'^[aAdDbBcC]\s*\.', t):
seen_option = True; opt_lines.append(t)
else:
(opt_lines if seen_option else lead_lines).append(ln)
if not prompt_runs and lead_lines:
paragraphs = re.split(r'\n\s*\n', "\n".join(lead_lines).rstrip())
for p_idx, para in enumerate(paragraphs):
runs = parse_paragraph_runs_color_aware(para)
is_last = (p_idx == len(paragraphs) - 1)
for r in runs: r.setdefault("newline", "false" if is_last else "true")
prompt_runs.extend(runs)
opts = []
for t in (opt_lines if opt_lines else lines):
tt = t.strip()
for key, pat in [("a", r'^[aA]\s*\.'), ("b", r'^[bB]\s*\.'), ("c", r'^[cC]\s*\.'), ("d", r'^[dD]\s*\.')]:
if re.match(pat, tt):
opts.append({"key": key, "text": tt.split('.', 1)[1].strip()})
multi_flag = attrs.get("multi") or attrs.get("multiple") or attrs.get("checkbox")
is_multi = str(multi_flag).strip().lower() in ("1", "true", "yes", "y", "on")
ans_raw = (attrs.get("ans", "") or "").strip().lower().rstrip(".")
ans_list = _split_mcq_answers(ans_raw)
ans_one = ans_list[0] if ans_list else ""
node = {
"type": "quiz", "qtype": "multi" if is_multi else "choice",
"id": attrs.get("id", ""), "prompt_runs": prompt_runs, "options": opts,
"shuffle": attrs.get("shuffle", "0") in ("1", "true", "yes"),
"explain": attrs.get("explain", ""), "right_msg": right, "wrong_msg": wrong,
"wrong_hints": hints, "card": attrs.get("card", "1"),
}
if is_multi:
node["answers"] = ans_list; node["answer"] = ""
else:
node["answer"] = ans_one
if indent_flag is not None:
node["indent_options"] = indent_flag
return node
return {"type": "paragraph", "content": prompt_md, "runs": prompt_runs}
def _next_flow_id() -> str:
global _FLOW_SEQ
_FLOW_SEQ += 1
return f"flow-{_FLOW_SEQ}"
def parse_flow_block(src: str) -> dict:
m = _FLOW_RE.search(src)
if not m:
return {"type": "paragraph", "content": src}
fattrs = _attrs_to_dict(m.group("attrs") or "")
body = (m.group("body") or "").strip()
flow_id = fattrs.get("id") or _next_flow_id()
persist = (fattrs.get("persist") or "").strip()
steps_raw = list(_STEP_RE.finditer(body))
if not steps_raw:
steps_raw_iter = [("", body)]
def _unwrap(x): return x[0], x[1]
else:
steps_raw_iter = steps_raw
def _unwrap(mm): return (mm.group("attrs") or ""), (mm.group("body") or "").strip()
steps, quiz_seq, prev_gate_qid = [], 0, None
for i, raw in enumerate(steps_raw_iter, start=1):
sattrs_raw, sbody = _unwrap(raw)
sattrs = _attrs_to_dict(sattrs_raw)
step_id = sattrs.get("id") or f"step-{i}"
title = sattrs.get("title") or f"步驟 {i}"
cond = (sattrs.get("cond") or "all").lower().strip()
content_blocks = parse_document(sbody)
first_quiz_id = None
for b in content_blocks:
if b.get("type") == "quiz":
if not b.get("id"):
quiz_seq += 1
b["id"] = f"{flow_id}::{step_id}::q{quiz_seq}"
b["flow"] = flow_id; b["step"] = step_id
if first_quiz_id is None:
first_quiz_id = b["id"]
steps.append({"id": step_id, "title": title, "cond": cond, "content": content_blocks, "gate_from": prev_gate_qid})
if first_quiz_id is not None:
prev_gate_qid = first_quiz_id
return {"type": "flow", "id": flow_id, "persist": persist, "steps": steps}
def parse_solution_block(src: str) -> dict:
m_open = _SOL_OPEN.search(src)
m_close = _SOL_CLOSE.search(src, m_open.end() if m_open else 0)
if not (m_open and m_close):
return {"type": "paragraph", "content": src}
attrs_raw = (m_open.group("attrs") or "").strip()
shortcut_label = ""
attrs_for_dict = attrs_raw
m_short = re.match(r'^_([^\s]+)(.*)$', attrs_raw)
if m_short:
shortcut_label = (m_short.group(1) or "").strip()
attrs_for_dict = (m_short.group(2) or "").strip()
attrs = _attrs_to_dict(attrs_for_dict)
inner = src[m_open.end():m_close.start()]
body_blocks = [b for b in parse_document(inner) if b.get('type') != 'comment']
label = ((attrs.get("label") or shortcut_label or "").strip()) or "看解法"
default = (attrs.get("default", "closed") or "closed").lower()
persist = (attrs.get("persist", "none") or "none").lower()
card = str(attrs.get("card", "0"))
accent = attrs.get("accent", "")
after = attrs.get("after", "")
h = hashlib.md5()
for part in [inner, label, default, persist, card, accent, after]:
h.update((part or "").encode("utf-8"))
h.update(b"|")
uid = f"sol_{h.hexdigest()[:10]}"
return {
"type": "solution", "uid": uid, "label": label,
"default": default, "persist": persist, "card": card,
"accent": accent, "after": after, "body": body_blocks,
}
def parse_quizzes(md: str) -> list[dict]:
"""解析 <!--quiz ...--> ... <!--quiz_end--> 區塊(order 題型)。"""
out, i = [], 0
while True:
m = QUIZ_OPEN.search(md, i)
if not m: break
n = QUIZ_END.search(md, m.end())
if not n: break
block = md[m.end():n.start()]
attrs = _parse_attrs(m.group('attrs') or "")
qtype = (attrs.get("qtype") or "").strip().lower() or "multi"
title = (attrs.get("title") or "練習題").strip()
card = 1 if str(attrs.get("card", "1")).lower() in ("1", "true", "yes") else 0
shuffle = str(attrs.get("shuffle", "0")).lower() in ("1", "true", "yes")
prompt_md = ""
for ln in block.splitlines():
s = ln.strip()
if s and not s.startswith("<!--"):
prompt_md = s; break
if qtype == "order":
steps = [s.strip() for s in STEP_RE_SIMPLE.findall(block)]
qid = (attrs.get("id") or _slug_ascii(title, "order"))
out.append({
"type": "quiz", "qtype": "order", "id": qid,
"title": title, "card": card, "shuffle": shuffle,
"prompt_md": prompt_md, "steps": steps, "answer": list(range(len(steps))),
"msg_right": attrs.get("msg_right") or "✔ 正確!",
"msg_wrong": attrs.get("msg_wrong") or "✘ 再想想。",
})
i = n.end()
return out
# ---------------------------------------------------------------------------
# 段落 block 解析(核心)
# ---------------------------------------------------------------------------
def parse_content_block(text: str) -> list[dict]:
return _parse_plain_blocks(text)
def _parse_plain_blocks(text: str) -> list[dict]:
parsed_content: list[dict] = []
pattern = r'(^\s*#+.*?\n|\n{2,}|^\s*[-_]{3,}\s*$)'
blocks = re.split(pattern, text, flags=re.MULTILINE | re.DOTALL)
for block_text in blocks:
m_blank = re.fullmatch(r'\s*\n{2,}\s*', block_text, flags=re.DOTALL)
if m_blank:
for _ in range(max(1, block_text.count('\n') - 1)):
_emit_blank_paragraph(parsed_content)
continue
if not block_text:
continue
heading_match = re.match(r'^\s*#+', block_text)
if heading_match:
level = len(heading_match.group(0).strip())
has_newline = block_text.endswith('\n')
clean_content = re.sub(r'^\s*#+\s*', '', block_text).strip()
runs = build_inline_runs(clean_content)
# 標題預設套 teal
if not any(('color' in r) or ('color_light' in r) or ('color_dark' in r) for r in runs):
for r in runs:
r['color'] = 'teal'; r['color_light'] = 'teal'; r['color_dark'] = "#49F0C3"
attach_theme_colors(r)
node = {
'type': 'heading', 'level': level,
'content': _plain_text_from_runs(runs),
'newline': 'true' if has_newline else 'false', 'runs': runs,
}
if len(runs) == 1:
if 'color' in runs[0]: node['color'] = runs[0]['color']
if 'style' in runs[0]: node['style'] = runs[0]['style']
parsed_content.append(node)
continue
elif re.match(r'^\s*([*-]|\d+\.)\s+', block_text):
parsed_content.append({'type': 'list', 'content': parse_list_items(block_text)})
elif re.fullmatch(r'^\s*([-]{3,}|[_-]{3,})\s*$', block_text, re.DOTALL):
parsed_content.append({'type': 'horizontal_rule', 'content': block_text.strip()})
else:
ITEM_LINE = re.compile(r'(?m)^\s*(?:[*-]|\d+\.)\s+')
first_item = ITEM_LINE.search(block_text)
if first_item and len(ITEM_LINE.findall(block_text)) >= 2:
preface = block_text[:first_item.start()]
rest = block_text[first_item.start():]
m_end = re.search(r'\n\s*\n(?!\s*(?:[*-]|\d+\.)\s+)', rest)
if m_end:
list_src = rest[:m_end.start()]; tail = rest[m_end.end():]
else:
list_src = rest; tail = ""
if preface.strip():
runs = parse_paragraph_runs_color_aware(preface)
parsed_content.append({
'type': 'paragraph', 'content': _plain_text_from_runs(runs),
'newline': 'true' if _has_newline_token(runs) else 'false', 'runs': runs,
})
parsed_content.append({'type': 'list', 'content': parse_list_items(list_src)})
if tail.strip():
parsed_content.extend(_parse_plain_blocks(tail))
continue
para_text = block_text
m_file_only = _FILE_MARKER_RE.fullmatch(para_text.strip())
if m_file_only:
parsed_content.append({"type": "file_embed", "relpath": (m_file_only.group("path") or "").strip()})
continue
nodes = _split_text_links_images(para_text)
buf_runs: list[dict] = []
def flush_buf():
nonlocal buf_runs
if buf_runs:
parsed_content.append({
'type': 'paragraph',
'content': _plain_text_from_runs(buf_runs),
'newline': 'true' if _has_newline_token(buf_runs) else 'false',
'runs': buf_runs,
})
buf_runs = []
for node in nodes:
if node["type"] == "text":
for part in _split_text_and_ggb(node["content"]):
if part["kind"] == "ggb":
flush_buf()
parsed_content.append(_ggb_node_from_attrs(part["attrs"]))
else:
buf_runs.extend(parse_paragraph_runs_color_aware(part["text"]))
elif node["type"] == "URL":
flush_buf()
parsed_content.append({'type': 'URL', 'text': node['text'], 'href': node['href']})
elif node["type"] == "picture":
flush_buf()
parsed_content.append({'type': 'picture', 'alt': node['alt'], 'src': node['src']})
flush_buf()
return parsed_content
# ---------------------------------------------------------------------------
# 文件級解析
# ---------------------------------------------------------------------------
def parse_document(entire_markdown_text: str, *, root_md: str = "") -> list[dict]:
"""
最頂層解析器:識別所有特殊區塊(card/solution/quiz/flow/ggb...),
其餘文字交給 parse_content_block。
root_md:整份原始 MD(供 AI prompt 用);若空則用 entire_markdown_text。
"""
_full_md = root_md or _ROOT_MD or entire_markdown_text
parsed_results: list[dict] = []
last_match_end = 0
pattern = re.compile(
r'<!--\s*flow[^>]*-->.*?<!--\s*flow_end\s*-->|'
r'<!--\s*quiz[^>]*-->.*?<!--\s*quiz_end\s*-->|'
r'<!--\s*card[^>]*-->.*?<!--\s*card_end\s*-->|'
r'<!--\s*solution[^>]*-->.*?<!--\s*solution_end\s*-->|'
r'<!--\s*pyfunc[^>]*-->|'
r'<!--CH\d+-P\d+-->|'
r'<!--\s*互動\s*-->.*?<!---?\s*互動end\s*-->|'
r'<!--\s*選擇[^>]*-->.*?<!--\s*選擇end\s*-->|'
r'<!--\s*(?:mcq|choice)[^>]*-->.*?<!--\s*(?:mcq|choice)(?:_end|end)\s*-->|'
r'<!--\s*填空[^>]*-->.*?<!--\s*填空end\s*-->|'
r'<!--\s*fill[^>]*-->.*?<!--\s*fill(?:_end|end)\s*-->|'
r'<!--\s*problem[^>]*-->|'
r'^\s*<!--\s*icon[^>]*-->\s*$|'
r'<!--\s*ggb[^>]*-->',
re.DOTALL,
)
for match in re.finditer(pattern, entire_markdown_text):
start, end = match.span()
pre_text = entire_markdown_text[last_match_end:start]
if pre_text:
parsed_results.extend(parse_content_block(pre_text))
content = match.group(0)
if content.lstrip().startswith('<!--card'):
parsed_results.append(parse_card_block(content))
elif content.lstrip().startswith('<!--solution'):
sol_node = parse_solution_block(content)
if isinstance(sol_node, dict) and sol_node.get("type") == "solution":
ai_md = build_ai_prompt_for_solution_pos(_full_md, start)
if ai_md:
sol_node["ai_prompt_md"] = ai_md
parsed_results.append(sol_node)
elif content.lstrip().startswith('<!--CH'):
parsed_results.append({'type': 'comment', 'content': content})
elif content.lstrip().startswith('<!--problem'):
parsed_results.append({'type': 'comment', 'content': content})
elif content.lstrip().startswith('<!--pyfunc'):
parsed_results.append(parse_pyfunc_marker(content))
elif re.match(r'^\s*<!--\s*(互動|選擇|填空|fill|choice|mcq)\b', content):
quiz_node = parse_interactive_block(content)
if isinstance(quiz_node, dict) and quiz_node.get("type") == "quiz":
ai_md = build_ai_prompt_for_pos(_full_md, start)
if ai_md:
quiz_node["ai_prompt_md"] = ai_md
if not quiz_node.get('prompt_runs') and parsed_results:
i = len(parsed_results) - 1
while i >= 0 and parsed_results[i].get('type') == 'br':
i -= 1
if i >= 0 and parsed_results[i].get('type') == 'paragraph':
prev = parsed_results[i]
runs = prev.get('runs') or build_inline_runs(prev.get('content', ''))
if prev.get('problem_tag'):
quiz_node['prompt_runs'] = runs
else:
prev = parsed_results.pop(i)
quiz_node['prompt_runs'] = prev.get('runs') or build_inline_runs(prev.get('content', ''))
parsed_results.append(quiz_node)
elif content.lstrip().startswith('<!--quiz'):
parsed_results.extend(parse_quizzes(content))
elif content.lstrip().startswith('<!--flow'):
parsed_results.append(parse_flow_block(content))
elif content.lstrip().startswith('<!--icon'):
parsed_results.append(parse_icon_marker(content))
elif content.lstrip().startswith('<!--ggb'):
parsed_results.append(parse_geogebra_marker(content))
else:
parsed_results.append({'type': 'unknown', 'content': content})
last_match_end = end
tail_text = entire_markdown_text[last_match_end:]
if tail_text:
parsed_results.extend(parse_content_block(tail_text))
# 補 Example heading 的 example_seq + ai_prompt_md
example_seq = 0
for node in parsed_results:
if node.get("type") != "heading":
continue
level = int(node.get("level", 0) or 0)
txt = (node.get("content") or "").strip()
if level == 3 and txt.lower().startswith("example"):
example_seq += 1
node["example_seq"] = example_seq
node["ai_prompt_md"] = build_ai_prompt_for_example(_full_md, example_seq)
return parsed_results