From b060e4094d2c2e932c3bc47d1cf8522bcb34ab49 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 19 Jul 2026 14:15:36 +0800 Subject: [PATCH] fix: sharpen and contain markdown math rendering --- CHANGELOG.md | 14 ++ nebula_app/res/math.f.glsl | 3 + nebula_app/src/display/markdown_view.rs | 165 +++++++++++++++++++++++- nebula_app/src/markdown/parser.rs | 147 ++++++++++++++++++++- nebula_app/src/math/rasterizer.rs | 18 ++- nebula_app/src/renderer/math.rs | 8 +- 6 files changed, 345 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7366575..e8318f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Every release entry is provided in English and Simplified Chinese. #### Added +- **Native mathematics in Markdown documents** — inline $...$ and display $$...$$ formulas are parsed and laid out entirely in Rust, then drawn as cached glyphs and rule quads with the bundled Latin Modern Math font. Fractions, roots, scripts, limits, matrices, scalable delimiters, Greek letters, common operators, and Unicode prose inside formulas work without a WebView, JavaScript runtime, image export, or external TeX process. - **A built-in font fallback** — Maple Mono Normal NF CN is now embedded in Nebula as a runtime fallback, so the interface remains readable when the system font has not been installed. The system-installed font is still preferred, the installation reminder remains dismissible, and the portable archive still includes the original font under `fonts` for normal installation. - **Choose the directory shown in Files and Git** — the Files drawer can now select a custom root with the native folder picker, and the Git view follows the same root. The choice lasts until the current window closes; “Follow current directory” immediately returns to the focused terminal directory. - **Four SSH sign-in modes** — each saved host can now use Automatic, Password, Private Key, or Keyboard-interactive authentication. Automatic mode tries configured keys before password and interactive prompts, while an explicitly selected mode stays strict and does not silently switch methods. @@ -20,12 +21,18 @@ Every release entry is provided in English and Simplified Chinese. #### Fixed +- **Markdown always stays inside its reading column** — paragraphs wrap at words and CJK characters, overlong unbroken text is hard-wrapped, failed formulas wrap as visible TeX source, and oversized native formulas are fitted to the existing 860 px reading column instead of being clipped. +- **Multiline display formulas survive blank rows** — standalone $$ fences can now contain blank lines and explanatory Unicode prose. Code fences and YAML metadata remain untouched, while the math block keeps a range into the original UTF-8 source. +- **Document typography is more consistent and math is clearer** — Markdown headings use the regular CJK outline with strong theme ink instead of uneven synthetic bold, and math glyphs use pixel-aligned placement plus contrast-corrected grayscale coverage for sharper small-size strokes. +- **Paste stays in the split pane that started it** — right-click paste, Ctrl+V, and multiline-paste confirmation retain the originating pane identity. The confirmation overlay no longer lets mouse coordinates or Enter redirect the text into a neighboring split. +- **Numpad Enter behaves like the main Enter key** — the numeric keypad Enter key now follows the normal command-submission path instead of being routed through the paste action. - **The selected default shell now applies at launch** — the first terminal pane reads the same default-shell setting as a new tab. An explicit command-line command still takes priority, so setting PowerShell 7 no longer starts with Windows PowerShell and switches only after opening another tab. - **Resize no longer loses bottom rows or moves the input cursor** — the terminal grid, pane layout, renderer, and ConPTY now share the final row and column count after stretching, maximizing, showing an error, or opening search. Reserved message/search rows are no longer accidentally added back before the PTY resize. - **Safer maximized and fullscreen boundaries** — startup maximize/fullscreen is applied only after the first visible frame, character resize increments are cleared for those states, and the invisible resize border no longer captures edge clicks while the window cannot be resized. #### Improved +- **Bounded math memory and frame cost** — formula parsing, node counts, nesting, matrix cells, draw operations, glyph dimensions, the layout cache, and the fixed-size glyph atlas all have explicit limits. Only visible document lines are expanded into draw work, and repeated formulas reuse cached layouts and glyphs. - **A cleaner portable archive** — the ZIP root now contains only `nebula.exe` and `README.md`. Runtime helpers, fonts, documentation, and licenses are grouped under `runtime`, `fonts`, `docs`, and `licenses`, and Nebula resolves the new runtime layout automatically. - **Safer transfers** — uploads and downloads stream in 256 KiB chunks instead of loading whole files into memory. Files are written to temporary paths first and moved into place only after the transfer succeeds, while failed or cancelled transfers clean up their partial files. - **A much smaller download** — the portable ZIP is now about half its previous size, while keeping the bundled font, ConPTY runtime, notification helper, documentation, and licenses intact. @@ -34,6 +41,7 @@ Every release entry is provided in English and Simplified Chinese. #### 新增 +- **Markdown 文档支持原生数学公式** — 行内 $...$ 和块级 $$...$$ 公式现在全部由 Rust 解析和排版,再使用内置 Latin Modern Math 字体以缓存字形和规则线 quad 直接绘制。分数、根式、上下标、极限、矩阵、伸缩括号、希腊字母、常用运算符以及公式中的 Unicode 说明文字都可以显示,整个过程不依赖 WebView、JavaScript、公式图片或外部 TeX 进程。 - **内置了字体显示兜底** — Maple Mono Normal NF CN 现在会随主程序一起内置。即使用户忘了安装系统字体,界面也能正常显示;已经安装的系统字体仍然优先,缺少字体时的提醒依旧可以关闭,便携包里的 `fonts` 目录和原始字体文件也继续保留,方便按正常方式安装。 - **文件和 Git 可以切换查看目录了** — 文件抽屉新增原生目录选择器,选中的目录也会同步用于 Git 页面。这个选择只保留到当前窗口关闭;点击“跟随”后会立即重新跟随当前终端所在目录。 - **四种 SSH 登录方式** — 每个已保存主机都可以选择自动、密码、密钥或交互式认证。自动模式会先尝试配置的私钥,再尝试密码和交互式提示;手动选择某种方式后则只使用该方式,不会失败后悄悄切换。 @@ -44,12 +52,18 @@ Every release entry is provided in English and Simplified Chinese. #### 修复 +- **Markdown 内容始终留在阅读列内** — 普通段落会按单词和中文字符换行,连续超长文本会强制折行,解析失败的公式会以可见 TeX 源码继续换行,过宽的原生公式则会收敛到现有 860 px 阅读列内,不再被右侧裁掉。 +- **块级公式可以跨越空行** — 独立 $$ 围栏现在可以包含空行和 Unicode 说明文字;代码围栏与 YAML 元数据保持原样,数学块仍然引用原始 UTF-8 源码范围。 +- **文档字重更统一,数学字形更清楚** — Markdown 标题改用规则 CJK 轮廓配合主题强调色,避开局部粗细不一的合成粗体;数学字形增加整像素定位和灰度覆盖补偿,小字号细笔画更清晰。 +- **粘贴会留在发起操作的分屏** — 右键粘贴、Ctrl+V 和多行粘贴确认都会保留原始 pane 身份;确认层显示时,鼠标坐标和 Enter 也不会再把内容重定向到相邻分屏。 +- **数字小键盘 Enter 与主 Enter 一致** — 小键盘最右侧 Enter 现在走正常命令提交路径,不再进入粘贴动作。 - **启动时也会使用用户选择的默认 Shell** — 第一个终端面板现在和新标签页读取同一份默认 Shell 设置;命令行明确指定的命令仍然优先。选择 PowerShell 7 后,不会再先打开 Windows PowerShell,只有新标签页才变成 7。 - **拉伸窗口后底部和输入光标不再错位** — 拉伸、最大化、显示错误提示或打开搜索后,终端网格、面板布局、渲染器和 ConPTY 会共用最终行列数;消息栏和搜索栏已经占用的行不会在 PTY resize 前被错误加回来。 - **最大化和全屏的窗口边界更稳了** — 启动时会等首帧可见后再应用最大化或全屏,这两种状态下会取消字符级拉伸步进,也不会再让不可用的隐藏 resize 边框抢走边缘点击。 #### 改进 +- **数学内存和每帧开销都有明确上限** — 公式源码、解析事件、节点数、嵌套深度、矩阵单元格、绘制操作、字形尺寸、布局缓存和固定大小字形图集都设置了边界;文档只为可见区域生成绘制工作,重复公式会复用布局与字形缓存。 - **便携包不再乱糟糟** — ZIP 根目录现在只保留 `nebula.exe` 和 `README.md`;运行组件、字体、文档和许可证分别放进 `runtime`、`fonts`、`docs` 与 `licenses`,Nebula 会自动识别新的运行组件位置。 - **传文件更稳妥** — 上传和下载按 256 KiB 分块流式传输,不会把整个大文件一次性塞进内存。文件会先写入临时位置,完整成功后才替换正式文件;失败或取消时也会清理未完成的临时文件。 - **下载包瘦了一半左右** — 字体、ConPTY 运行组件、通知助手、文档和许可证都还在,便携包体积从约 33.2 MB 降到了约 16.8 MB。 diff --git a/nebula_app/res/math.f.glsl b/nebula_app/res/math.f.glsl index 367d3ae..3eea050 100644 --- a/nebula_app/res/math.f.glsl +++ b/nebula_app/res/math.f.glsl @@ -19,5 +19,8 @@ void main() { #else float coverage = texture(uTexture, uv).a; #endif + // The atlas stores grayscale coverage rather than premultiplied color. + // Keep the RGB fully opaque so the existing straight-alpha blend state + // uses the coverage as the glyph's actual opacity. FRAG_COLOR = vec4(uColor.rgb, uColor.a * coverage); } diff --git a/nebula_app/src/display/markdown_view.rs b/nebula_app/src/display/markdown_view.rs index 40c0eb7..ace2388 100644 --- a/nebula_app/src/display/markdown_view.rs +++ b/nebula_app/src/display/markdown_view.rs @@ -59,6 +59,9 @@ const OVERSCAN_LINES: usize = 8; /// Body line height, in cell heights. const BODY_LINE: f32 = 1.55; const CODE_LINE: f32 = 1.4; +/// Below this size a fitted formula stops being readable; the existing +/// source-text fallback then wraps it normally instead of clipping tiny ink. +const MIN_FITTED_MATH_PX: f32 = 6.0; /// Per-level indent of lists and quotes, in px. const LIST_INDENT: f32 = 26.0; const QUOTE_INDENT: f32 = 20.0; @@ -84,6 +87,8 @@ struct Span { text: String, cols: usize, bold: bool, + /// Use strong theme ink without selecting a synthetic bold font face. + strong_ink: bool, italic: bool, strike: bool, code: bool, @@ -418,7 +423,19 @@ fn wrap_inline_with_math( pixels_per_point, false, cache, - ); + ) + .and_then(|run| { + fit_math_run( + source.clone(), + run, + current_id, + pixel_size, + pixels_per_point, + false, + max_width, + cache, + ) + }); match run { Some(run) => { let width = run.metrics.width; @@ -500,6 +517,31 @@ fn measure_math( }) } +#[allow(clippy::too_many_arguments)] +fn fit_math_run( + source: MathSource, + run: MathRun, + formula_id: u64, + pixel_size: f32, + pixels_per_point: f32, + display: bool, + max_width: f32, + cache: &mut MathLayoutCache, +) -> Option { + if run.advance_width <= max_width { + return Some(run); + } + + // Math layout is linear in pixel size. Leave a small rounding margin so + // the rightmost antialiasing pixel stays inside the reading column. + let fitted_size = pixel_size * (max_width / run.advance_width) * 0.98; + if fitted_size < MIN_FITTED_MATH_PX { + return None; + } + measure_math(source, formula_id, fitted_size, pixels_per_point, display, cache) + .filter(|fitted| fitted.advance_width <= max_width) +} + /// Regroup a wrapped char slice back into style spans. Trailing spaces are /// dropped (they carry no width at a break point); leading whitespace stays — /// code lines depend on their indentation. @@ -528,6 +570,7 @@ impl Span { text: String::new(), cols: 0, bold: styles.weight.is_some_and(|weight| weight.is_at_least_bold()), + strong_ink: false, italic: styles.italic, strike: styles.strikethrough, code: styles.inline_code, @@ -556,6 +599,7 @@ impl Span { text: String::new(), cols: 0, bold: false, + strong_ink: false, italic: false, strike: false, code: false, @@ -573,6 +617,7 @@ impl Span { text, cols, bold: false, + strong_ink: false, italic: false, strike: false, code: false, @@ -673,7 +718,11 @@ impl LayoutCtx<'_> { let count = wrapped.len(); for (i, mut spans) in wrapped.into_iter().enumerate() { for span in &mut spans { - span.bold = true; + // Windows synthesizes bold when only the bundled + // Regular CJK face is available. Complex glyphs then + // develop visibly uneven strokes, so headings keep the + // real Regular outline and use strong theme ink. + span.strong_ink = true; } let mut decor = decor; // Typora-style underline on H1/H2, on the last wrap line. @@ -746,14 +795,28 @@ impl LayoutCtx<'_> { self.y += ch * 0.35; let formula_id = self.next_formula_id; self.next_formula_id = self.next_formula_id.wrapping_add(1); - if let Some(run) = measure_math( + let max_math_width = (self.content_w - indent).max(self.cell_w * 4.0); + let run = measure_math( math.clone(), formula_id, self.font_pixel_size, self.pixels_per_point, true, self.math_cache, - ) { + ) + .and_then(|run| { + fit_math_run( + math.clone(), + run, + formula_id, + self.font_pixel_size, + self.pixels_per_point, + true, + max_math_width, + self.math_cache, + ) + }); + if let Some(run) = run { let top_pad = ch * 0.45; let bottom_pad = ch * 0.45; let baseline = top_pad + run.metrics.height; @@ -1353,7 +1416,7 @@ pub fn draw( skin.ink_strong } else if span.faint { skin.ink_faint - } else if span.bold { + } else if span.bold || span.strong_ink { skin.ink_strong } else if span.italic { skin.ink_dim @@ -1557,6 +1620,20 @@ mod tests { assert!(math[0].metrics.height > 0.0 && math[0].metrics.depth > 0.0); } + #[test] + fn oversized_inline_math_is_fitted_before_it_can_cross_the_column() { + let parsed = crate::markdown::parse_markdown(r"before $\frac{a+b+c+d+e}{x+y+z+w+q}$ after"); + let FormattedTextLine::Line(inline) = &parsed.lines[0] else { panic!() }; + let mut cache = MathLayoutCache::default(); + let mut formula_id = 0; + let max_width = 96.0; + let lines = + wrap_inline_with_math(inline, max_width, 8.0, &mut formula_id, 16.0, 1.0, &mut cache); + + assert!(lines.iter().flatten().any(|span| span.math.is_some())); + assert!(lines.iter().all(|line| { line_width_for_test(line, 8.0) <= max_width })); + } + #[test] fn display_math_uses_real_height_and_centers_in_the_column() { let markdown = "$$\\frac{x+1}{\\sqrt{y}}$$"; @@ -1579,6 +1656,25 @@ mod tests { assert!(line.spans[0].math.is_some()); } + #[test] + fn headings_use_strong_ink_without_forcing_synthetic_cjk_bold() { + let mut doc = DocView { + path: PathBuf::from("heading.md"), + title: "heading.md".into(), + blocks: crate::markdown::parse_markdown("# 微积分").lines.into_iter().collect(), + visual: Vec::new(), + wrap_key: WrapKey::default(), + scroll: 0.0, + content_h: 0.0, + math_cache: MathLayoutCache::default(), + }; + doc.relayout(600.0, 8.0, 16.0, 8.0, 16.0, 1.0, 12.0, 1.0); + let span = &doc.visual[0].spans[0]; + + assert!(span.strong_ink); + assert!(!span.bold); + } + #[test] fn invalid_math_keeps_source_text_visible() { let parsed = crate::markdown::parse_markdown("before $\\def\\x{1}$ after"); @@ -1657,6 +1753,65 @@ mod tests { assert!(!layout.text.is_empty()); } + #[test] + fn auxiliary_angle_formula_with_blank_rows_and_prose_uses_native_layout() { + let markdown = concat!( + "#### 辅助角公式\n\n", + "$$\n", + r"\sin x + \cos x = \sqrt{2} \sin(x + \frac{\pi}{4}) \\", + "\n\n", + r"\sin x + \cos x = \sqrt{2} \cos(x - \frac{\pi}{4}) \\", + "\n\n", + r"a \sin x + b \cos x = \sqrt{a^2 + b^2} \cos(x - \varphi) \quad ", + r"\left(b > 0, \varphi \in \left(-\frac{\pi}{2}, \frac{\pi}{2}\right), ", + r"\tan \varphi = \frac{a}{b}\right)", + "\n\n", + r"这里使用了和角公式:\\", + "\n", + r"\sin(a + b) = \sin a \cos b + \cos a \sin b\\", + "\n", + r"将 a = x 和 b = \frac{\pi}{4} 代入,因为 ", + r"\sin\frac{\pi}{4} = \cos\frac{\pi}{4} = \frac{\sqrt{2}}{2},所以:\\", + "\n", + r"\sin(a+\frac{\pi}{4})= \sin x \cdot \frac{\sqrt{2}}{2} ", + r"+ \cos x \cdot \frac{\sqrt{2}}{2}\\", + "\n", + r"= \frac{\sqrt{2}}{2}(\sin x + \cos x)\\", + "\n", + r"(\sin x + \cos x) = \sqrt{2}\sin\left(x + \frac{\pi}{4}\right) \\", + "\n", + "同时乘以根号 2 即可得出\n", + "$$", + ); + let mut doc = DocView { + path: PathBuf::from("auxiliary-angle.md"), + title: "auxiliary-angle.md".into(), + blocks: crate::markdown::parse_markdown(markdown).lines.into_iter().collect(), + visual: Vec::new(), + wrap_key: WrapKey::default(), + scroll: 0.0, + content_h: 0.0, + math_cache: MathLayoutCache::default(), + }; + doc.relayout(860.0, 8.0, 16.0, 8.0, 16.0, 1.0, 12.0, 1.0); + + assert!( + doc.visual.iter().any(|line| { + line.center_math && line.spans.iter().any(|span| span.math.is_some()) + }) + ); + assert!( + doc.visual + .iter() + .all(|line| { !line.spans.iter().any(|span| span.text.starts_with("$$")) }) + ); + assert!(doc.visual.iter().all(|line| { + line.spans + .iter() + .all(|span| span.math.as_ref().is_none_or(|run| run.advance_width <= 860.0)) + })); + } + #[test] fn table_cells_keep_math_as_native_objects() { let markdown = "| expression | value |\n| --- | --- |\n| $x^2$ | $\\frac{1}{2}$ |"; diff --git a/nebula_app/src/markdown/parser.rs b/nebula_app/src/markdown/parser.rs index 8ddfd3e..bba69a3 100644 --- a/nebula_app/src/markdown/parser.rs +++ b/nebula_app/src/markdown/parser.rs @@ -6,6 +6,7 @@ //! This file owns ONLY the event folding. The AST lives in `super`, the //! on-screen layout (wrapping, virtual scrolling) in `display::markdown_view`. +use std::borrow::Cow; use std::ops::Range; use std::sync::Arc; @@ -28,13 +29,94 @@ pub fn parse_markdown(text: &str) -> FormattedText { options.insert(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS); options.insert(Options::ENABLE_MATH); + let parser_input = normalize_standalone_display_math(text); let mut fold = Fold::new(Arc::from(text)); - for (event, range) in Parser::new_ext(text, options).into_offset_iter() { + for (event, range) in Parser::new_ext(parser_input.as_ref(), options).into_offset_iter() { fold.event(event, range); } fold.finish() } +/// CommonMark paragraphs stop at blank lines, while real-world `$$` blocks +/// often contain them. Replace only line endings inside paired standalone +/// math fences with spaces in a temporary, byte-for-byte buffer. This keeps +/// pulldown-cmark's offsets valid, and the folded document still points at the +/// untouched UTF-8 source. Fenced code and YAML metadata retain their lines. +fn normalize_standalone_display_math(text: &str) -> Cow<'_, str> { + let source = text.as_bytes(); + let mut normalized: Option> = None; + let mut math_content_start = None; + let mut code_fence: Option<(u8, usize)> = None; + let mut in_metadata = false; + let mut line_start = 0usize; + + while line_start < source.len() { + let newline = source[line_start..] + .iter() + .position(|byte| *byte == b'\n') + .map_or(source.len(), |relative| line_start + relative); + let content_end = if newline > line_start && source[newline - 1] == b'\r' { + newline - 1 + } else { + newline + }; + let line = &text[line_start..content_end]; + let indent = line.bytes().take_while(|byte| *byte == b' ').count(); + let block = (indent <= 3).then(|| &line[indent..]); + + if line_start == 0 && block == Some("---") { + in_metadata = true; + } else if in_metadata { + if matches!(block, Some("---" | "...")) { + in_metadata = false; + } + } else if let Some(content_start) = math_content_start { + if block == Some("$$") { + let output = normalized.get_or_insert_with(|| source.to_vec()); + for byte in &mut output[content_start..line_start] { + if matches!(*byte, b'\r' | b'\n') { + *byte = b' '; + } + } + math_content_start = None; + } + } else if let Some((marker, minimum)) = code_fence { + if block.is_some_and(|content| closes_code_fence(content, marker, minimum)) { + code_fence = None; + } + } else if let Some(content) = block { + if let Some(fence) = opens_code_fence(content) { + code_fence = Some(fence); + } else if content == "$$" { + math_content_start = Some(content_end); + } + } + + if newline == source.len() { + break; + } + line_start = newline + 1; + } + + normalized.map_or(Cow::Borrowed(text), |bytes| { + Cow::Owned(String::from_utf8(bytes).expect("ASCII newline replacement preserves UTF-8")) + }) +} + +fn opens_code_fence(line: &str) -> Option<(u8, usize)> { + let marker = *line.as_bytes().first()?; + if !matches!(marker, b'`' | b'~') { + return None; + } + let length = line.bytes().take_while(|byte| *byte == marker).count(); + (length >= 3).then_some((marker, length)) +} + +fn closes_code_fence(line: &str, marker: u8, minimum: usize) -> bool { + let length = line.bytes().take_while(|byte| *byte == marker).count(); + length >= minimum && line[length..].trim().is_empty() +} + /// A list item whose first-line text is still being collected. Flushed into /// an Ordered/Unordered/Task list block by the item's end — or earlier, when /// a nested block (sub-list, code block) interrupts the item. @@ -157,7 +239,7 @@ impl Fold { )); }, Event::DisplayMath(math) => { - let source = self.source_ref(range, &math); + let source = self.display_math_source_ref(range, &math); self.flush_inline_as_paragraph(); self.push_block(FormattedTextLine::DisplayMath(MathSource { source, @@ -392,6 +474,33 @@ impl Fold { .unwrap_or_else(|| TextRef::generated(rendered.to_owned().into_boxed_str())) } + fn display_math_source_ref(&self, outer: Range, rendered: &str) -> TextRef { + if let Some(range) = self.source.get(outer.clone()).and_then(|outer_text| { + outer_text.find(rendered).map(|offset| { + let start = outer.start + offset; + start..start + rendered.len() + }) + }) { + return TextRef::source(self.source.clone(), range) + .unwrap_or_else(|| TextRef::generated(rendered.to_owned().into_boxed_str())); + } + + let Some(outer_text) = self.source.get(outer.clone()) else { + return TextRef::generated(rendered.to_owned().into_boxed_str()); + }; + let leading = outer_text.len() - outer_text.trim_start().len(); + let trimmed = outer_text.trim(); + if !trimmed.starts_with("$$") || !trimmed.ends_with("$$") || trimmed.len() < 4 { + return TextRef::generated(rendered.to_owned().into_boxed_str()); + } + let inner = &trimmed[2..trimmed.len() - 2]; + let inner_leading = inner.len() - inner.trim_start().len(); + let inner_trimmed = inner.trim(); + let start = outer.start + leading + 2 + inner_leading; + TextRef::source(self.source.clone(), start..start + inner_trimmed.len()) + .unwrap_or_else(|| TextRef::generated(rendered.to_owned().into_boxed_str())) + } + /// Emit the pending inline run as a plain paragraph block, if any. fn flush_inline_as_paragraph(&mut self) { if self.inline.is_empty() { @@ -481,6 +590,40 @@ mod tests { assert_eq!(&document.source[range.as_usize()], "x^2"); } + #[test] + fn standalone_display_math_crosses_blank_lines_without_copying_its_source() { + let source = concat!( + "#### 辅助角公式\n\n", + "$$\n", + r"\sin x + \cos x = \sqrt{2} \\", + "\n", + "\n", + r"这里使用了和角公式:\\", + "\n", + "\\sin(a + b) = \\sin a \\cos b + \\cos a \\sin b\n", + "$$\n", + ); + let document = parse_markdown(source); + let FormattedTextLine::DisplayMath(math) = &document.lines[1] else { + panic!("expected one display-math block: {:?}", document.lines); + }; + + assert!(math.source.as_str().contains("\n\n这里使用了和角公式")); + let TextRef::Source { source, .. } = &math.source else { + panic!("display math must retain an original source range"); + }; + assert!(Arc::ptr_eq(source, &document.source)); + } + + #[test] + fn standalone_math_normalization_skips_fenced_code_and_yaml_metadata() { + let source = concat!( + "---\nexample: |\n $$\n metadata\n $$\n---\n\n", + "```text\n$$\ncode\n\nblock\n$$\n```\n", + ); + assert!(matches!(normalize_standalone_display_math(source), Cow::Borrowed(_))); + } + #[test] fn escaped_dollars_and_code_do_not_become_math() { let document = parse_markdown(r"escaped \$x$ and `$code$`"); diff --git a/nebula_app/src/math/rasterizer.rs b/nebula_app/src/math/rasterizer.rs index ddbab42..8b0e418 100644 --- a/nebula_app/src/math/rasterizer.rs +++ b/nebula_app/src/math/rasterizer.rs @@ -8,6 +8,10 @@ use super::{MathError, MathErrorKind}; const GLYPH_PADDING: f32 = 1.0; const MAX_GLYPH_DIMENSION: u32 = 512; +/// Grayscale math glyphs do not receive DirectWrite's subpixel contrast. +/// A modest coverage curve keeps thin Latin Modern strokes legible at the +/// document's normal text size without allocating a supersampled bitmap. +const COVERAGE_GAMMA: f32 = 0.75; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct RasterizedMathGlyph { @@ -77,7 +81,7 @@ impl MathGlyphRasterizer { rgba[offset] = 255; rgba[offset + 1] = 255; rgba[offset + 2] = 255; - rgba[offset + 3] = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8; + rgba[offset + 3] = coverage_alpha(alpha); }); Ok(RasterizedMathGlyph { width: width as u16, @@ -89,6 +93,10 @@ impl MathGlyphRasterizer { } } +fn coverage_alpha(alpha: f32) -> u8 { + (alpha.clamp(0.0, 1.0).powf(COVERAGE_GAMMA) * 255.0).round() as u8 +} + fn checked_i16(value: f32) -> Result { if value < i16::MIN as f32 || value > i16::MAX as f32 { Err(MathError::new(MathErrorKind::GlyphTooLarge, 0)) @@ -195,4 +203,12 @@ mod tests { MathErrorKind::GlyphTooLarge ); } + + #[test] + fn coverage_curve_preserves_endpoints_and_strengthens_thin_edges() { + assert_eq!(coverage_alpha(0.0), 0); + assert_eq!(coverage_alpha(1.0), 255); + assert!(coverage_alpha(0.25) > 64); + assert!(coverage_alpha(0.25) < coverage_alpha(0.5)); + } } diff --git a/nebula_app/src/renderer/math.rs b/nebula_app/src/renderer/math.rs index bf5d66d..e67597c 100644 --- a/nebula_app/src/renderer/math.rs +++ b/nebula_app/src/renderer/math.rs @@ -195,8 +195,12 @@ impl MathRenderer { } }, }; - let x = origin_x + op.x + glyph.left as f32; - let y = baseline_y + op.baseline_y - glyph.top as f32; + // The atlas already contains antialiased coverage. Sampling it + // again from fractional screen coordinates softens every edge a + // second time, so snap only the final bitmap origin while keeping + // TeX advances and rule geometry at full precision. + let x = (origin_x + op.x + glyph.left as f32).round(); + let y = (baseline_y + op.baseline_y - glyph.top as f32).round(); self.push_clipped_quad(size, x, y, glyph, clip); } self.flush(size, color);