Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |