diff --git a/input.css b/input.css index 42bcbef..95df0ea 100644 --- a/input.css +++ b/input.css @@ -445,6 +445,69 @@ margin-bottom: 0; } + /* 脚注引用上标:[^id] 渲染成的 。 + * line-height: 0 防止上标撑高所在行行高;font-size 缩小为 0.75em。 */ + .md-content .fn-ref { + font-size: 0.75em; + line-height: 0; + vertical-align: super; + margin-inline-start: 0.1em; + } + + /* 引用数字链接:不加下划线(上标下划线极难看),用 accent 色区分。 */ + .md-content .fn-ref-link { + text-decoration: none; + color: var(--color-paper-accent); + } + + .md-content .fn-ref-link:hover { + color: var(--color-paper-accent-2); + } + + /* 脚注定义块:仿 blockquote 的左侧边框视觉语言,但更轻(0.2rem + 稍小字号)。 */ + .md-content .footnote-definition { + margin: 1rem 0; + padding-inline-start: 1rem; + border-inline-start: 0.2rem solid var(--color-paper-content); + font-size: 0.875em; + color: var(--color-paper-secondary); + } + + .md-content .footnote-definition:last-child { + margin-bottom: 0; + } + + /* 定义内末段去底距,避免与 back-link 间距过大。 */ + .md-content .footnote-definition > p:last-child { + margin-bottom: 0; + } + + /* 定义编号上标:与正文引用数字呼应。 */ + .md-content .footnote-definition-label { + font-size: 0.75em; + line-height: 0; + vertical-align: super; + margin-inline-end: 0.25em; + color: var(--color-paper-tertiary); + } + + /* 返回正文链接:↩ 符号,弱色,hover 转 accent。 */ + .md-content .fn-backref { + margin-inline-start: 0.25em; + text-decoration: none; + color: var(--color-paper-tertiary); + } + + .md-content .fn-backref:hover { + color: var(--color-paper-accent); + } + + /* 多引用时的数字上标(↩² 中的 ²)。 */ + .md-content .fn-backref-num { + font-size: 0.75em; + vertical-align: super; + } + .md-content ol, .md-content ul { padding-inline-start: 1.25rem; diff --git a/src/api/markdown.rs b/src/api/markdown.rs index 0588d4b..d577357 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -40,7 +40,17 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { // 两遍遍历使用相同的 Options 与同一份解析结果,避免 TOC 收集与正文渲染对 // Markdown 扩展语法(表格、删除线、脚注等)的处理不一致。 - let opts = Options::all(); + // + // 脚注模式:pulldown-cmark 的 ENABLE_OLD_FOOTNOTES = (1<<9)|(1<<2),它把 + // ENABLE_FOOTNOTES 的 bit 也打进了 OLD 的位掩码里。Options::all() 同时置两者, + // 使 has_gfm_footnotes()(=ENABLE_FOOTNOTES && !ENABLE_OLD_FOOTNOTES)返回 false, + // 走 OLD 模式(续行宽松、label 可含换行)。我们想要 GFM 模式(与 GitHub 一致、 + // 解析可控),所以不能简单地 remove(OLD)——那会连 ENABLE_FOOTNOTES 一起清掉。 + // 正确做法:先 remove(OLD)(清掉 bit 9 + bit 2),再 insert(ENABLE_FOOTNOTES) + // 单独把 bit 2 加回,使 has_gfm_footnotes() = true。 + let mut opts = Options::all(); + opts.remove(Options::ENABLE_OLD_FOOTNOTES); + opts.insert(Options::ENABLE_FOOTNOTES); // pulldown-cmark 只解析一次,collect 成 Vec 后两遍遍历复用。 // 旧实现对同一份 md 调用两次 Parser::new_ext,等于两倍的 tokenize + 解析 CPU。 @@ -52,6 +62,15 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { let mut headings: Vec<(u8, String, String)> = Vec::new(); let mut current_heading: Option<(u8, String)> = None; + // 脚注引用统计:label → (引用次数, 首次出现序号)。 + // pulldown-cmark 不保证定义移到文末、也不保证 ref 先于 def,唯一可靠不变量是 + // 每个唯一 label 的 FootnoteDefinition 只出现一次、FootnoteReference 每次引用触发一次。 + // 所以 back-link 必须按 label 关联,display_num 按 label 首次出现顺序分配。 + // fn_order 记录 label 首次出现顺序,用于分配稳定的显示编号(1, 2, 3…)。 + use std::collections::HashMap; + let mut fn_refs: HashMap = HashMap::new(); + let mut fn_order: Vec = Vec::new(); + for event in &events { match event { Event::Start(Tag::Heading { level, .. }) => { @@ -81,10 +100,28 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { headings.push((lvl, text, id)); } } + // 统计脚注引用:仅对 FootnoteReference 计数(含未被定义的悬空引用)。 + // 悬空引用([^missing] 无定义)也会产生此事件,但第二遍不会有对应 def, + // fn_refs 里的条目无害(查不到对应 def 时第二遍不会输出 back-link)。 + Event::FootnoteReference(name) => { + let label = name.to_string(); + let count = fn_refs.entry(label.clone()).or_insert(0); + *count += 1; + if *count == 1 { + fn_order.push(label); + } + } _ => {} } } + // 按 label 首次出现顺序分配显示编号(1-based)。脚注定义内查此表取 display_num。 + let fn_num: HashMap<&String, usize> = fn_order + .iter() + .enumerate() + .map(|(i, label)| (label, i + 1)) + .collect(); + // 2. Generate TOC HTML let toc_html = generate_toc_html(&headings); @@ -101,6 +138,12 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { let mut code_runnable: Option<(String, String)> = None; let mut code_buffer = String::new(); let mut non_heading_events: Vec = Vec::new(); + // 第二遍维护的脚注引用计数:label → 已渲染的引用序号(从 1 起)。 + // 用于给每个 ref 分配 id 后缀 fnref:{label}-{n},并让 def 末尾的 back-link 对应到每个 ref。 + let mut fn_ref_seen: HashMap = HashMap::new(); + // 脚注定义栈:Start(FootnoteDefinition) 压入 label,End 弹出。 + // 脚注定义可嵌套(def 内引用另一个脚注),用栈保证 End 配对到正确的 label。 + let mut fn_def_stack: Vec = Vec::new(); for event in events { match event { @@ -246,6 +289,79 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { html.push_str(&crate::api::katex::render_display(&tex)); html.push_str("

"); } + Event::FootnoteReference(name) => { + // 先刷出累积的普通事件,再注入脚注引用标记。 + if !non_heading_events.is_empty() { + pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter()); + non_heading_events = Vec::new(); + } + let label = name.to_string(); + // 本 label 的第 n 次引用(1-based),用于 id 后缀。 + let n = { + let entry = fn_ref_seen.entry(label.clone()).or_insert(0); + *entry += 1; + *entry + }; + let id = footnote_id(&label); + // display_num:label 首次出现顺序编号;悬空引用(无 def)查不到时回退到引用序号 n。 + let num = fn_num.get(&label).copied().unwrap_or(n); + // 上标引用:id 供 back-link 回跳,href 跳到定义,role=doc-noteref 语义化。 + let _ = write!( + html, + r##"{num}"## + ); + } + Event::Start(Tag::FootnoteDefinition(name)) => { + // 脚注定义开始:先刷出累积的普通事件,再用