chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "jcode-render-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# Backend-neutral document/render model shared by the TUI and desktop
|
||||
# front-ends. This crate owns markdown parsing, the semantic block/span
|
||||
# model, and width-parameterized wrapping. It has NO dependency on ratatui
|
||||
# or any GPU/glyph layer; front-ends provide thin adapters that turn the
|
||||
# neutral model into their own draw primitives.
|
||||
|
||||
[dependencies]
|
||||
pulldown-cmark = "0.12"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
unicode-width = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,42 @@
|
||||
//! # jcode-render-core
|
||||
//!
|
||||
//! Backend-neutral document/render model shared by jcode's front-ends (the
|
||||
//! ratatui TUI and the desktop GPU UI).
|
||||
//!
|
||||
//! The pipeline is split at the seam where backends actually differ:
|
||||
//!
|
||||
//! ```text
|
||||
//! text ─▶ parse_markdown ─▶ Document (neutral blocks/spans) ─▶ wrap ─▶ adapter ─▶ backend draw
|
||||
//! ```
|
||||
//!
|
||||
//! Everything up to and including wrapping is shared here. Each front-end owns
|
||||
//! only a thin adapter: it resolves [`model::StyleRole`] to concrete colors and
|
||||
//! turns [`model::StyledLine`]s into its own draw primitives (`ratatui::Line`
|
||||
//! for the TUI, glyph runs for the desktop), and supplies a
|
||||
//! [`wrap::WidthMeasure`] for its width units.
|
||||
//!
|
||||
//! This model is extracted from the *working* TUI markdown renderer
|
||||
//! (`jcode-tui-markdown`); that renderer remains authoritative until this core
|
||||
//! reaches parity, after which front-ends migrate onto it.
|
||||
|
||||
pub mod markdown;
|
||||
pub mod math;
|
||||
pub mod model;
|
||||
pub mod preprocess;
|
||||
pub mod reasoning;
|
||||
pub mod wrap;
|
||||
|
||||
pub use markdown::parse_markdown;
|
||||
pub use math::{render_display_latex, render_inline_latex};
|
||||
pub use model::{
|
||||
Alignment, Block, BlockKind, Document, FillRole, StyleRole, StyledLine, StyledSpan, TextAttrs,
|
||||
};
|
||||
pub use preprocess::{escape_currency_dollars, normalize_latex_math};
|
||||
pub use reasoning::{
|
||||
REASONING_SENTINEL, reasoning_line_markup, reasoning_partial_markup,
|
||||
reasoning_summary_line_markup,
|
||||
};
|
||||
pub use wrap::{ColumnWidth, WidthMeasure, wrap_line, wrap_lines};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,684 @@
|
||||
//! Markdown -> backend-neutral [`Document`].
|
||||
//!
|
||||
//! This mirrors the *semantics* of the TUI markdown renderer
|
||||
//! (`jcode-tui-markdown`) but emits the neutral [`crate::model`] types instead
|
||||
//! of `ratatui` spans. Front-ends adapt the document to their backend and may
|
||||
//! wrap it with [`crate::wrap`].
|
||||
//!
|
||||
//! Scope note: this is the shared foundation. It currently covers headings,
|
||||
//! paragraphs, inline emphasis/strong/strike/code, fenced & indented code
|
||||
//! blocks, blockquotes, ordered/unordered (incl. nested) lists, thematic
|
||||
//! breaks, links, raw HTML passthrough, tables, and terminal-friendly math.
|
||||
|
||||
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
|
||||
|
||||
use crate::model::{
|
||||
Alignment, Block, BlockKind, Document, FillRole, StyleRole, StyledLine, StyledSpan, TextAttrs,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct InlineStyle {
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
strike: bool,
|
||||
}
|
||||
|
||||
impl InlineStyle {
|
||||
fn attrs(self) -> TextAttrs {
|
||||
TextAttrs {
|
||||
bold: self.bold,
|
||||
italic: self.italic,
|
||||
strikethrough: self.strike,
|
||||
underline: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn role(self) -> StyleRole {
|
||||
if self.bold {
|
||||
StyleRole::Strong
|
||||
} else {
|
||||
StyleRole::Text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ListFrame {
|
||||
ordered: bool,
|
||||
next_number: u64,
|
||||
/// Block index in `doc.blocks` where this list's content begins, used to
|
||||
/// right-align ordered markers once the list's width is known.
|
||||
start_block: usize,
|
||||
/// Nesting depth of this list (0 = outermost).
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
/// Right-align ordered-list markers within a single list level, mirroring the
|
||||
/// legacy renderer: when the list has multi-digit item numbers, shorter markers
|
||||
/// are padded with leading spaces so the `.` separators line up and wrapped
|
||||
/// continuation lines indent consistently.
|
||||
///
|
||||
/// Only markers at exactly `depth` indentation are touched, so nested lists
|
||||
/// (which carry deeper indentation) are aligned by their own `End(List)`.
|
||||
fn align_ordered_markers(doc: &mut Document, start_block: usize, depth: usize) {
|
||||
let indent = " ".repeat(depth);
|
||||
|
||||
// First pass: find the widest digit run among this level's markers.
|
||||
let mut max_digits = 0usize;
|
||||
for block in doc.blocks.iter().skip(start_block) {
|
||||
if let Some(d) = ordered_marker_digits(block, &indent) {
|
||||
max_digits = max_digits.max(d);
|
||||
}
|
||||
}
|
||||
if max_digits <= 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Second pass: pad shorter markers.
|
||||
for block in doc.blocks.iter_mut().skip(start_block) {
|
||||
let Some(d) = ordered_marker_digits(block, &indent) else {
|
||||
continue;
|
||||
};
|
||||
let extra = max_digits - d;
|
||||
if extra == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(first_span) = block
|
||||
.lines
|
||||
.first_mut()
|
||||
.and_then(|line| line.spans.first_mut())
|
||||
{
|
||||
// Insert padding right after the indent, before the digits.
|
||||
let rest = &first_span.text[indent.len()..];
|
||||
first_span.text = format!("{indent}{}{rest}", " ".repeat(extra));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If `block`'s first span is an ordered-list marker at exactly `indent`
|
||||
/// (i.e. `{indent}{digits}. `), return the digit count.
|
||||
fn ordered_marker_digits(block: &Block, indent: &str) -> Option<usize> {
|
||||
let text = &block.lines.first()?.spans.first()?.text;
|
||||
let rest = text.strip_prefix(indent)?;
|
||||
// Reject deeper-nested markers (extra leading spaces) and non-markers.
|
||||
if rest.starts_with(' ') {
|
||||
return None;
|
||||
}
|
||||
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
if digits == 0 {
|
||||
return None;
|
||||
}
|
||||
if !rest[digits..].starts_with(". ") {
|
||||
return None;
|
||||
}
|
||||
Some(digits)
|
||||
}
|
||||
|
||||
/// The block kind that inline content flushed in the current context belongs
|
||||
/// to, based on enclosing blockquote/list nesting.
|
||||
fn current_kind(blockquote_depth: usize, list_stack: &[ListFrame]) -> BlockKind {
|
||||
if blockquote_depth > 0 {
|
||||
BlockKind::BlockQuote
|
||||
} else if let Some(frame) = list_stack.last() {
|
||||
BlockKind::ListItem {
|
||||
ordered: frame.ordered,
|
||||
depth: list_stack.len().saturating_sub(1),
|
||||
}
|
||||
} else {
|
||||
BlockKind::Paragraph
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse markdown into a backend-neutral [`Document`].
|
||||
pub fn parse_markdown(text: &str) -> Document {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_MATH);
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TASKLISTS);
|
||||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
options.insert(Options::ENABLE_GFM);
|
||||
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||
options.insert(Options::ENABLE_DEFINITION_LIST);
|
||||
|
||||
let normalized = crate::preprocess::normalize_latex_math(text);
|
||||
let escaped = crate::preprocess::escape_currency_dollars(&normalized);
|
||||
let parser = Parser::new_ext(&escaped, options);
|
||||
|
||||
let mut doc = Document::default();
|
||||
|
||||
// Inline accumulation for the block currently being built.
|
||||
let mut spans: Vec<StyledSpan> = Vec::new();
|
||||
let mut style = InlineStyle::default();
|
||||
|
||||
// Block context.
|
||||
let mut heading_level: Option<u8> = None;
|
||||
let mut blockquote_depth = 0usize;
|
||||
let mut list_stack: Vec<ListFrame> = Vec::new();
|
||||
|
||||
// Code block accumulation.
|
||||
let mut in_code_block = false;
|
||||
let mut code_lang: Option<String> = None;
|
||||
let mut code_buf = String::new();
|
||||
|
||||
// Pending list-item marker prefix to emit when the item's first inline
|
||||
// text arrives.
|
||||
let mut pending_item_marker: Option<String> = None;
|
||||
|
||||
// Table accumulation. While `in_table`, inline text is collected into the
|
||||
// current cell (as raw text) rather than styled spans, mirroring the legacy
|
||||
// renderer which lays tables out by width.
|
||||
let mut in_table = false;
|
||||
let mut table_rows: Vec<Vec<String>> = Vec::new();
|
||||
let mut table_row: Vec<String> = Vec::new();
|
||||
let mut current_cell = String::new();
|
||||
|
||||
// Blockquote line accumulation. Legacy emits one rendered line per source
|
||||
// line inside a quote (soft breaks split lines), so we buffer the lines and
|
||||
// emit a single BlockQuote block when the outermost quote closes.
|
||||
let mut bq_lines: Vec<StyledLine> = Vec::new();
|
||||
|
||||
// Link destinations (stack for nesting); appended as a dim ` (url)` suffix
|
||||
// after the link text, mirroring the legacy renderer.
|
||||
let mut link_targets: Vec<String> = Vec::new();
|
||||
// Image alt-text accumulation.
|
||||
let mut in_image = false;
|
||||
let mut image_url: Option<String> = None;
|
||||
let mut image_alt = String::new();
|
||||
|
||||
let push_block = |doc: &mut Document, kind: BlockKind, lines: Vec<StyledLine>| {
|
||||
if !lines.is_empty() {
|
||||
doc.blocks.push(Block::new(kind, lines));
|
||||
}
|
||||
};
|
||||
|
||||
let flush_paragraph = |doc: &mut Document,
|
||||
spans: &mut Vec<StyledSpan>,
|
||||
kind: BlockKind,
|
||||
alignment: Alignment,
|
||||
blockquote_depth: usize,
|
||||
bq_lines: &mut Vec<StyledLine>| {
|
||||
if spans.is_empty() {
|
||||
return;
|
||||
}
|
||||
if blockquote_depth > 0 {
|
||||
// Inside a quote: accumulate as a gutter-prefixed line. The block is
|
||||
// emitted when the outermost quote closes.
|
||||
let mut line = std::mem::take(spans);
|
||||
line.insert(
|
||||
0,
|
||||
StyledSpan::new("│ ".repeat(blockquote_depth), StyleRole::Dim),
|
||||
);
|
||||
bq_lines.push(StyledLine::from_spans(line));
|
||||
return;
|
||||
}
|
||||
let line = StyledLine::aligned(std::mem::take(spans), alignment);
|
||||
push_block(doc, kind, vec![line]);
|
||||
};
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
// ---- block starts ----
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
heading_level = Some(level as u8);
|
||||
}
|
||||
Event::Start(Tag::Paragraph) => {
|
||||
// marker (if any) is emitted lazily on first text
|
||||
}
|
||||
Event::Start(Tag::BlockQuote(_)) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
current_kind(blockquote_depth, &list_stack),
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
blockquote_depth += 1;
|
||||
}
|
||||
Event::Start(Tag::List(first)) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
current_kind(blockquote_depth, &list_stack),
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
let depth = list_stack.len();
|
||||
list_stack.push(ListFrame {
|
||||
ordered: first.is_some(),
|
||||
next_number: first.unwrap_or(1),
|
||||
start_block: doc.blocks.len(),
|
||||
depth,
|
||||
});
|
||||
}
|
||||
Event::Start(Tag::Item) => {
|
||||
let depth = list_stack.len().saturating_sub(1);
|
||||
let indent = " ".repeat(depth);
|
||||
let marker = if let Some(frame) = list_stack.last_mut() {
|
||||
if frame.ordered {
|
||||
let n = frame.next_number;
|
||||
frame.next_number += 1;
|
||||
format!("{indent}{n}. ")
|
||||
} else {
|
||||
format!("{indent}• ")
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
pending_item_marker = Some(marker);
|
||||
}
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
in_code_block = true;
|
||||
code_buf.clear();
|
||||
code_lang = match kind {
|
||||
CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Event::Start(Tag::Emphasis) => style.italic = true,
|
||||
Event::Start(Tag::Strong) => style.bold = true,
|
||||
Event::Start(Tag::Strikethrough) => style.strike = true,
|
||||
Event::Start(Tag::Link { dest_url, .. }) => {
|
||||
link_targets.push(dest_url.to_string());
|
||||
}
|
||||
Event::Start(Tag::Image { dest_url, .. }) => {
|
||||
in_image = true;
|
||||
image_url = Some(dest_url.to_string());
|
||||
image_alt.clear();
|
||||
}
|
||||
|
||||
// ---- footnote definitions ----
|
||||
Event::Start(Tag::FootnoteDefinition(label)) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
spans.push(StyledSpan::new(format!("[^{label}]: "), StyleRole::Dim));
|
||||
}
|
||||
Event::End(TagEnd::FootnoteDefinition) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- definition lists ----
|
||||
Event::Start(Tag::DefinitionListTitle) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
spans.push(StyledSpan::new("• ".to_string(), StyleRole::Dim));
|
||||
}
|
||||
Event::End(TagEnd::DefinitionListTitle) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
}
|
||||
Event::Start(Tag::DefinitionListDefinition) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
spans.push(StyledSpan::new(" -> ".to_string(), StyleRole::Dim));
|
||||
}
|
||||
Event::End(TagEnd::DefinitionListDefinition) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- tables ----
|
||||
Event::Start(Tag::Table(_)) => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
current_kind(blockquote_depth, &list_stack),
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
in_table = true;
|
||||
table_rows.clear();
|
||||
}
|
||||
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => {
|
||||
table_row.clear();
|
||||
}
|
||||
Event::Start(Tag::TableCell) => {
|
||||
current_cell.clear();
|
||||
}
|
||||
Event::End(TagEnd::TableCell) => {
|
||||
table_row.push(current_cell.trim().to_string());
|
||||
current_cell.clear();
|
||||
}
|
||||
Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
|
||||
if !table_row.is_empty() {
|
||||
table_rows.push(std::mem::take(&mut table_row));
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::Table) => {
|
||||
in_table = false;
|
||||
if !table_rows.is_empty() {
|
||||
doc.blocks
|
||||
.push(Block::table(std::mem::take(&mut table_rows)));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- inline content ----
|
||||
Event::Text(t) => {
|
||||
if in_image {
|
||||
image_alt.push_str(&t);
|
||||
} else if in_table {
|
||||
current_cell.push_str(&t);
|
||||
} else if in_code_block {
|
||||
code_buf.push_str(&t);
|
||||
} else {
|
||||
if let Some(marker) = pending_item_marker.take() {
|
||||
spans.push(StyledSpan::new(marker, StyleRole::Dim));
|
||||
}
|
||||
spans.push(StyledSpan {
|
||||
text: t.to_string(),
|
||||
role: style.role(),
|
||||
fill: FillRole::None,
|
||||
attrs: style.attrs(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::Code(t) => {
|
||||
if in_table {
|
||||
current_cell.push_str(&t);
|
||||
} else {
|
||||
if let Some(marker) = pending_item_marker.take() {
|
||||
spans.push(StyledSpan::new(marker, StyleRole::Dim));
|
||||
}
|
||||
spans.push(StyledSpan {
|
||||
text: t.to_string(),
|
||||
role: StyleRole::Code,
|
||||
fill: FillRole::Code,
|
||||
attrs: TextAttrs::none(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::InlineMath(math) => {
|
||||
if in_table {
|
||||
current_cell.push_str(&crate::math::render_inline_latex(&math));
|
||||
} else {
|
||||
if let Some(marker) = pending_item_marker.take() {
|
||||
spans.push(StyledSpan::new(marker, StyleRole::Dim));
|
||||
}
|
||||
spans.push(StyledSpan {
|
||||
text: crate::math::render_inline_latex(&math),
|
||||
role: StyleRole::Math,
|
||||
fill: FillRole::None,
|
||||
attrs: TextAttrs::none(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::DisplayMath(math) => {
|
||||
if in_table {
|
||||
current_cell.push_str(&crate::math::render_inline_latex(&math));
|
||||
} else {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
current_kind(blockquote_depth, &list_stack),
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
let mut lines: Vec<StyledLine> = crate::math::render_display_latex(&math)
|
||||
.into_iter()
|
||||
.map(|l| StyledLine::from_spans(vec![StyledSpan::new(l, StyleRole::Math)]))
|
||||
.collect();
|
||||
if lines.is_empty() {
|
||||
lines.push(StyledLine::from_spans(vec![StyledSpan::new(
|
||||
String::new(),
|
||||
StyleRole::Math,
|
||||
)]));
|
||||
}
|
||||
push_block(&mut doc, BlockKind::MathDisplay, lines);
|
||||
}
|
||||
}
|
||||
Event::FootnoteReference(label) => {
|
||||
let text = format!("[^{label}]");
|
||||
if in_image {
|
||||
image_alt.push_str(&text);
|
||||
} else if in_table {
|
||||
current_cell.push_str(&text);
|
||||
} else {
|
||||
spans.push(StyledSpan::new(text, StyleRole::Dim));
|
||||
}
|
||||
}
|
||||
Event::TaskListMarker(checked) => {
|
||||
let marker = if checked { "[x] " } else { "[ ] " };
|
||||
if in_table {
|
||||
current_cell.push_str(marker);
|
||||
} else {
|
||||
if let Some(m) = pending_item_marker.take() {
|
||||
spans.push(StyledSpan::new(m, StyleRole::Dim));
|
||||
}
|
||||
spans.push(StyledSpan::new(marker.to_string(), StyleRole::Dim));
|
||||
}
|
||||
}
|
||||
Event::SoftBreak | Event::HardBreak => {
|
||||
if in_table {
|
||||
current_cell.push(' ');
|
||||
} else if blockquote_depth > 0 {
|
||||
// Inside a quote, a soft/hard break starts a new quoted line.
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
current_kind(blockquote_depth, &list_stack),
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
} else {
|
||||
spans.push(StyledSpan::plain(" "));
|
||||
}
|
||||
}
|
||||
Event::Html(raw) | Event::InlineHtml(raw) => {
|
||||
if in_table {
|
||||
current_cell.push_str(&raw);
|
||||
} else {
|
||||
spans.push(StyledSpan {
|
||||
text: raw.to_string(),
|
||||
role: StyleRole::Html,
|
||||
fill: FillRole::None,
|
||||
attrs: TextAttrs {
|
||||
italic: true,
|
||||
..TextAttrs::none()
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::Rule => {
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
push_block(
|
||||
&mut doc,
|
||||
BlockKind::ThematicBreak,
|
||||
vec![StyledLine::from_spans(vec![StyledSpan::new(
|
||||
"─".repeat(3),
|
||||
StyleRole::Dim,
|
||||
)])],
|
||||
);
|
||||
}
|
||||
|
||||
// ---- block ends ----
|
||||
Event::End(TagEnd::Heading(_)) => {
|
||||
let level = heading_level.take().unwrap_or(1);
|
||||
// Headings render with strong role + bold across the line.
|
||||
for s in spans.iter_mut() {
|
||||
s.role = StyleRole::Strong;
|
||||
s.attrs.bold = true;
|
||||
}
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Heading { level },
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
}
|
||||
Event::End(TagEnd::Paragraph) => {
|
||||
let kind = current_kind(blockquote_depth, &list_stack);
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
kind,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
}
|
||||
Event::End(TagEnd::Item) => {
|
||||
// Item with no paragraph child (tight list): flush inline buffer.
|
||||
if !spans.is_empty() {
|
||||
let kind = current_kind(blockquote_depth, &list_stack);
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
kind,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
}
|
||||
pending_item_marker = None;
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => {
|
||||
if let Some(frame) = list_stack.pop()
|
||||
&& frame.ordered
|
||||
{
|
||||
align_ordered_markers(&mut doc, frame.start_block, frame.depth);
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::BlockQuote(_)) => {
|
||||
blockquote_depth = blockquote_depth.saturating_sub(1);
|
||||
if blockquote_depth == 0 && !bq_lines.is_empty() {
|
||||
push_block(
|
||||
&mut doc,
|
||||
BlockKind::BlockQuote,
|
||||
std::mem::take(&mut bq_lines),
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
let lines: Vec<StyledLine> = code_buf
|
||||
.trim_end_matches('\n')
|
||||
.split('\n')
|
||||
.map(|l| {
|
||||
StyledLine::from_spans(vec![StyledSpan {
|
||||
text: l.to_string(),
|
||||
role: StyleRole::Code,
|
||||
fill: FillRole::Code,
|
||||
attrs: TextAttrs::none(),
|
||||
}])
|
||||
})
|
||||
.collect();
|
||||
push_block(
|
||||
&mut doc,
|
||||
BlockKind::CodeBlock {
|
||||
language: code_lang.take(),
|
||||
},
|
||||
lines,
|
||||
);
|
||||
in_code_block = false;
|
||||
code_buf.clear();
|
||||
}
|
||||
Event::End(TagEnd::Emphasis) => style.italic = false,
|
||||
Event::End(TagEnd::Strong) => style.bold = false,
|
||||
Event::End(TagEnd::Strikethrough) => style.strike = false,
|
||||
Event::End(TagEnd::Link) => {
|
||||
if let Some(url) = link_targets.pop()
|
||||
&& !url.is_empty()
|
||||
{
|
||||
spans.push(StyledSpan::new(format!(" ({url})"), StyleRole::Dim));
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::Image) => {
|
||||
let alt = if image_alt.trim().is_empty() {
|
||||
"image".to_string()
|
||||
} else {
|
||||
image_alt.trim().to_string()
|
||||
};
|
||||
let label = match image_url.take() {
|
||||
Some(url) => format!("[image: {alt}] ({url})"),
|
||||
None => format!("[image: {alt}]"),
|
||||
};
|
||||
in_image = false;
|
||||
image_alt.clear();
|
||||
if in_table {
|
||||
current_cell.push_str(&label);
|
||||
} else {
|
||||
spans.push(StyledSpan::new(label, StyleRole::Dim));
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing inline buffer.
|
||||
flush_paragraph(
|
||||
&mut doc,
|
||||
&mut spans,
|
||||
BlockKind::Paragraph,
|
||||
Alignment::Left,
|
||||
blockquote_depth,
|
||||
&mut bq_lines,
|
||||
);
|
||||
|
||||
doc
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
//! Backend-neutral styled-span and block model.
|
||||
//!
|
||||
//! The TUI markdown renderer emits `ratatui::Span`s whose colors are chosen
|
||||
//! from *semantic* helpers (`md_dim_color()`, `bold_color()`, `code_fg()`,
|
||||
//! ...), not raw colors. We preserve that: the neutral model carries a
|
||||
//! semantic [`StyleRole`] plus boolean text attributes, and each front-end
|
||||
//! adapter resolves the role to a concrete color for its backend (terminal
|
||||
//! palette vs. desktop theme).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Semantic color role. Front-ends map these to concrete colors so the same
|
||||
/// document renders consistently across backends while still honoring each
|
||||
/// backend's theme.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum StyleRole {
|
||||
/// Default body text.
|
||||
#[default]
|
||||
Text,
|
||||
/// De-emphasized text (markers, rules, quote bars, metadata).
|
||||
Dim,
|
||||
/// Headings / strong emphasis color.
|
||||
Strong,
|
||||
/// Inline code and code-block foreground.
|
||||
Code,
|
||||
/// Hyperlink text.
|
||||
Link,
|
||||
/// Inline/raw HTML passthrough.
|
||||
Html,
|
||||
/// Model "reasoning" text (dim + italic latch in the TUI).
|
||||
Reasoning,
|
||||
/// Math (inline `$..$` or display `$$..$$`) content.
|
||||
Math,
|
||||
}
|
||||
|
||||
/// Background fill role for a span. Most spans have no background; code uses a
|
||||
/// distinct fill.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum FillRole {
|
||||
#[default]
|
||||
None,
|
||||
Code,
|
||||
}
|
||||
|
||||
/// Text attributes that are backend-independent (bold/italic/etc map cleanly
|
||||
/// onto both terminal modifiers and font variants).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub struct TextAttrs {
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub strikethrough: bool,
|
||||
pub underline: bool,
|
||||
}
|
||||
|
||||
impl TextAttrs {
|
||||
pub const fn none() -> Self {
|
||||
Self {
|
||||
bold: false,
|
||||
italic: false,
|
||||
strikethrough: false,
|
||||
underline: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of text sharing one style. The atomic unit of the neutral model.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StyledSpan {
|
||||
pub text: String,
|
||||
pub role: StyleRole,
|
||||
pub fill: FillRole,
|
||||
pub attrs: TextAttrs,
|
||||
}
|
||||
|
||||
impl StyledSpan {
|
||||
pub fn new(text: impl Into<String>, role: StyleRole) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
role,
|
||||
fill: FillRole::None,
|
||||
attrs: TextAttrs::none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plain(text: impl Into<String>) -> Self {
|
||||
Self::new(text, StyleRole::Text)
|
||||
}
|
||||
|
||||
pub fn with_fill(mut self, fill: FillRole) -> Self {
|
||||
self.fill = fill;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_attrs(mut self, attrs: TextAttrs) -> Self {
|
||||
self.attrs = attrs;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bold(mut self) -> Self {
|
||||
self.attrs.bold = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn italic(mut self) -> Self {
|
||||
self.attrs.italic = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal alignment for a rendered line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum Alignment {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// One visual line: a sequence of styled spans plus alignment. This is what
|
||||
/// front-ends consume; the TUI adapter turns each into a `ratatui::Line`, the
|
||||
/// desktop adapter into a glyph run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct StyledLine {
|
||||
pub spans: Vec<StyledSpan>,
|
||||
pub alignment: Alignment,
|
||||
}
|
||||
|
||||
impl StyledLine {
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn from_spans(spans: Vec<StyledSpan>) -> Self {
|
||||
Self {
|
||||
spans,
|
||||
alignment: Alignment::Left,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aligned(spans: Vec<StyledSpan>, alignment: Alignment) -> Self {
|
||||
Self { spans, alignment }
|
||||
}
|
||||
|
||||
/// Plain-text content of the line (spans concatenated), for measuring and
|
||||
/// copy/extraction.
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.spans.iter().map(|s| s.text.as_str()).collect()
|
||||
}
|
||||
|
||||
pub fn is_blank(&self) -> bool {
|
||||
self.spans.iter().all(|s| s.text.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind of a semantic block, retained so front-ends and copy/extraction logic
|
||||
/// can reason about structure (e.g. "this block is a fenced code block in
|
||||
/// language X") without re-parsing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BlockKind {
|
||||
Paragraph,
|
||||
Heading {
|
||||
level: u8,
|
||||
},
|
||||
CodeBlock {
|
||||
language: Option<String>,
|
||||
},
|
||||
BlockQuote,
|
||||
ListItem {
|
||||
ordered: bool,
|
||||
depth: usize,
|
||||
},
|
||||
/// GFM table. The raw cell text per row is carried in [`Block::table`]
|
||||
/// (the first row is the header); column layout/wrapping is width-dependent
|
||||
/// and therefore performed by each front-end adapter.
|
||||
Table,
|
||||
/// Display math (`$$..$$`). Terminal-friendly rendered math lines are in
|
||||
/// [`Block::lines`] with [`StyleRole::Math`]; the adapter frames them.
|
||||
MathDisplay,
|
||||
ThematicBreak,
|
||||
Html,
|
||||
}
|
||||
|
||||
/// A semantic block: its kind plus the already-laid-out lines it produced.
|
||||
/// Front-ends mostly render `lines`; `kind` enables copy targets, spacing
|
||||
/// policy, and structural styling decisions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Block {
|
||||
pub kind: BlockKind,
|
||||
pub lines: Vec<StyledLine>,
|
||||
/// Raw table cells (row-major, first row is the header). Only populated for
|
||||
/// [`BlockKind::Table`]; layout is deferred to the front-end adapter.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub table: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
pub fn new(kind: BlockKind, lines: Vec<StyledLine>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
lines,
|
||||
table: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a table block carrying raw cells (row-major, header first).
|
||||
pub fn table(rows: Vec<Vec<String>>) -> Self {
|
||||
Self {
|
||||
kind: BlockKind::Table,
|
||||
lines: Vec::new(),
|
||||
table: rows,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fully parsed, backend-neutral document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
pub blocks: Vec<Block>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// All lines across all blocks, in order. Convenient for line-oriented
|
||||
/// front-ends that don't need block structure.
|
||||
pub fn lines(&self) -> impl Iterator<Item = &StyledLine> {
|
||||
self.blocks.iter().flat_map(|b| b.lines.iter())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
//! Input preprocessing applied before markdown parsing.
|
||||
//!
|
||||
//! pulldown-cmark's math extension is aggressive: any `$`-delimited run can be
|
||||
//! treated as inline math, so plain currency like `$5` or `$5x$` gets parsed as
|
||||
//! math. The TUI renderer guards against this by escaping `$`-then-digit into
|
||||
//! `\$` before parsing. We mirror that behavior here so the shared core matches
|
||||
//! the authoritative renderer.
|
||||
|
||||
const DISPLAY_MATH_ENVIRONMENTS: &[&str] = &[
|
||||
"equation",
|
||||
"equation*",
|
||||
"displaymath",
|
||||
"align",
|
||||
"align*",
|
||||
"aligned",
|
||||
"aligned*",
|
||||
"gather",
|
||||
"gather*",
|
||||
"gathered",
|
||||
"multline",
|
||||
"multline*",
|
||||
"split",
|
||||
"eqnarray",
|
||||
"eqnarray*",
|
||||
"matrix",
|
||||
"smallmatrix",
|
||||
"array",
|
||||
"pmatrix",
|
||||
"bmatrix",
|
||||
"Bmatrix",
|
||||
"vmatrix",
|
||||
"Vmatrix",
|
||||
"cases",
|
||||
"cases*",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum MathDelimiter {
|
||||
DollarInline,
|
||||
DollarDisplay,
|
||||
LatexInline,
|
||||
LatexDisplay,
|
||||
}
|
||||
|
||||
/// Normalize common LaTeX math containers into pulldown-cmark's `$` / `$$`
|
||||
/// syntax before markdown parsing.
|
||||
///
|
||||
/// This recognizes `\(...\)`, `\[...\]`, standalone display-math
|
||||
/// environments, and fenced `math`/`latex`/`tex`/`katex` blocks. Generic code
|
||||
/// spans and code fences are intentionally left byte-for-byte unchanged.
|
||||
pub fn normalize_latex_math(text: &str) -> String {
|
||||
let fenced = normalize_math_fences(text);
|
||||
let normalized = normalize_latex_delimiters_and_environments(&fenced);
|
||||
let promoted = promote_standalone_inline_math(&normalized);
|
||||
stabilize_display_math(&promoted)
|
||||
}
|
||||
|
||||
/// A single-dollar or `\(`/`\)` pair placed on lines by itself is visibly a
|
||||
/// block construct even though its delimiter spelling is inline. pulldown-cmark
|
||||
/// does not tokenize inline math across physical newlines, so promote this
|
||||
/// unambiguous line-oriented form to display math. Embedded multiline inline
|
||||
/// delimiters keep Markdown's ordinary literal fallback behavior.
|
||||
fn promote_standalone_inline_math(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.split_inclusive('\n').collect();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut index = 0usize;
|
||||
let mut fence: Option<(char, usize)> = None;
|
||||
|
||||
while index < lines.len() {
|
||||
let line = lines[index];
|
||||
let logical = line
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(line)
|
||||
.trim_end_matches('\r');
|
||||
if let Some((marker, len)) = fence {
|
||||
out.push_str(line);
|
||||
if closing_fence(logical, marker, len) {
|
||||
fence = None;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let Some((prefix, marker_start)) = math_line_prefix(logical, "$") else {
|
||||
if let Some((_, marker, len, _)) = opening_fence(logical) {
|
||||
fence = Some((marker, len));
|
||||
}
|
||||
out.push_str(line);
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(close_offset) = lines[index + 1..].iter().position(|candidate| {
|
||||
let candidate = candidate
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(candidate)
|
||||
.trim_end_matches('\r');
|
||||
prefix
|
||||
.strip_continuation(candidate)
|
||||
.and_then(|rest| math_line_prefix(rest, "$"))
|
||||
.is_some()
|
||||
}) else {
|
||||
out.push_str(line);
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let close_index = index + 1 + close_offset;
|
||||
out.push_str(&line[..marker_start]);
|
||||
out.push_str("$$");
|
||||
out.push_str(&line[marker_start + 1..]);
|
||||
for body_line in &lines[index + 1..close_index] {
|
||||
out.push_str(body_line);
|
||||
}
|
||||
let close = lines[close_index];
|
||||
let close_logical = close
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(close)
|
||||
.trim_end_matches('\r');
|
||||
let close_start = close_logical.rfind('$').expect("matched closing marker");
|
||||
out.push_str(&close[..close_start]);
|
||||
out.push_str("$$");
|
||||
out.push_str(&close[close_start + 1..]);
|
||||
index = close_index + 1;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// pulldown-cmark recognizes block constructs before it finishes collecting a
|
||||
/// multiline `$$` span. A perfectly valid TeX line such as `=` can therefore
|
||||
/// become a Setext heading underline and split the math block. Prefix those
|
||||
/// interrupting body lines with an empty TeX group (`{}`), which is invisible to
|
||||
/// both the terminal renderer and a real LaTeX toolchain while making the line
|
||||
/// unambiguously part of the math span. Unlike flattening the display to one
|
||||
/// line, this preserves TeX comments and other newline-sensitive source.
|
||||
fn stabilize_display_math(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.split_inclusive('\n').collect();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut index = 0usize;
|
||||
let mut fence: Option<(char, usize)> = None;
|
||||
|
||||
while index < lines.len() {
|
||||
let line = lines[index];
|
||||
let logical = line
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(line)
|
||||
.trim_end_matches('\r');
|
||||
|
||||
if let Some((marker, len)) = fence {
|
||||
out.push_str(line);
|
||||
if closing_fence(logical, marker, len) {
|
||||
fence = None;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let Some((prefix, _)) = math_line_prefix(logical, "$$") else {
|
||||
if let Some((_, marker, len, _)) = opening_fence(logical) {
|
||||
fence = Some((marker, len));
|
||||
}
|
||||
out.push_str(line);
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(close_offset) = lines[index + 1..].iter().position(|candidate| {
|
||||
let candidate = candidate
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(candidate)
|
||||
.trim_end_matches('\r');
|
||||
prefix
|
||||
.strip_continuation(candidate)
|
||||
.is_some_and(|rest| rest.trim() == "$$")
|
||||
}) else {
|
||||
out.push_str(line);
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let close_index = index + 1 + close_offset;
|
||||
out.push_str(line);
|
||||
for body_line in &lines[index + 1..close_index] {
|
||||
let without_newline = body_line.strip_suffix('\n').unwrap_or(body_line);
|
||||
let logical_body = without_newline.trim_end_matches('\r');
|
||||
let ending = &body_line[logical_body.len()..];
|
||||
let (container, math) = prefix.split_body(logical_body);
|
||||
out.push_str(container);
|
||||
if markdown_interrupts_math(math) {
|
||||
out.push_str("{}");
|
||||
}
|
||||
out.push_str(math);
|
||||
out.push_str(ending);
|
||||
}
|
||||
out.push_str(lines[close_index]);
|
||||
index = close_index + 1;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DisplayMathLinePrefix {
|
||||
continuation: String,
|
||||
containerized: bool,
|
||||
}
|
||||
|
||||
impl DisplayMathLinePrefix {
|
||||
fn strip_continuation<'a>(&self, line: &'a str) -> Option<&'a str> {
|
||||
if self.containerized {
|
||||
line.strip_prefix(&self.continuation)
|
||||
} else {
|
||||
Some(line.strip_prefix(&self.continuation).unwrap_or(line))
|
||||
}
|
||||
}
|
||||
|
||||
fn split_body<'a>(&self, line: &'a str) -> (&'a str, &'a str) {
|
||||
match line.strip_prefix(&self.continuation) {
|
||||
Some(math) => line.split_at(line.len() - math.len()),
|
||||
None => ("", line),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn math_line_prefix(line: &str, marker: &str) -> Option<(DisplayMathLinePrefix, usize)> {
|
||||
let marker_start = line.rfind(marker)?;
|
||||
if line[marker_start..].trim() != marker
|
||||
|| (marker == "$" && line[..marker_start].ends_with('$'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let before = &line[..marker_start];
|
||||
let leading_len = before.len() - before.trim_start_matches([' ', '\t']).len();
|
||||
let leading = &before[..leading_len];
|
||||
let mut position = leading_len;
|
||||
let mut has_quote = false;
|
||||
|
||||
while line[position..marker_start].starts_with('>') {
|
||||
has_quote = true;
|
||||
position += 1;
|
||||
if line[position..marker_start].starts_with([' ', '\t']) {
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let list_marker = &line[position..marker_start];
|
||||
if list_marker.is_empty() {
|
||||
let leading_columns = leading
|
||||
.chars()
|
||||
.map(|ch| if ch == '\t' { 4 } else { 1 })
|
||||
.sum::<usize>();
|
||||
if !has_quote && leading_columns > 3 {
|
||||
return None;
|
||||
}
|
||||
return Some((
|
||||
DisplayMathLinePrefix {
|
||||
continuation: before.to_string(),
|
||||
containerized: has_quote,
|
||||
},
|
||||
marker_start,
|
||||
));
|
||||
}
|
||||
|
||||
let marker_width = markdown_list_marker_width(list_marker)?;
|
||||
Some((
|
||||
DisplayMathLinePrefix {
|
||||
continuation: format!("{}{}", &line[..position], " ".repeat(marker_width)),
|
||||
containerized: true,
|
||||
},
|
||||
marker_start,
|
||||
))
|
||||
}
|
||||
|
||||
fn markdown_list_marker_width(marker: &str) -> Option<usize> {
|
||||
if matches!(marker, "- " | "* " | "+ ") {
|
||||
return Some(marker.len());
|
||||
}
|
||||
let (number, suffix) = marker.split_once(['.', ')'])?;
|
||||
(!number.is_empty()
|
||||
&& number.chars().all(|ch| ch.is_ascii_digit())
|
||||
&& matches!(suffix, " " | "\t"))
|
||||
.then_some(marker.len())
|
||||
}
|
||||
|
||||
fn markdown_interrupts_math(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let is_setext = trimmed.chars().all(|ch| ch == '=')
|
||||
|| (trimmed.len() >= 3 && trimmed.chars().all(|ch| ch == '-'));
|
||||
let is_fence = trimmed.starts_with("```") || trimmed.starts_with("~~~");
|
||||
let is_atx_heading = trimmed.starts_with('#');
|
||||
let is_quote = trimmed.starts_with('>');
|
||||
let is_list = trimmed.starts_with("- ")
|
||||
|| trimmed.starts_with("* ")
|
||||
|| trimmed.starts_with("+ ")
|
||||
|| trimmed
|
||||
.split_once(['.', ')'])
|
||||
.is_some_and(|(number, rest)| {
|
||||
!number.is_empty()
|
||||
&& number.chars().all(|ch| ch.is_ascii_digit())
|
||||
&& rest.starts_with([' ', '\t'])
|
||||
});
|
||||
is_setext || is_fence || is_atx_heading || is_quote || is_list
|
||||
}
|
||||
|
||||
fn normalize_math_fences(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut index = 0usize;
|
||||
|
||||
while index < lines.len() {
|
||||
let Some((indent, marker, marker_len, info)) = opening_fence(lines[index]) else {
|
||||
out.push_str(lines[index]);
|
||||
if index + 1 < lines.len() {
|
||||
out.push('\n');
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(close_offset) = lines[index + 1..]
|
||||
.iter()
|
||||
.position(|line| closing_fence(line, marker, marker_len))
|
||||
else {
|
||||
out.push_str(lines[index]);
|
||||
if index + 1 < lines.len() {
|
||||
out.push('\n');
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let close_index = index + 1 + close_offset;
|
||||
|
||||
if is_math_fence_info(info) {
|
||||
let body = lines[index + 1..close_index].join("\n");
|
||||
let body = strip_outer_display_delimiters(&body);
|
||||
out.push_str(indent);
|
||||
out.push_str("$$\n");
|
||||
out.push_str(body.trim_matches('\n'));
|
||||
out.push('\n');
|
||||
out.push_str(indent);
|
||||
out.push_str("$$");
|
||||
if close_index + 1 < lines.len() {
|
||||
out.push('\n');
|
||||
}
|
||||
} else {
|
||||
for (offset, line) in lines[index..=close_index].iter().enumerate() {
|
||||
out.push_str(line);
|
||||
let is_closing_line = index + offset == close_index;
|
||||
if close_index + 1 < lines.len() || !is_closing_line {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
index = close_index + 1;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn strip_outer_display_delimiters(body: &str) -> &str {
|
||||
let trimmed = body.trim();
|
||||
if let Some(inner) = trimmed
|
||||
.strip_prefix("\\[")
|
||||
.and_then(|value| value.strip_suffix("\\]"))
|
||||
{
|
||||
return inner;
|
||||
}
|
||||
if let Some(inner) = trimmed
|
||||
.strip_prefix("$$")
|
||||
.and_then(|value| value.strip_suffix("$$"))
|
||||
{
|
||||
return inner;
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn opening_fence(line: &str) -> Option<(&str, char, usize, &str)> {
|
||||
let indent_len = line.len() - line.trim_start_matches([' ', '\t']).len();
|
||||
let indent = &line[..indent_len];
|
||||
if indent
|
||||
.chars()
|
||||
.map(|ch| if ch == '\t' { 4 } else { 1 })
|
||||
.sum::<usize>()
|
||||
> 3
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let rest = &line[indent_len..];
|
||||
let marker = rest.chars().next()?;
|
||||
if !matches!(marker, '`' | '~') {
|
||||
return None;
|
||||
}
|
||||
let marker_len = rest.chars().take_while(|ch| *ch == marker).count();
|
||||
if marker_len < 3 {
|
||||
return None;
|
||||
}
|
||||
let marker_bytes = marker.len_utf8() * marker_len;
|
||||
Some((indent, marker, marker_len, rest[marker_bytes..].trim()))
|
||||
}
|
||||
|
||||
fn closing_fence(line: &str, marker: char, minimum_len: usize) -> bool {
|
||||
let trimmed = line.trim_start_matches([' ', '\t']);
|
||||
let run = trimmed.chars().take_while(|ch| *ch == marker).count();
|
||||
run >= minimum_len && trimmed[run * marker.len_utf8()..].trim().is_empty()
|
||||
}
|
||||
|
||||
fn is_math_fence_info(info: &str) -> bool {
|
||||
let language = info
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_matches(['{', '}', '.'])
|
||||
.to_ascii_lowercase();
|
||||
matches!(language.as_str(), "math" | "latex" | "tex" | "katex")
|
||||
}
|
||||
|
||||
fn normalize_latex_delimiters_and_environments(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut index = 0usize;
|
||||
let mut inline_ticks = 0usize;
|
||||
let mut fence: Option<(char, usize)> = None;
|
||||
let mut line_start = true;
|
||||
let mut leading_columns = 0usize;
|
||||
let mut math_delimiter = None;
|
||||
let mut wrapped_environment: Option<(String, usize)> = None;
|
||||
let mut literal_environment: Option<(String, usize)> = None;
|
||||
|
||||
while index < text.len() {
|
||||
let rest = &text[index..];
|
||||
let ch = rest.chars().next().expect("index stays on a char boundary");
|
||||
|
||||
if ch == '\n' {
|
||||
out.push(ch);
|
||||
index += 1;
|
||||
line_start = true;
|
||||
leading_columns = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line_start && matches!(ch, ' ' | '\t') {
|
||||
leading_columns += if ch == '\t' { 4 } else { 1 };
|
||||
out.push(ch);
|
||||
index += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
|
||||
if inline_ticks == 0
|
||||
&& math_delimiter.is_none()
|
||||
&& line_start
|
||||
&& leading_columns <= 3
|
||||
&& matches!(ch, '`' | '~')
|
||||
{
|
||||
let run = rest
|
||||
.chars()
|
||||
.take_while(|candidate| *candidate == ch)
|
||||
.count();
|
||||
if run >= 3 {
|
||||
let current_line = rest.split_once('\n').map_or(rest, |(line, _)| line);
|
||||
let trailing = ¤t_line[run * ch.len_utf8()..];
|
||||
let is_close = fence.is_some_and(|(marker, len)| {
|
||||
marker == ch && run >= len && trailing.trim().is_empty()
|
||||
});
|
||||
if fence.is_none() || is_close {
|
||||
fence = if is_close { None } else { Some((ch, run)) };
|
||||
}
|
||||
out.push_str(&rest[..run * ch.len_utf8()]);
|
||||
index += run * ch.len_utf8();
|
||||
line_start = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
line_start = false;
|
||||
|
||||
// Four-space and tab-indented Markdown code is literal just like fenced
|
||||
// and inline code. Keep the whole logical line byte-for-byte unchanged.
|
||||
if leading_columns >= 4 {
|
||||
out.push(ch);
|
||||
index += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
|
||||
if fence.is_some() {
|
||||
out.push(ch);
|
||||
index += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '`' && math_delimiter.is_none() {
|
||||
let run = rest
|
||||
.chars()
|
||||
.take_while(|candidate| *candidate == '`')
|
||||
.count();
|
||||
if inline_ticks == 0 {
|
||||
inline_ticks = run;
|
||||
} else if inline_ticks == run {
|
||||
inline_ticks = 0;
|
||||
}
|
||||
out.push_str(&rest[..run]);
|
||||
index += run;
|
||||
continue;
|
||||
}
|
||||
if inline_ticks > 0 {
|
||||
out.push(ch);
|
||||
index += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((name, depth)) = literal_environment.as_mut() {
|
||||
if let Some((begin_name, marker_len)) = environment_marker(rest, "begin")
|
||||
&& begin_name == name
|
||||
{
|
||||
*depth += 1;
|
||||
out.push_str(&rest[..marker_len]);
|
||||
index += marker_len;
|
||||
continue;
|
||||
}
|
||||
if let Some((end_name, marker_len)) = environment_marker(rest, "end")
|
||||
&& end_name == name
|
||||
{
|
||||
*depth -= 1;
|
||||
out.push_str(&rest[..marker_len]);
|
||||
index += marker_len;
|
||||
if *depth == 0 {
|
||||
literal_environment = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
index += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '$' && !is_escaped_at(text, index) {
|
||||
let run = rest
|
||||
.chars()
|
||||
.take_while(|candidate| *candidate == '$')
|
||||
.count()
|
||||
.min(2);
|
||||
let looks_like_currency = run == 1
|
||||
&& rest[1..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|next| next.is_ascii_digit());
|
||||
if !looks_like_currency {
|
||||
math_delimiter = match (math_delimiter, run) {
|
||||
(Some(MathDelimiter::DollarInline), 1)
|
||||
| (Some(MathDelimiter::DollarDisplay), 2) => None,
|
||||
(None, 1) => Some(MathDelimiter::DollarInline),
|
||||
(None, 2) => Some(MathDelimiter::DollarDisplay),
|
||||
(current, _) => current,
|
||||
};
|
||||
}
|
||||
out.push_str(&rest[..run]);
|
||||
index += run;
|
||||
continue;
|
||||
}
|
||||
|
||||
if math_delimiter.is_none()
|
||||
&& rest.starts_with("\\(")
|
||||
&& !is_escaped_at(text, index)
|
||||
&& has_unescaped_delimiter(&rest[2..], "\\)")
|
||||
{
|
||||
out.push('$');
|
||||
math_delimiter = Some(MathDelimiter::LatexInline);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if math_delimiter == Some(MathDelimiter::LatexInline)
|
||||
&& rest.starts_with("\\)")
|
||||
&& !is_escaped_at(text, index)
|
||||
{
|
||||
out.push('$');
|
||||
math_delimiter = None;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if math_delimiter.is_none()
|
||||
&& rest.starts_with("\\[")
|
||||
&& !is_escaped_at(text, index)
|
||||
&& has_unescaped_delimiter(&rest[2..], "\\]")
|
||||
&& !looks_like_escaped_markdown_link(rest)
|
||||
{
|
||||
out.push_str("$$");
|
||||
math_delimiter = Some(MathDelimiter::LatexDisplay);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if math_delimiter == Some(MathDelimiter::LatexDisplay)
|
||||
&& rest.starts_with("\\]")
|
||||
&& !is_escaped_at(text, index)
|
||||
{
|
||||
out.push_str("$$");
|
||||
math_delimiter = None;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '\\'
|
||||
&& math_delimiter.is_none()
|
||||
&& wrapped_environment.is_none()
|
||||
&& let Some((name, marker_len)) = environment_marker(rest, "begin")
|
||||
&& DISPLAY_MATH_ENVIRONMENTS.contains(&name)
|
||||
{
|
||||
if has_matching_environment_end(rest, name) {
|
||||
out.push_str("$$\n");
|
||||
out.push_str(&rest[..marker_len]);
|
||||
wrapped_environment = Some((name.to_string(), 1));
|
||||
} else {
|
||||
out.push_str(&rest[..marker_len]);
|
||||
literal_environment = Some((name.to_string(), 1));
|
||||
}
|
||||
index += marker_len;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((name, depth)) = wrapped_environment.as_mut() {
|
||||
if let Some((begin_name, marker_len)) = environment_marker(rest, "begin")
|
||||
&& begin_name == name
|
||||
{
|
||||
*depth += 1;
|
||||
out.push_str(&rest[..marker_len]);
|
||||
index += marker_len;
|
||||
continue;
|
||||
}
|
||||
if let Some((end_name, marker_len)) = environment_marker(rest, "end")
|
||||
&& end_name == name
|
||||
{
|
||||
*depth -= 1;
|
||||
out.push_str(&rest[..marker_len]);
|
||||
index += marker_len;
|
||||
if *depth == 0 {
|
||||
out.push_str("\n$$");
|
||||
wrapped_environment = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out.push(ch);
|
||||
index += ch.len_utf8();
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn environment_marker<'a>(rest: &'a str, command: &str) -> Option<(&'a str, usize)> {
|
||||
let prefix = format!("\\{command}{{");
|
||||
let after_prefix = rest.strip_prefix(&prefix)?;
|
||||
let close = after_prefix.find('}')?;
|
||||
let name = &after_prefix[..close];
|
||||
Some((name, prefix.len() + close + 1))
|
||||
}
|
||||
|
||||
fn has_matching_environment_end(source: &str, name: &str) -> bool {
|
||||
let begin_marker = format!("\\begin{{{name}}}");
|
||||
let end_marker = format!("\\end{{{name}}}");
|
||||
let mut search = begin_marker.len();
|
||||
let mut depth = 1usize;
|
||||
while search < source.len() {
|
||||
let next_begin = source[search..]
|
||||
.find(&begin_marker)
|
||||
.map(|offset| search + offset);
|
||||
let next_end = source[search..]
|
||||
.find(&end_marker)
|
||||
.map(|offset| search + offset);
|
||||
match (next_begin, next_end) {
|
||||
(_, None) => return false,
|
||||
(Some(begin), Some(end)) if begin < end => {
|
||||
depth += 1;
|
||||
search = begin + begin_marker.len();
|
||||
}
|
||||
(_, Some(end)) => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return true;
|
||||
}
|
||||
search = end + end_marker.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_escaped_at(text: &str, index: usize) -> bool {
|
||||
let preceding = text[..index]
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|ch| *ch == '\\')
|
||||
.count();
|
||||
preceding % 2 == 1
|
||||
}
|
||||
|
||||
fn has_unescaped_delimiter(text: &str, delimiter: &str) -> bool {
|
||||
find_unescaped_delimiter(text, delimiter).is_some()
|
||||
}
|
||||
|
||||
fn find_unescaped_delimiter(text: &str, delimiter: &str) -> Option<usize> {
|
||||
let mut search_from = 0usize;
|
||||
while let Some(offset) = text[search_from..].find(delimiter) {
|
||||
let index = search_from + offset;
|
||||
if !is_escaped_at(text, index) {
|
||||
return Some(index);
|
||||
}
|
||||
search_from = index + delimiter.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn looks_like_escaped_markdown_link(rest: &str) -> bool {
|
||||
let Some(close) = find_unescaped_delimiter(&rest[2..], "\\]") else {
|
||||
return false;
|
||||
};
|
||||
rest[2 + close + 2..].starts_with('(')
|
||||
}
|
||||
|
||||
/// Escape dollar signs that look like currency amounts (`$` immediately
|
||||
/// followed by an ASCII digit) into `\$`, so the math extension does not treat
|
||||
/// them as inline math. Dollars inside inline code spans and fenced code blocks
|
||||
/// are left untouched, and already-escaped `\$` is preserved. Display-math
|
||||
/// `$$` runs are passed through unchanged.
|
||||
pub fn escape_currency_dollars(text: &str) -> String {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
let mut code_fence: Option<(char, usize)> = None;
|
||||
let mut inline_code_len: usize = 0;
|
||||
let mut at_line_start = true;
|
||||
let mut leading_spaces = 0;
|
||||
|
||||
let count_marker = |chars: &[char], start: usize, marker: char| {
|
||||
let mut j = start;
|
||||
while j < chars.len() && chars[j] == marker {
|
||||
j += 1;
|
||||
}
|
||||
j - start
|
||||
};
|
||||
|
||||
let is_escaped = |chars: &[char], pos: usize| {
|
||||
let mut backslashes = 0usize;
|
||||
let mut j = pos;
|
||||
while j > 0 {
|
||||
if chars[j - 1] != '\\' {
|
||||
break;
|
||||
}
|
||||
backslashes += 1;
|
||||
j -= 1;
|
||||
}
|
||||
backslashes % 2 == 1
|
||||
};
|
||||
|
||||
while i < len {
|
||||
let c = chars[i];
|
||||
|
||||
if c == '\n' {
|
||||
at_line_start = true;
|
||||
leading_spaces = 0;
|
||||
out.push('\n');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if at_line_start && (c == ' ' || c == '\t') {
|
||||
leading_spaces += if c == '\t' { 4 } else { 1 };
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let marker_run = if matches!(c, '`' | '~') {
|
||||
count_marker(&chars, i, c)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let maybe_fence = inline_code_len == 0 && marker_run >= 3;
|
||||
if maybe_fence && at_line_start && leading_spaces <= 3 {
|
||||
let run = marker_run;
|
||||
let line_end = chars[i + run..]
|
||||
.iter()
|
||||
.position(|ch| *ch == '\n')
|
||||
.map(|offset| i + run + offset)
|
||||
.unwrap_or(len);
|
||||
let trailing_is_blank = chars[i + run..line_end].iter().all(|ch| ch.is_whitespace());
|
||||
let is_close = code_fence.is_some_and(|(marker, minimum)| {
|
||||
marker == c && run >= minimum && trailing_is_blank
|
||||
});
|
||||
if code_fence.is_none() || is_close {
|
||||
code_fence = if is_close { None } else { Some((c, run)) };
|
||||
}
|
||||
for _ in 0..run {
|
||||
out.push(c);
|
||||
}
|
||||
i += run;
|
||||
at_line_start = false;
|
||||
leading_spaces = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if code_fence.is_some() {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
at_line_start = false;
|
||||
leading_spaces = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '`' {
|
||||
let run = count_marker(&chars, i, '`');
|
||||
if inline_code_len > 0 {
|
||||
if run == inline_code_len {
|
||||
inline_code_len = 0;
|
||||
}
|
||||
for _ in 0..run {
|
||||
out.push('`');
|
||||
}
|
||||
i += run;
|
||||
at_line_start = false;
|
||||
leading_spaces = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
inline_code_len = run;
|
||||
for _ in 0..run {
|
||||
out.push('`');
|
||||
}
|
||||
i += run;
|
||||
at_line_start = false;
|
||||
leading_spaces = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if at_line_start {
|
||||
at_line_start = false;
|
||||
}
|
||||
|
||||
if c == ' ' || c == '\t' {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if inline_code_len > 0 {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '$' && i + 1 < len && chars[i + 1] == '$' {
|
||||
out.push_str("$$");
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '$' && i + 1 < len && chars[i + 1].is_ascii_digit() {
|
||||
if is_escaped(&chars, i) {
|
||||
out.push('$');
|
||||
} else {
|
||||
out.push_str("\\$");
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{escape_currency_dollars, normalize_latex_math};
|
||||
|
||||
#[test]
|
||||
fn escapes_currency() {
|
||||
assert_eq!(escape_currency_dollars("$5"), "\\$5");
|
||||
assert_eq!(escape_currency_dollars("$5x$"), "\\$5x$");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_real_math() {
|
||||
assert_eq!(escape_currency_dollars("$a+b$"), "$a+b$");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_display_math() {
|
||||
assert_eq!(escape_currency_dollars("$$x=5$$"), "$$x=5$$");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_inline_code() {
|
||||
assert_eq!(escape_currency_dollars("`$5`"), "`$5`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_fenced_code() {
|
||||
let input = "```\n$5\n```";
|
||||
assert_eq!(escape_currency_dollars(input), input);
|
||||
let tilde = "~~~text\n$5\n~~~";
|
||||
assert_eq!(escape_currency_dollars(tilde), tilde);
|
||||
let longer = "````text\n``` is content and $5\n````";
|
||||
assert_eq!(escape_currency_dollars(longer), longer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_already_escaped() {
|
||||
assert_eq!(escape_currency_dollars("\\$5"), "\\$5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_parenthesized_and_bracketed_math() {
|
||||
assert_eq!(
|
||||
normalize_latex_math(r"Inline \(x^2 + \alpha\) done."),
|
||||
r"Inline $x^2 + \alpha$ done."
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_latex_math("\\[\n\\frac{x+1}{y}\n\\]"),
|
||||
"$$\n\\frac{x+1}{y}\n$$"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_standalone_display_environments() {
|
||||
let input = "before\n\\begin{align*}\nx &= 1 \\\\\ny &= 2\n\\end{align*}\nafter";
|
||||
let normalized = normalize_latex_math(input);
|
||||
assert!(normalized.contains("$$\n\\begin{align*}"), "{normalized}");
|
||||
assert!(normalized.contains("\\end{align*}\n$$"), "{normalized}");
|
||||
|
||||
let nested = r"\begin{matrix}\begin{matrix}a\end{matrix}\end{matrix}";
|
||||
assert_eq!(normalize_latex_math(nested), format!("$$\n{nested}\n$$"));
|
||||
|
||||
let unmatched = r"\begin{matrix}\begin{matrix}a\end{matrix}";
|
||||
assert_eq!(normalize_latex_math(unmatched), unmatched);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_explicit_math_fences() {
|
||||
assert_eq!(
|
||||
normalize_latex_math("```math\n\\frac{a}{b}\n```"),
|
||||
"$$\n\\frac{a}{b}\n$$"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_latex_math("~~~latex\n\\[\nx^2\n\\]\n~~~"),
|
||||
"$$\nx^2\n$$"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_literal_code_and_malformed_delimiters() {
|
||||
let inline = r"Use `\(x^2\)` literally";
|
||||
let fenced = "```rust\nlet latex = r\"\\[x\\]\";\n```";
|
||||
let plain_fenced = "```\nlet x = 1;\n```";
|
||||
let escaped_link = r"a \[link\](https://example.com) reference";
|
||||
let malformed = r"unfinished \[x^2";
|
||||
assert_eq!(normalize_latex_math(inline), inline);
|
||||
assert_eq!(normalize_latex_math(fenced), fenced);
|
||||
assert_eq!(normalize_latex_math(plain_fenced), plain_fenced);
|
||||
assert_eq!(normalize_latex_math(escaped_link), escaped_link);
|
||||
assert_eq!(normalize_latex_math(malformed), malformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn currency_does_not_hide_later_latex_math() {
|
||||
assert_eq!(
|
||||
normalize_latex_math(r"Costs $35. Then \[x^2\]."),
|
||||
r"Costs $35. Then $$x^2$$."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Reasoning-line markdown formatting.
|
||||
//!
|
||||
//! Pure string helpers shared by the server/streaming path and the TUI renderer
|
||||
//! so the wrapping/escaping rules stay in lockstep with the renderer that
|
||||
//! consumes them. These live in `jcode-render-core` (a backend-neutral, pure
|
||||
//! crate) rather than in `jcode-tui-markdown` so the foundation/streaming layer
|
||||
//! can format reasoning lines without depending on any `jcode-tui-*` crate.
|
||||
|
||||
/// Invisible separator placed just inside both ends of an emphasis run so the
|
||||
/// flanking `*` are always adjacent to non-whitespace (see
|
||||
/// [`reasoning_line_markup`]).
|
||||
pub const REASONING_SENTINEL: &str = "\u{2063}";
|
||||
|
||||
/// Escape the characters that would otherwise be interpreted as inline markdown
|
||||
/// inside a reasoning line, so the body renders literally inside the dim/italic
|
||||
/// emphasis run.
|
||||
fn escape_reasoning_inline_markdown(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len() + 8);
|
||||
for ch in line.chars() {
|
||||
match ch {
|
||||
'\\' | '*' | '_' | '`' | '[' | ']' | '<' | '>' | '&' | '~' | '|' | '$' => {
|
||||
out.push('\\');
|
||||
out.push(ch);
|
||||
}
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap a completed reasoning line as dim+italic markdown.
|
||||
///
|
||||
/// Empty lines become a bare newline (no empty emphasis run). The result always
|
||||
/// ends in a CommonMark hard break (`" \n"`).
|
||||
///
|
||||
/// The trailing two spaces are a CommonMark *hard break*: without them,
|
||||
/// consecutive reasoning lines (each terminated by a single `\n`) collapse into
|
||||
/// one paragraph where the line breaks render as spaces, so multi-line thinking
|
||||
/// shows up as a single run-on line. The hard break keeps each reasoning line on
|
||||
/// its own visual row, matching the model's line structure.
|
||||
///
|
||||
/// The sentinel must wrap both ends because CommonMark's emphasis flanking rules
|
||||
/// require the opening `*` to not be followed by whitespace and the closing `*`
|
||||
/// to not be preceded by whitespace. A reasoning line that starts or ends with
|
||||
/// whitespace (or is whitespace-only) would otherwise leave the asterisks as
|
||||
/// literal text and break the dim/italic styling. The zero-width sentinels
|
||||
/// guarantee both asterisks are flanked by non-whitespace regardless of the body.
|
||||
pub fn reasoning_line_markup(line: &str) -> String {
|
||||
if line.is_empty() {
|
||||
"\n".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"*{0}{1}{0}* \n",
|
||||
REASONING_SENTINEL,
|
||||
escape_reasoning_inline_markdown(line)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap the in-progress (not yet newline-terminated) reasoning line as dim+italic
|
||||
/// markdown, identical to [`reasoning_line_markup`] but *without* the trailing
|
||||
/// newline so it renders as the live tail of the streaming buffer. Callers
|
||||
/// truncate and re-emit this tail on each streamed delta so reasoning trickles in
|
||||
/// token-by-token instead of one whole line at a time. An empty line yields an
|
||||
/// empty string (nothing to render yet).
|
||||
pub fn reasoning_partial_markup(line: &str) -> String {
|
||||
if line.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"*{0}{1}{0}*",
|
||||
REASONING_SENTINEL,
|
||||
escape_reasoning_inline_markdown(line)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line collapsed reasoning summary markup (e.g. `▸ thought (3 lines)`),
|
||||
/// styled dim+italic like the live reasoning lines. Used to fold a persisted
|
||||
/// reasoning block down to a single trace line when the transcript is
|
||||
/// re-rendered from history in `current` reasoning-display mode (so reloaded /
|
||||
/// resumed sessions match the live collapse instead of replaying every line).
|
||||
///
|
||||
/// Lives here (a backend-neutral, pure crate) rather than in `jcode-tui-markdown`
|
||||
/// so the foundation/streaming layer can format the summary without depending on
|
||||
/// any `jcode-tui-*` crate. Re-exported from `jcode-tui-markdown` for the
|
||||
/// existing `jcode_tui_markdown::reasoning_summary_line_markup` path.
|
||||
pub fn reasoning_summary_line_markup(line_count: usize) -> String {
|
||||
let label = match line_count {
|
||||
0 | 1 => "▸ thought".to_string(),
|
||||
n => format!("▸ thought ({} lines)", n),
|
||||
};
|
||||
reasoning_line_markup(&label)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use crate::{
|
||||
Alignment, BlockKind, ColumnWidth, FillRole, StyleRole, StyledLine, StyledSpan, WidthMeasure,
|
||||
parse_markdown, wrap_line,
|
||||
};
|
||||
|
||||
fn block_kinds(doc: &crate::Document) -> Vec<&BlockKind> {
|
||||
doc.blocks.iter().map(|b| &b.kind).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_heading() {
|
||||
let doc = parse_markdown("# Hello world");
|
||||
assert_eq!(doc.blocks.len(), 1);
|
||||
assert_eq!(doc.blocks[0].kind, BlockKind::Heading { level: 1 });
|
||||
let line = &doc.blocks[0].lines[0];
|
||||
assert_eq!(line.plain_text(), "Hello world");
|
||||
assert!(line.spans.iter().all(|s| s.attrs.bold));
|
||||
assert!(line.spans.iter().all(|s| s.role == StyleRole::Strong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_paragraph_with_emphasis() {
|
||||
let doc = parse_markdown("plain *italic* **bold**");
|
||||
assert_eq!(doc.blocks.len(), 1);
|
||||
assert_eq!(doc.blocks[0].kind, BlockKind::Paragraph);
|
||||
let spans = &doc.blocks[0].lines[0].spans;
|
||||
let italic = spans.iter().find(|s| s.text == "italic").unwrap();
|
||||
assert!(italic.attrs.italic && !italic.attrs.bold);
|
||||
let bold = spans.iter().find(|s| s.text == "bold").unwrap();
|
||||
assert!(bold.attrs.bold);
|
||||
assert_eq!(bold.role, StyleRole::Strong);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_inline_code() {
|
||||
let doc = parse_markdown("use `cargo build` now");
|
||||
let spans = &doc.blocks[0].lines[0].spans;
|
||||
let code = spans.iter().find(|s| s.text == "cargo build").unwrap();
|
||||
assert_eq!(code.role, StyleRole::Code);
|
||||
assert_eq!(code.fill, FillRole::Code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_inline_math_into_unicode_math_span() {
|
||||
let doc = parse_markdown(r"Euler: $e^{i\pi} + 1 = 0$");
|
||||
let math = doc.blocks[0].lines[0]
|
||||
.spans
|
||||
.iter()
|
||||
.find(|span| span.role == StyleRole::Math)
|
||||
.expect("math span");
|
||||
assert_eq!(math.text, "e^(iπ) + 1 = 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_display_math_into_laid_out_block() {
|
||||
let doc = parse_markdown(r"$$\frac{x+1}{y}$$");
|
||||
assert_eq!(doc.blocks[0].kind, BlockKind::MathDisplay);
|
||||
let lines: Vec<String> = doc.blocks[0]
|
||||
.lines
|
||||
.iter()
|
||||
.map(StyledLine::plain_text)
|
||||
.collect();
|
||||
assert_eq!(lines, vec![" x+1 ", "─────", " y "]);
|
||||
assert!(
|
||||
doc.blocks[0]
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.all(|span| span.role == StyleRole::Math)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_all_common_latex_math_containers() {
|
||||
let inline = parse_markdown(r"Euler: \(e^{i\pi} + 1 = 0\)");
|
||||
assert!(
|
||||
inline.blocks[0].lines[0]
|
||||
.spans
|
||||
.iter()
|
||||
.any(|span| span.role == StyleRole::Math && span.text == "e^(iπ) + 1 = 0")
|
||||
);
|
||||
|
||||
for markdown in [
|
||||
r"\[\frac{x+1}{y}\]",
|
||||
"```math\n\\frac{x+1}{y}\n```",
|
||||
r"\begin{equation}\frac{x+1}{y}\end{equation}",
|
||||
] {
|
||||
let doc = parse_markdown(markdown);
|
||||
assert_eq!(doc.blocks[0].kind, BlockKind::MathDisplay, "{markdown}");
|
||||
assert!(
|
||||
doc.blocks[0]
|
||||
.lines
|
||||
.iter()
|
||||
.any(|line| line.plain_text().contains("─────")),
|
||||
"{markdown}: {:?}",
|
||||
doc.blocks[0].lines
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fenced_code_block() {
|
||||
let md = "```rust\nfn main() {}\nlet x = 1;\n```";
|
||||
let doc = parse_markdown(md);
|
||||
assert_eq!(
|
||||
block_kinds(&doc),
|
||||
vec![&BlockKind::CodeBlock {
|
||||
language: Some("rust".to_string())
|
||||
}]
|
||||
);
|
||||
let lines: Vec<String> = doc.blocks[0].lines.iter().map(|l| l.plain_text()).collect();
|
||||
assert_eq!(lines, vec!["fn main() {}", "let x = 1;"]);
|
||||
assert!(
|
||||
doc.blocks[0].lines[0]
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.fill == FillRole::Code)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_unordered_list_with_markers() {
|
||||
let doc = parse_markdown("- one\n- two");
|
||||
let items: Vec<_> = doc
|
||||
.blocks
|
||||
.iter()
|
||||
.filter(|b| matches!(b.kind, BlockKind::ListItem { .. }))
|
||||
.collect();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert!(items[0].lines[0].plain_text().starts_with("• "));
|
||||
assert!(items[0].lines[0].plain_text().contains("one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ordered_list_numbers() {
|
||||
let doc = parse_markdown("1. first\n2. second");
|
||||
let items: Vec<_> = doc
|
||||
.blocks
|
||||
.iter()
|
||||
.filter(|b| matches!(b.kind, BlockKind::ListItem { ordered: true, .. }))
|
||||
.collect();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert!(items[0].lines[0].plain_text().starts_with("1. "));
|
||||
assert!(items[1].lines[0].plain_text().starts_with("2. "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_nested_list_depth() {
|
||||
let doc = parse_markdown("- top\n - nested");
|
||||
let depths: Vec<usize> = doc
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b.kind {
|
||||
BlockKind::ListItem { depth, .. } => Some(depth),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(depths.contains(&0));
|
||||
assert!(depths.contains(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_blockquote() {
|
||||
let doc = parse_markdown("> quoted text");
|
||||
assert!(
|
||||
doc.blocks
|
||||
.iter()
|
||||
.any(|b| b.kind == BlockKind::BlockQuote && b.lines[0].plain_text().contains("quoted"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_thematic_break() {
|
||||
let doc = parse_markdown("a\n\n---\n\nb");
|
||||
assert!(
|
||||
doc.blocks
|
||||
.iter()
|
||||
.any(|b| b.kind == BlockKind::ThematicBreak)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_preserves_styling_and_width() {
|
||||
let line = StyledLine::from_spans(vec![
|
||||
StyledSpan::new("hello ", StyleRole::Text),
|
||||
StyledSpan::new("beautiful", StyleRole::Strong).bold(),
|
||||
StyledSpan::new(" world", StyleRole::Text),
|
||||
]);
|
||||
let wrapped = wrap_line(&line, 8, &ColumnWidth);
|
||||
assert!(wrapped.len() > 1);
|
||||
// Every produced line is within the width budget.
|
||||
for l in &wrapped {
|
||||
assert!(
|
||||
ColumnWidth.measure(&l.plain_text()) <= 8,
|
||||
"line too wide: {:?}",
|
||||
l.plain_text()
|
||||
);
|
||||
}
|
||||
// The bold word retains its styling across the wrap (it may be split).
|
||||
let has_bold = wrapped
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.any(|s| s.attrs.bold && s.role == StyleRole::Strong && !s.text.trim().is_empty());
|
||||
assert!(has_bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_hard_splits_overlong_word() {
|
||||
let line = StyledLine::from_spans(vec![StyledSpan::plain("abcdefghij")]);
|
||||
let wrapped = wrap_line(&line, 4, &ColumnWidth);
|
||||
assert!(wrapped.len() >= 3);
|
||||
for l in &wrapped {
|
||||
assert!(ColumnWidth.measure(&l.plain_text()) <= 4);
|
||||
}
|
||||
let joined: String = wrapped.iter().map(|l| l.plain_text()).collect();
|
||||
assert_eq!(joined, "abcdefghij");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_keeps_alignment() {
|
||||
let line = StyledLine::aligned(
|
||||
vec![StyledSpan::plain("one two three four five")],
|
||||
Alignment::Center,
|
||||
);
|
||||
let wrapped = wrap_line(&line, 7, &ColumnWidth);
|
||||
assert!(wrapped.iter().all(|l| l.alignment == Alignment::Center));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_noop_when_fits() {
|
||||
let line = StyledLine::from_spans(vec![StyledSpan::plain("short")]);
|
||||
let wrapped = wrap_line(&line, 80, &ColumnWidth);
|
||||
assert_eq!(wrapped.len(), 1);
|
||||
assert_eq!(wrapped[0].plain_text(), "short");
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Backend-neutral width measurement and line wrapping.
|
||||
//!
|
||||
//! Wrapping needs to know how "wide" text is, but width is backend-specific:
|
||||
//! the terminal measures in monospace columns, the desktop measures in pixels
|
||||
//! via font metrics. We abstract that behind [`WidthMeasure`] so the wrapping
|
||||
//! algorithm itself is shared.
|
||||
|
||||
use crate::model::{StyledLine, StyledSpan};
|
||||
|
||||
/// Measures the display width of text in a backend's units.
|
||||
pub trait WidthMeasure {
|
||||
/// Width of a whole string in this backend's units.
|
||||
fn measure(&self, text: &str) -> usize;
|
||||
|
||||
/// Width of a single `char`. Defaults to measuring a one-char string, but
|
||||
/// backends can override for speed.
|
||||
fn measure_char(&self, ch: char) -> usize {
|
||||
let mut buf = [0u8; 4];
|
||||
self.measure(ch.encode_utf8(&mut buf))
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal-style measurer: Unicode display columns (what ratatui uses).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ColumnWidth;
|
||||
|
||||
impl WidthMeasure for ColumnWidth {
|
||||
fn measure(&self, text: &str) -> usize {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
text.width()
|
||||
}
|
||||
|
||||
fn measure_char(&self, ch: char) -> usize {
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
ch.width().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a single styled line to `max_width` (in the measurer's units),
|
||||
/// preserving span styling across the wrap boundary. Wrapping prefers to break
|
||||
/// at whitespace; words longer than `max_width` are hard-split.
|
||||
///
|
||||
/// Alignment is carried onto every produced line.
|
||||
pub fn wrap_line<M: WidthMeasure>(
|
||||
line: &StyledLine,
|
||||
max_width: usize,
|
||||
measure: &M,
|
||||
) -> Vec<StyledLine> {
|
||||
if max_width == 0 {
|
||||
return vec![line.clone()];
|
||||
}
|
||||
if measure.measure(&line.plain_text()) <= max_width {
|
||||
return vec![line.clone()];
|
||||
}
|
||||
|
||||
let mut out: Vec<StyledLine> = Vec::new();
|
||||
let mut cur: Vec<StyledSpan> = Vec::new();
|
||||
let mut cur_width = 0usize;
|
||||
|
||||
let push_line = |out: &mut Vec<StyledLine>, spans: Vec<StyledSpan>| {
|
||||
out.push(StyledLine {
|
||||
spans,
|
||||
alignment: line.alignment,
|
||||
});
|
||||
};
|
||||
|
||||
for span in &line.spans {
|
||||
// Split the span text into word / whitespace runs, keeping whitespace
|
||||
// so we can drop a trailing space at a wrap point but keep interior
|
||||
// spacing.
|
||||
for token in tokenize(&span.text) {
|
||||
let tok_width = measure.measure(token);
|
||||
let is_ws = token.chars().all(char::is_whitespace);
|
||||
|
||||
if cur_width + tok_width <= max_width {
|
||||
push_token(&mut cur, span, token);
|
||||
cur_width += tok_width;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Token doesn't fit on the current line.
|
||||
if is_ws {
|
||||
// Whitespace at a wrap boundary is dropped; start a fresh line.
|
||||
if !cur.is_empty() {
|
||||
push_line(&mut out, std::mem::take(&mut cur));
|
||||
cur_width = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flush what we have, then place the word (hard-splitting if the
|
||||
// word itself is wider than max_width).
|
||||
if !cur.is_empty() {
|
||||
push_line(&mut out, std::mem::take(&mut cur));
|
||||
cur_width = 0;
|
||||
}
|
||||
|
||||
if tok_width <= max_width {
|
||||
push_token(&mut cur, span, token);
|
||||
cur_width += tok_width;
|
||||
} else {
|
||||
// Hard-split an over-long word by char width.
|
||||
let mut chunk = String::new();
|
||||
let mut chunk_w = 0usize;
|
||||
for ch in token.chars() {
|
||||
let cw = measure.measure_char(ch);
|
||||
if chunk_w + cw > max_width && !chunk.is_empty() {
|
||||
push_token(&mut cur, span, &chunk);
|
||||
push_line(&mut out, std::mem::take(&mut cur));
|
||||
chunk.clear();
|
||||
chunk_w = 0;
|
||||
}
|
||||
chunk.push(ch);
|
||||
chunk_w += cw;
|
||||
}
|
||||
if !chunk.is_empty() {
|
||||
push_token(&mut cur, span, &chunk);
|
||||
cur_width = chunk_w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !cur.is_empty() {
|
||||
push_line(&mut out, cur);
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(StyledLine {
|
||||
spans: vec![],
|
||||
alignment: line.alignment,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap many lines.
|
||||
pub fn wrap_lines<M: WidthMeasure>(
|
||||
lines: &[StyledLine],
|
||||
max_width: usize,
|
||||
measure: &M,
|
||||
) -> Vec<StyledLine> {
|
||||
lines
|
||||
.iter()
|
||||
.flat_map(|l| wrap_line(l, max_width, measure))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn push_token(cur: &mut Vec<StyledSpan>, src: &StyledSpan, text: &str) {
|
||||
// Merge into the previous span when it shares styling, to avoid span
|
||||
// fragmentation across token boundaries.
|
||||
if let Some(last) = cur.last_mut()
|
||||
&& last.role == src.role
|
||||
&& last.fill == src.fill
|
||||
&& last.attrs == src.attrs
|
||||
{
|
||||
last.text.push_str(text);
|
||||
return;
|
||||
}
|
||||
cur.push(StyledSpan {
|
||||
text: text.to_string(),
|
||||
role: src.role,
|
||||
fill: src.fill,
|
||||
attrs: src.attrs,
|
||||
});
|
||||
}
|
||||
|
||||
/// Split text into alternating non-whitespace / whitespace runs.
|
||||
fn tokenize(text: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0usize;
|
||||
let mut prev_ws: Option<bool> = None;
|
||||
for (idx, ch) in text.char_indices() {
|
||||
let is_ws = ch.is_whitespace();
|
||||
match prev_ws {
|
||||
Some(p) if p != is_ws => {
|
||||
out.push(&text[start..idx]);
|
||||
start = idx;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
prev_ws = Some(is_ws);
|
||||
}
|
||||
if start < text.len() {
|
||||
out.push(&text[start..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
use jcode_render_core::{
|
||||
BlockKind, StyleRole, normalize_latex_math, parse_markdown, render_display_latex,
|
||||
render_inline_latex,
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
fn assert_visible_and_deterministic(source: &str) {
|
||||
let inline = render_inline_latex(source);
|
||||
let display = render_display_latex(source);
|
||||
assert_eq!(inline, render_inline_latex(source), "inline: {source:?}");
|
||||
assert_eq!(display, render_display_latex(source), "display: {source:?}");
|
||||
if !source.trim().is_empty() {
|
||||
assert!(
|
||||
!inline.trim().is_empty(),
|
||||
"inline content vanished: {source:?}"
|
||||
);
|
||||
assert!(
|
||||
display.iter().any(|line| !line.trim().is_empty()),
|
||||
"display content vanished: {source:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_the_supported_symbol_vocabulary() {
|
||||
let cases = [
|
||||
(r"\alpha \beta \gamma \delta \epsilon \varepsilon", "αβγδεε"),
|
||||
(
|
||||
r"\theta \vartheta \lambda \mu \pi \varpi \phi \varphi \omega",
|
||||
"θϑλμπϖφϕω",
|
||||
),
|
||||
(
|
||||
r"\Gamma \Delta \Theta \Lambda \Xi \Pi \Sigma \Phi \Psi \Omega",
|
||||
"ΓΔΘΛΞΠΣΦΨΩ",
|
||||
),
|
||||
(
|
||||
r"\sum \prod \coprod \int \iint \iiint \oint \partial \nabla \infty",
|
||||
"∑∏∐∫∬∭∮∂∇∞",
|
||||
),
|
||||
(r"\times \div \cdot \circ \pm \mp \ast \star", "×÷·∘±∓∗⋆"),
|
||||
(
|
||||
r"\le \leq \ge \geq \ne \neq \approx \sim \simeq \equiv \propto",
|
||||
"≤≤≥≥≠≠≈∼≃≡∝",
|
||||
),
|
||||
(
|
||||
r"\in \notin \ni \subset \supset \subseteq \supseteq \cup \cap \setminus",
|
||||
"∈∉∋⊂⊃⊆⊇∪∩∖",
|
||||
),
|
||||
(
|
||||
r"\forall \exists \nexists \neg \land \lor \oplus \otimes \vdash \models",
|
||||
"∀∃∄¬∧∨⊕⊗⊢⊨",
|
||||
),
|
||||
(
|
||||
r"\to \leftarrow \leftrightarrow \Rightarrow \Leftarrow \Leftrightarrow \mapsto \uparrow \downarrow",
|
||||
"→←↔⇒⇐⇔↦↑↓",
|
||||
),
|
||||
(
|
||||
r"\ldots \cdots \vdots \ddots \angle \degree \prime \perp \parallel \mid",
|
||||
"…⋯⋮⋱∠°′⊥∥∣",
|
||||
),
|
||||
(
|
||||
r"\langle x \rangle \lceil x \rceil \lfloor x \rfloor",
|
||||
"⟨x ⟩⌈x ⌉⌊x ⌋",
|
||||
),
|
||||
(r"\emptyset \varnothing \ell \hbar", "∅∅ℓℏ"),
|
||||
];
|
||||
for (source, expected) in cases {
|
||||
assert_eq!(render_inline_latex(source), expected, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_all_supported_script_characters_and_falls_back_for_others() {
|
||||
assert_eq!(
|
||||
render_inline_latex("x^{0123456789+-=()ni}"),
|
||||
"x⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ"
|
||||
);
|
||||
assert_eq!(
|
||||
render_inline_latex("x_{0123456789+-=()aehijklmnoprstuvx}"),
|
||||
"x₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ"
|
||||
);
|
||||
assert_eq!(render_inline_latex("x^{ab}_{qy}"), "x_(qy)^(ab)");
|
||||
assert_eq!(render_inline_latex("x_1^2_3^4"), "x₁₃²⁴");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_commands_preserve_content_and_spacing() {
|
||||
for command in [
|
||||
"mathbf",
|
||||
"mathrm",
|
||||
"mathit",
|
||||
"mathsf",
|
||||
"mathtt",
|
||||
"mathcal",
|
||||
"mathbb",
|
||||
"boldsymbol",
|
||||
"displaystyle",
|
||||
"scriptstyle",
|
||||
] {
|
||||
assert_eq!(
|
||||
render_inline_latex(&format!(r"\{command}{{x_2+y}}")),
|
||||
"x₂+y",
|
||||
"{command}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
render_inline_latex(r"a\,b\;c\:d\quad e\qquad f\!g"),
|
||||
"a b c d e fg"
|
||||
);
|
||||
assert_eq!(
|
||||
render_inline_latex(r"\text{rate = 5 percent}"),
|
||||
"rate = 5 percent"
|
||||
);
|
||||
assert_eq!(
|
||||
render_inline_latex(r"\operatorname{arg max}_x f(x)"),
|
||||
"arg maxₓ f(x)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accents_lines_and_delimiters_are_stable_unicode() {
|
||||
assert_eq!(
|
||||
render_inline_latex(r"\hat{x}\bar{y}\vec{v}\dot{x}\ddot{y}\tilde{z}"),
|
||||
"x̂ȳv⃗ẋÿz̃"
|
||||
);
|
||||
assert_eq!(render_inline_latex(r"\overline{ab}"), "a̅b̅");
|
||||
assert_eq!(render_inline_latex(r"\underline{ab}"), "a̲b̲");
|
||||
assert_eq!(render_inline_latex(r"\left( x \right)"), "( x )");
|
||||
assert_eq!(render_inline_latex(r"\left\{ x \right\}"), "{ x }");
|
||||
assert_eq!(render_inline_latex(r"\bigl\langle x \bigr\rangle"), "⟨ x ⟩");
|
||||
assert_eq!(render_inline_latex(r"\left. x \right|"), " x |");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fraction_root_and_script_layouts_have_consistent_geometry() {
|
||||
let cases = [
|
||||
r"\frac{a}{b}",
|
||||
r"\frac{x+1}{y-z}",
|
||||
r"\frac{\frac{a}{b}}{\sqrt{x}}",
|
||||
r"x^{a+b}_{i-j}",
|
||||
r"\sqrt[3]{\frac{x}{y}}",
|
||||
r"A+\frac{界}{\alpha}+B",
|
||||
];
|
||||
for source in cases {
|
||||
let lines = render_display_latex(source);
|
||||
assert!(!lines.is_empty(), "{source}");
|
||||
let width = lines.iter().map(|line| line.width()).max().unwrap();
|
||||
assert!(width > 0, "{source}: {lines:?}");
|
||||
assert!(
|
||||
lines.iter().all(|line| line.width() <= width),
|
||||
"{source}: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().all(|line| !line.contains('\t')),
|
||||
"{source}: {lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_matrix_environment_has_the_expected_delimiter_family() {
|
||||
let cases = [
|
||||
("matrix", "", ""),
|
||||
("smallmatrix", "", ""),
|
||||
("array", "", ""),
|
||||
("pmatrix", "⎛", "⎞"),
|
||||
("bmatrix", "⎡", "⎤"),
|
||||
("Bmatrix", "⎧", "⎫"),
|
||||
("vmatrix", "│", "│"),
|
||||
("Vmatrix", "‖", "‖"),
|
||||
("cases", "⎧", ""),
|
||||
];
|
||||
for (environment, left, right) in cases {
|
||||
let source = if environment == "array" {
|
||||
r"\begin{array}{cc}a & bb \\ ccc & d\end{array}".to_string()
|
||||
} else {
|
||||
format!(r"\begin{{{environment}}}a & bb \\ ccc & d\end{{{environment}}}")
|
||||
};
|
||||
let lines = render_display_latex(&source);
|
||||
assert_eq!(lines.len(), 2, "{environment}: {lines:?}");
|
||||
if !left.is_empty() {
|
||||
assert!(lines[0].starts_with(left), "{environment}: {lines:?}");
|
||||
}
|
||||
if !right.is_empty() {
|
||||
assert!(lines[0].ends_with(right), "{environment}: {lines:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
lines[0].width(),
|
||||
lines[1].width(),
|
||||
"{environment}: {lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrices_handle_ragged_rows_nested_groups_and_row_spacing() {
|
||||
let ragged = render_display_latex(r"\begin{bmatrix}a & bb & ccc \\ d \\ e & ff\end{bmatrix}");
|
||||
assert_eq!(ragged.len(), 3);
|
||||
assert!(
|
||||
ragged.iter().all(|line| line.width() == ragged[0].width()),
|
||||
"{ragged:?}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
render_inline_latex(r"\begin{matrix}{a & b} & \frac{c&d}{e} \\ x & y\end{matrix}"),
|
||||
"a & b, (c&d)⁄e; x, y"
|
||||
);
|
||||
assert_eq!(
|
||||
render_inline_latex(r"\begin{matrix}a\\[12pt]b\\[-2pt]c\\\end{matrix}"),
|
||||
"a; b; c"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_display_environments_render_their_body() {
|
||||
for environment in ["equation", "equation*", "displaymath"] {
|
||||
let source = format!(r"\begin{{{environment}}}\frac{{x}}{{y}}\end{{{environment}}}");
|
||||
let lines = render_display_latex(&source);
|
||||
assert!(
|
||||
lines.iter().any(|line| line.contains('─')),
|
||||
"{source}: {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
!lines.iter().any(|line| line.contains("begin")),
|
||||
"{source}: {lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_commands_and_environments_remain_debuggable() {
|
||||
let cases = [
|
||||
(r"\unknown", r"\unknown"),
|
||||
(r"\unknown value", r"\unknown value"),
|
||||
(r"\unknown{value}", r"\unknownvalue"),
|
||||
(
|
||||
r"\begin{mystery}x+y\end{mystery}",
|
||||
r"\begin{mystery}x+y\end{mystery}",
|
||||
),
|
||||
(r"\end{orphan}", r"\endorphan"),
|
||||
];
|
||||
for (source, expected) in cases {
|
||||
assert_eq!(render_inline_latex(source), expected, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_constructs_never_panic_and_keep_diagnostic_content() {
|
||||
let cases = [
|
||||
"{",
|
||||
"}",
|
||||
"[",
|
||||
"]",
|
||||
"^",
|
||||
"_",
|
||||
"\\",
|
||||
r"\frac",
|
||||
r"\frac{}",
|
||||
r"\frac{}{}",
|
||||
r"\sqrt",
|
||||
r"\sqrt[",
|
||||
r"\sqrt[]{}",
|
||||
r"\text",
|
||||
r"\left",
|
||||
r"\right",
|
||||
r"\begin",
|
||||
r"\begin{}",
|
||||
r"\begin{matrix}",
|
||||
r"\begin{matrix}a&b\end{pmatrix}",
|
||||
r"x_{",
|
||||
r"x^{}}",
|
||||
r"x^^__",
|
||||
"😀_{界",
|
||||
"\0\u{1b}[31m",
|
||||
];
|
||||
for source in cases {
|
||||
assert_visible_and_deterministic(source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_latex_grammar_corpus_is_total_and_deterministic() {
|
||||
let atoms = ["x", "7", "α", "界", "😀", r"\beta", r"\unknown"];
|
||||
let wrappers = [
|
||||
(r"{", "}"),
|
||||
(r"\frac{", "}{2}"),
|
||||
(r"\sqrt{", "}"),
|
||||
(r"\sqrt[3]{", "}"),
|
||||
(r"\mathbf{", "}"),
|
||||
(r"\left(", r"\right)"),
|
||||
(r"\begin{bmatrix}", r" & y \\ z & w\end{bmatrix}"),
|
||||
];
|
||||
let scripts = ["", "_1", "^2", "_{i+j}", "^{n-1}", "_i^2"];
|
||||
for atom in atoms {
|
||||
for (open, close) in wrappers {
|
||||
for script in scripts {
|
||||
let source = format!("{open}{atom}{close}{script}");
|
||||
assert_visible_and_deterministic(&source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_flat_inputs_and_deep_commands_remain_bounded() {
|
||||
let flat = format!("{}{}", r"\alpha+".repeat(20_000), "x");
|
||||
let inline = render_inline_latex(&flat);
|
||||
assert!(inline.ends_with('x'));
|
||||
assert!(inline.len() < flat.len());
|
||||
|
||||
let mut nested = "x".to_string();
|
||||
for _ in 0..2_000 {
|
||||
nested = format!(r"\sqrt{{{nested}}}");
|
||||
}
|
||||
std::thread::Builder::new()
|
||||
.stack_size(256 * 1024)
|
||||
.spawn(move || assert_visible_and_deterministic(&nested))
|
||||
.expect("spawn bounded stack test")
|
||||
.join()
|
||||
.expect("deep command rendering must not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_every_supported_math_fence_spelling() {
|
||||
for info in [
|
||||
"math",
|
||||
"MATH",
|
||||
"latex",
|
||||
"LaTeX",
|
||||
"tex",
|
||||
"TEX",
|
||||
"katex",
|
||||
"KaTeX",
|
||||
"{.math}",
|
||||
"{latex}",
|
||||
"math title=demo",
|
||||
] {
|
||||
let source = format!("```{info}\n\\alpha_2\n```");
|
||||
assert_eq!(normalize_latex_math(&source), "$$\n\\alpha_2\n$$", "{info}");
|
||||
}
|
||||
assert_eq!(
|
||||
normalize_latex_math(" ~~~~latex\n\\[x^2\\]\n ~~~~~"),
|
||||
" $$\nx^2\n $$"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_protects_all_literal_code_forms() {
|
||||
let cases = [
|
||||
r"`\(x\)`",
|
||||
r"``code ` and \[x\]``",
|
||||
"```rust\n\\(x\\)\n```",
|
||||
"~~~text\n\\begin{matrix}x\\end{matrix}\n~~~",
|
||||
" \\[indented code\\]",
|
||||
"\t\\(tab-indented code\\)",
|
||||
];
|
||||
for source in cases {
|
||||
assert_eq!(normalize_latex_math(source), source, "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fence_like_content_does_not_end_a_generic_code_fence() {
|
||||
for source in [
|
||||
"```text\n```not a closing fence\n\\(literal\\)\n```",
|
||||
"~~~~text\n~~~~still content\n\\[literal\\]\n~~~~",
|
||||
"````rust\n``` shorter fence\n\\begin{matrix}x\\end{matrix}\n````",
|
||||
] {
|
||||
assert_eq!(normalize_latex_math(source), source, "{source:?}");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
normalize_latex_math("```text\n\\(literal\\)\n```\n\n\\(real math\\)"),
|
||||
"```text\n\\(literal\\)\n```\n\n$real math$"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_delimiters_only_when_balanced_and_unescaped() {
|
||||
let cases = [
|
||||
(r"before \(x^2\) after", r"before $x^2$ after"),
|
||||
(r"before \[x^2\] after", r"before $$x^2$$ after"),
|
||||
(r"\\(literal\\)", r"\\(literal\\)"),
|
||||
(r"\(missing", r"\(missing"),
|
||||
(r"missing\)", r"missing\)"),
|
||||
(r"\[label\](url)", r"\[label\](url)"),
|
||||
(r"$x + \(y\)$", r"$x + \(y\)$"),
|
||||
(r"$$x + \[y\]$$", r"$$x + \[y\]$$"),
|
||||
];
|
||||
for (source, expected) in cases {
|
||||
assert_eq!(normalize_latex_math(source), expected, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_all_standalone_display_environments() {
|
||||
let environments = [
|
||||
"equation",
|
||||
"equation*",
|
||||
"displaymath",
|
||||
"align",
|
||||
"align*",
|
||||
"aligned",
|
||||
"aligned*",
|
||||
"gather",
|
||||
"gather*",
|
||||
"gathered",
|
||||
"multline",
|
||||
"multline*",
|
||||
"split",
|
||||
"eqnarray",
|
||||
"eqnarray*",
|
||||
"matrix",
|
||||
"smallmatrix",
|
||||
"array",
|
||||
"pmatrix",
|
||||
"bmatrix",
|
||||
"Bmatrix",
|
||||
"vmatrix",
|
||||
"Vmatrix",
|
||||
"cases",
|
||||
"cases*",
|
||||
];
|
||||
for environment in environments {
|
||||
let source = format!(r"prefix \begin{{{environment}}}x\end{{{environment}}} suffix");
|
||||
let normalized = normalize_latex_math(&source);
|
||||
assert!(normalized.contains("$$\n"), "{environment}: {normalized:?}");
|
||||
assert!(normalized.contains("\n$$"), "{environment}: {normalized:?}");
|
||||
assert!(normalized.contains(&format!(r"\begin{{{environment}}}x\end{{{environment}}}")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_normalization_handles_nesting_and_mismatches() {
|
||||
let nested = r"\begin{align}a\begin{align}b\end{align}c\end{align}";
|
||||
let normalized = normalize_latex_math(nested);
|
||||
assert_eq!(normalized.matches("$$").count(), 2, "{normalized}");
|
||||
assert!(normalized.contains(nested));
|
||||
|
||||
for source in [
|
||||
r"\begin{align}x",
|
||||
r"\begin{align}x\end{equation}",
|
||||
r"\begin{unknown}x\end{unknown}",
|
||||
] {
|
||||
assert_eq!(normalize_latex_math(source), source, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_pipeline_preserves_structure_and_math_roles() {
|
||||
let markdown = concat!(
|
||||
"Price: $35.00 and inline \\(x_2 + \\alpha\\).\n\n",
|
||||
"\\[\\frac{x+1}{y}\\]\n\n",
|
||||
"```rust\nlet literal = r\"\\(x\\)\";\n```\n"
|
||||
);
|
||||
let doc = parse_markdown(markdown);
|
||||
assert!(
|
||||
doc.blocks
|
||||
.iter()
|
||||
.any(|block| block.kind == BlockKind::Paragraph)
|
||||
);
|
||||
let display = doc
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|block| block.kind == BlockKind::MathDisplay)
|
||||
.expect("display math block");
|
||||
assert!(
|
||||
display
|
||||
.lines
|
||||
.iter()
|
||||
.all(|line| line.spans.iter().all(|span| span.role == StyleRole::Math))
|
||||
);
|
||||
assert!(
|
||||
display
|
||||
.lines
|
||||
.iter()
|
||||
.any(|line| line.plain_text().contains('─'))
|
||||
);
|
||||
|
||||
let all_text = doc
|
||||
.blocks
|
||||
.iter()
|
||||
.flat_map(|block| &block.lines)
|
||||
.map(|line| line.plain_text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(all_text.contains("$35.00"), "{all_text}");
|
||||
assert!(all_text.contains("x₂ + α"), "{all_text}");
|
||||
assert!(all_text.contains(r"\(x\)"), "{all_text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_container_matrix_has_backend_neutral_parity() {
|
||||
let containers = [
|
||||
r"Inline \(\alpha_2 + x^2\).",
|
||||
r"\[\frac{x+1}{y}\]",
|
||||
"```math\n\\frac{x+1}{y}\n```",
|
||||
"~~~latex\n\\begin{bmatrix}a & b \\\\ c & d\\end{bmatrix}\n~~~",
|
||||
r"\begin{align*}x &= 1 \\ y &= 2\end{align*}",
|
||||
];
|
||||
for markdown in containers {
|
||||
let first = parse_markdown(markdown);
|
||||
let second = parse_markdown(markdown);
|
||||
assert_eq!(first, second, "{markdown}");
|
||||
assert!(
|
||||
first
|
||||
.blocks
|
||||
.iter()
|
||||
.flat_map(|block| &block.lines)
|
||||
.flat_map(|line| &line.spans)
|
||||
.any(|span| span.role == StyleRole::Math),
|
||||
"{markdown}: {first:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use jcode_render_core::{BlockKind, normalize_latex_math, parse_markdown};
|
||||
|
||||
fn math_display_count(markdown: &str) -> usize {
|
||||
parse_markdown(markdown)
|
||||
.blocks
|
||||
.iter()
|
||||
.filter(|block| block.kind == BlockKind::MathDisplay)
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_exact_multiline_equation_response_from_the_tui() {
|
||||
let response = concat!(
|
||||
"\\[\n\\boxed{\ne^{i\\pi}+1=0\n}\n\\]\n\n",
|
||||
"\\[\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx=\\sqrt{\\pi}\n\\]\n\n",
|
||||
"\\[\nx=\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}\n\\]\n\n",
|
||||
"\\[\n\\nabla\\cdot\\mathbf{E}=\\frac{\\rho}{\\varepsilon_0}\n\\]\n\n",
|
||||
"\\[\n\\frac{\\partial \\psi}{\\partial t}\n=\n",
|
||||
"\\alpha\\frac{\\partial^2\\psi}{\\partial x^2}\n\\]",
|
||||
);
|
||||
|
||||
let normalized = normalize_latex_math(response);
|
||||
let parsed = parse_markdown(response);
|
||||
assert_eq!(
|
||||
math_display_count(response),
|
||||
5,
|
||||
"normalized={normalized:?} parsed={parsed:#?}"
|
||||
);
|
||||
assert_eq!(normalized.matches("$$").count(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_streaming_prefix_is_deterministic_and_the_complete_response_is_math() {
|
||||
let equation = concat!(
|
||||
"Result:\n\n\\[\n",
|
||||
"\\frac{\\partial \\psi}{\\partial t}\n=\n",
|
||||
"\\alpha\\frac{\\partial^2\\psi}{\\partial x^2}\n\\]",
|
||||
);
|
||||
|
||||
for end in equation
|
||||
.char_indices()
|
||||
.map(|(index, _)| index)
|
||||
.chain(std::iter::once(equation.len()))
|
||||
{
|
||||
let prefix = &equation[..end];
|
||||
let first = parse_markdown(prefix);
|
||||
let second = parse_markdown(prefix);
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"nondeterministic prefix ending at byte {end}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(math_display_count(equation), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_math_survives_common_markdown_containers() {
|
||||
let cases = [
|
||||
("> \\[\n> x^2\n> =\n> y^2\n> \\]", "blockquote"),
|
||||
("- \\[\n x^2\n =\n y^2\n \\]", "bullet list"),
|
||||
("1. \\[\n x^2\n =\n y^2\n \\]", "ordered list"),
|
||||
];
|
||||
|
||||
for (source, label) in cases {
|
||||
assert!(
|
||||
math_display_count(source) >= 1,
|
||||
"{label} lost display math after normalization: {:?}",
|
||||
normalize_latex_math(source)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_display_math_resists_markdown_block_interruptors() {
|
||||
let bodies = [
|
||||
"x\n=\ny",
|
||||
"x\n---\ny",
|
||||
"x\n\ny",
|
||||
"x\n# not a heading\ny",
|
||||
"x\n> not a quote\ny",
|
||||
"x\n- not a list\ny",
|
||||
"x\n1. not a list\ny",
|
||||
"x\n``` not a fence\ny",
|
||||
];
|
||||
|
||||
for body in bodies {
|
||||
for source in [format!("$$\n{body}\n$$"), format!("\\[\n{body}\n\\]")] {
|
||||
assert_eq!(
|
||||
math_display_count(&source),
|
||||
1,
|
||||
"block syntax escaped display math: {source:?}; normalized={:?}",
|
||||
normalize_latex_math(&source)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_and_adjacent_display_blocks_remain_balanced_and_idempotent() {
|
||||
let source = "\\[\r\nx\r\n=\r\ny\r\n\\]\r\n\r\n$$\r\na+b\r\n$$";
|
||||
let normalized = normalize_latex_math(source);
|
||||
assert_eq!(math_display_count(source), 2, "{normalized:?}");
|
||||
assert_eq!(normalize_latex_math(&normalized), normalized);
|
||||
assert_eq!(normalized.matches("$$").count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stabilization_preserves_newlines_comments_and_is_idempotent() {
|
||||
let source = "\\[\na % this comment must still end at the newline\nb\n=\nc\n\\]";
|
||||
let normalized = normalize_latex_math(source);
|
||||
|
||||
assert_eq!(
|
||||
normalized,
|
||||
"$$\na % this comment must still end at the newline\nb\n{}=\nc\n$$"
|
||||
);
|
||||
assert_eq!(normalize_latex_math(&normalized), normalized);
|
||||
assert_eq!(math_display_count(source), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_quote_list_display_math_keeps_its_container() {
|
||||
let source = "> - \\[\n> x\n> =\n> y\n> \\]";
|
||||
let normalized = normalize_latex_math(source);
|
||||
|
||||
assert!(normalized.contains("> {}="), "{normalized:?}");
|
||||
assert_eq!(math_display_count(source), 1, "{normalized:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_and_literal_delimiters_never_become_math() {
|
||||
let cases = [
|
||||
r"\\[escaped\\]",
|
||||
r"`\\[inline code\\]`",
|
||||
"```text\n\\[fenced code\\]\n```",
|
||||
" \\[indented code\\]",
|
||||
r"\\[missing close",
|
||||
];
|
||||
|
||||
for source in cases {
|
||||
assert_eq!(
|
||||
math_display_count(source),
|
||||
0,
|
||||
"literal case parsed as math: {source:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let unclosed_display = "$$\nx\n=\ny";
|
||||
assert_eq!(normalize_latex_math(unclosed_display), unclosed_display);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_multiline_inline_delimiters_promote_without_losing_content() {
|
||||
for source in [
|
||||
"\\(\nx\n=\ny\n\\)",
|
||||
"$\nx\n=\ny\n$",
|
||||
"> $\n> x\n> =\n> y\n> $",
|
||||
"- $\n x\n =\n y\n $",
|
||||
"$\r\nx\r\n=\r\ny\r\n$",
|
||||
] {
|
||||
let normalized = normalize_latex_math(source);
|
||||
let visible = parse_markdown(source)
|
||||
.blocks
|
||||
.iter()
|
||||
.flat_map(|block| &block.lines)
|
||||
.map(|line| line.plain_text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
for expected in ["x", "=", "y"] {
|
||||
assert!(visible.contains(expected), "{source:?} => {visible:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
math_display_count(source),
|
||||
1,
|
||||
"{source:?} => {normalized:?}"
|
||||
);
|
||||
assert_eq!(normalize_latex_math(&normalized), normalized);
|
||||
}
|
||||
|
||||
let unclosed = "$\nx\n=\ny";
|
||||
assert_eq!(normalize_latex_math(unclosed), unclosed);
|
||||
|
||||
let fenced = "```text\n$\nx\n$\n```";
|
||||
assert_eq!(normalize_latex_math(fenced), fenced);
|
||||
assert_eq!(math_display_count(fenced), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user