From 9a0be42ca7c0fa0f42e2152aa82eb3dd042073ad Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 17:27:56 +0200 Subject: [PATCH 1/7] feat(table): honour column alignment from the delimiter row Header and body cells are padded left, centred or right per the table's :--, :-: and --: markers, line by line for wrapped cells, measured in display columns so CJK and emoji cells stay aligned. --- src/layout/table.rs | 39 ++++++++++-- tests/rendering_features.rs | 115 ++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 tests/rendering_features.rs diff --git a/src/layout/table.rs b/src/layout/table.rs index 7b282ac..8822ba1 100644 --- a/src/layout/table.rs +++ b/src/layout/table.rs @@ -1,6 +1,6 @@ use super::{SpanStyle, StyledLine, StyledSpan}; use crate::theme::Theme; -use comrak::nodes::{AstNode, NodeValue}; +use comrak::nodes::{AstNode, NodeValue, TableAlignment}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -22,6 +22,10 @@ pub fn layout_table<'a>( } let num_cols = headers.len(); + let alignments: Vec = match &node.data.borrow().value { + NodeValue::Table(t) => t.alignments.clone(), + _ => Vec::new(), + }; // Calculate ideal column widths from content let mut col_widths: Vec = headers.iter().map(|h| cell_width(h)).collect(); @@ -93,6 +97,7 @@ pub fn layout_table<'a>( render_row_lines( &header_wrapped, &col_widths, + &alignments, Some(header_color), border_color, false, @@ -117,6 +122,7 @@ pub fn layout_table<'a>( render_row_lines( &row_wrapped, &col_widths, + &alignments, None, border_color, i % 2 == 1, @@ -387,6 +393,7 @@ fn force_break(word: &str, max_width: usize) -> Vec { fn render_row_lines( wrapped_cols: &[Vec], widths: &[usize], + alignments: &[TableAlignment], text_color: Option<&String>, border_color: &str, alt_row: bool, @@ -420,8 +427,14 @@ fn render_row_lines( .map(|s| s.as_str()) .unwrap_or(""); - let cell_width = cell_text.width(); - let padding = width.saturating_sub(cell_width); + let (pad_left, pad_right) = align_padding( + alignments + .get(col_idx) + .copied() + .unwrap_or(TableAlignment::None), + *width, + cell_text.width(), + ); let mut style = SpanStyle::default(); if let Some(c) = text_color { @@ -433,7 +446,11 @@ fn render_row_lines( } line.push(StyledSpan { - text: format!(" {cell_text}{} ", " ".repeat(padding)), + text: format!( + " {}{cell_text}{} ", + " ".repeat(pad_left), + " ".repeat(pad_right) + ), style, }); line.push(StyledSpan { @@ -449,6 +466,20 @@ fn render_row_lines( } } +/// Left and right padding that places `text_width` columns of cell text in +/// a `width`-column slot per the column's `:--`/`:-:`/`--:` alignment. Each +/// wrapped line of a cell is placed on its own, so a right-aligned +/// multi-line cell is ragged left. Centering leans left on an odd remainder +/// (as GitHub's HTML tables do). +fn align_padding(align: TableAlignment, width: usize, text_width: usize) -> (usize, usize) { + let free = width.saturating_sub(text_width); + match align { + TableAlignment::Right => (free, 0), + TableAlignment::Center => (free / 2, free - free / 2), + TableAlignment::Left | TableAlignment::None => (0, free), + } +} + fn border_line( widths: &[usize], left: &str, diff --git a/tests/rendering_features.rs b/tests/rendering_features.rs new file mode 100644 index 0000000..3c6a8b3 --- /dev/null +++ b/tests/rendering_features.rs @@ -0,0 +1,115 @@ +//! Rendering features through the real `--plain` pipeline (parse, layout, +//! colourless output): table alignment, extensions, frontmatter, math. + +use ink_md::render::plain::render_plain_with_color; +use ink_md::{Args, Spacing}; +use unicode_width::UnicodeWidthStr; + +fn args() -> Args { + Args { + inputs: vec![], + theme: "dark".to_string(), + width: Some(80), + slides: false, + plain: true, + watch: false, + toc: false, + images: ink_md::image::ImageMode::Off, + image_protocol: ink_md::graphics::ProtocolChoice::HalfBlocks, + frontmatter: false, + spacing: Spacing::Normal, + mouse_capture: true, + clipboard: ink_md::clipboard::ClipboardMode::Off, + } +} + +fn render(src: &str) -> String { + render_plain_with_color(src, &args(), false).unwrap() +} + +fn render_with(src: &str, f: impl FnOnce(&mut Args)) -> String { + let mut a = args(); + f(&mut a); + render_plain_with_color(src, &a, false).unwrap() +} + +/// The table's rows (lines starting with the cell border), margin removed. +fn table_rows(out: &str) -> Vec { + out.lines() + .map(|l| l.trim_start().to_string()) + .filter(|l| l.starts_with('│')) + .collect() +} + +// ── Table alignment ── + +#[test] +fn table_right_alignment_pads_on_the_left() { + let out = render("| n | name |\n|--:|------|\n| 1 | a |\n| 12345 | b |\n"); + let rows = table_rows(&out); + assert_eq!(rows[0], "│ n │ name │"); + assert_eq!(rows[1], "│ 1 │ a │"); + assert_eq!(rows[2], "│ 12345 │ b │"); +} + +#[test] +fn table_center_alignment_splits_the_padding() { + let out = render("| centered |\n|:--------:|\n| x |\n| ab |\n"); + let rows = table_rows(&out); + assert_eq!(rows[0], "│ centered │"); + // 7 free columns: 3 left, 4 right (leans left on an odd remainder). + assert_eq!(rows[1], "│ x │"); + assert_eq!(rows[2], "│ ab │"); +} + +#[test] +fn table_default_and_left_alignment_pad_on_the_right() { + let out = render("| a | b |\n|---|:--|\n| x | y |\n| long | longer |\n"); + let rows = table_rows(&out); + assert_eq!(rows[1], "│ x │ y │"); +} + +#[test] +fn table_mixed_alignment() { + let out = render( + "| left | right | center | def |\n|:-----|------:|:------:|-----|\n| a | 1 | x | d |\n| longer text | 12345 | mid | e |\n", + ); + let rows = table_rows(&out); + assert_eq!(rows[0], "│ left │ right │ center │ def │"); + assert_eq!(rows[1], "│ a │ 1 │ x │ d │"); + assert_eq!(rows[2], "│ longer text │ 12345 │ mid │ e │"); +} + +#[test] +fn table_alignment_applies_to_each_wrapped_line() { + // A 30-column view forces the long right-aligned cell to wrap; every + // visual line of it is right-aligned within the column. + let out = render_with( + "| k | value |\n|---|------:|\n| a | one two three four five six seven eight |\n", + |a| a.width = Some(30), + ); + assert_eq!( + table_rows(&out), + [ + "│ k │ value │", + "│ a │ one two three │", + "│ │ four five six │", + "│ │ seven eight │", + ] + ); +} + +#[test] +fn table_alignment_measures_wide_characters() { + let out = render("| 名前 | v |\n|:----:|--:|\n| 日本 | 1 |\n| a | 22 |\n"); + let rows = table_rows(&out); + // Every row has the same display width despite double-width cells. + let w = rows[0].width(); + for r in &rows { + assert_eq!(r.width(), w, "{r:?}"); + } + assert_eq!(rows[0], "│ 名前 │ v │"); + assert_eq!(rows[1], "│ 日本 │ 1 │"); + // "a" centred in four columns: 1 left, 2 right. + assert_eq!(rows[2], "│ a │ 22 │"); +} From 0e1327584c152f24442ac89d5efe3d61c4c580b2 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 17:35:24 +0200 Subject: [PATCH 2/7] feat(markdown): superscript, subscript, spoilers, definition lists, callout titles - ^sup^ and ~sub~ render with Unicode super/subscript characters when every character has one, else ^(x) / _(x) (always in ASCII mode). ~~strikethrough~~ is unchanged; a single-tilde pair is now subscript. - ||spoiler|| keeps its bars visible and draws the text in its own background colour until selected or copied. - Definition lists: bold term, definitions indented under a marker. - Alerts and Obsidian callouts show their custom title (> [!NOTE] Title, > [!info]- Title); Obsidian types map onto the five alert colours and fold markers are ignored. - Table cells convert super/subscripts too. underline stays off: comrak's extension turns __bold__ into underline. --- src/layout/mod.rs | 379 +++++++++++++++++++++++++++++------- src/layout/scripts.rs | 192 ++++++++++++++++++ src/layout/table.rs | 38 +++- src/parser/mod.rs | 20 +- tests/cli.rs | 24 ++- tests/fixtures/ascii.md | 11 ++ tests/rendering_features.rs | 140 +++++++++++++ tests/security.rs | 14 ++ 8 files changed, 747 insertions(+), 71 deletions(-) create mode 100644 src/layout/scripts.rs diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 6eda69d..b669ff3 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -1,5 +1,6 @@ pub mod html; pub mod mermaid; +mod scripts; pub mod table; use crate::image::ImageMode; @@ -349,6 +350,10 @@ fn layout_node<'a>(node: &'a AstNode<'a>, ctx: &LayoutContext, lines: &mut Vec { + drop(data); + layout_description_list(node, ctx, lines); + } NodeValue::Table(..) => { drop(data); table::layout_table(node, ctx.theme, ctx.width, ctx.margin, lines); @@ -1107,6 +1112,10 @@ fn layout_flattened<'a>(node: &'a AstNode<'a>, ctx: &LayoutContext, lines: &mut | NodeValue::Item(_) | NodeValue::TaskItem(_) | NodeValue::FootnoteDefinition(_) + | NodeValue::DescriptionList + | NodeValue::DescriptionItem(_) + | NodeValue::DescriptionTerm + | NodeValue::DescriptionDetails ); if is_container { stack.extend(child.reverse_children()); @@ -1126,18 +1135,18 @@ fn layout_blockquote<'a>( layout_flattened(node, ctx, lines); return; } - // Check first child for admonition pattern [!TYPE] + // GitHub alert / Obsidian callout: `[!TYPE]` opening the first paragraph. let admonition = detect_admonition(node); // Choose bar color based on depth or admonition type let bar_color = if let Some(ref adm) = admonition { - match adm.as_str() { - "NOTE" => &ctx.theme.colors.admonition_note, - "TIP" => &ctx.theme.colors.admonition_tip, - "IMPORTANT" => &ctx.theme.colors.admonition_important, - "WARNING" => &ctx.theme.colors.admonition_warning, - "CAUTION" => &ctx.theme.colors.admonition_caution, - _ => &ctx.theme.colors.blockquote_bar, + match adm.kind { + AdmonitionKind::Note => &ctx.theme.colors.admonition_note, + AdmonitionKind::Tip => &ctx.theme.colors.admonition_tip, + AdmonitionKind::Important => &ctx.theme.colors.admonition_important, + AdmonitionKind::Warning => &ctx.theme.colors.admonition_warning, + AdmonitionKind::Caution => &ctx.theme.colors.admonition_caution, + AdmonitionKind::Other => &ctx.theme.colors.blockquote_bar, } } else { // Different colors for nested blockquote depths @@ -1149,37 +1158,6 @@ fn layout_blockquote<'a>( } }; - // Render admonition header if detected - if let Some(ref adm_type) = admonition { - let icons = crate::glyphs::current().admonition; - let icon = match adm_type.as_str() { - "NOTE" => icons[0], - "TIP" => icons[1], - "IMPORTANT" => icons[2], - "WARNING" => icons[3], - "CAUTION" => icons[4], - _ => icons[5], - }; - let mut header_line = StyledLine::new(); - ctx.add_margin(&mut header_line); - header_line.push(StyledSpan { - text: crate::glyphs::current().quote_bar.to_string(), - style: SpanStyle { - fg: Some(bar_color.clone()), - ..Default::default() - }, - }); - header_line.push(StyledSpan { - text: format!("{icon}{adm_type}"), - style: SpanStyle { - fg: Some(bar_color.clone()), - bold: true, - ..Default::default() - }, - }); - lines.push(header_line); - } - // Render children into temporary buffer let inner_ctx = LayoutContext { theme: ctx.theme, @@ -1201,6 +1179,39 @@ fn layout_blockquote<'a>( source: ctx.source, }; let mut inner_lines = Vec::new(); + // Header: the icon and the custom title, or the type when there is none. + if let Some(ref adm) = admonition { + let icons = crate::glyphs::current().admonition; + let icon = icons[adm.kind as usize]; + let header_style = SpanStyle { + fg: Some(bar_color.clone()), + bold: true, + ..Default::default() + }; + let mut spans = vec![StyledSpan { + text: icon.to_string(), + style: header_style.clone(), + }]; + if adm.title_head.is_empty() && adm.title_nodes.is_empty() { + spans.push(StyledSpan { + text: adm.name.clone(), + style: header_style.clone(), + }); + } else { + spans.push(StyledSpan { + text: adm.title_head.clone(), + style: header_style.clone(), + }); + collect_inline_seq( + adm.title_nodes.iter().copied(), + &inner_ctx, + &mut spans, + &header_style, + 0, + ); + } + inner_lines.extend(wrap_spans(spans, inner_ctx.width, 0, 0)); + } let mut skip_first = admonition.is_some(); for child in node.children() { let child_data = child.data.borrow(); @@ -1213,15 +1224,20 @@ fn layout_blockquote<'a>( if skip_first { // For admonitions, we render first paragraph without the [!TYPE] prefix skip_first = false; - if let NodeValue::Paragraph = &child.data.borrow().value { - let spans = collect_inline_spans(child, &inner_ctx); - // Filter out the admonition marker text - let filtered: Vec = spans - .into_iter() - .filter(|s| !s.text.starts_with("[!")) - .collect(); - if !filtered.is_empty() { - let wrapped = wrap_spans(filtered, inner_ctx.width, 0, 0); + if let (NodeValue::Paragraph, Some(adm)) = + (&child.data.borrow().value, admonition.as_ref()) + { + // The paragraph minus its first line (marker and title). + let mut spans = Vec::new(); + collect_inline_seq( + child.children().skip(adm.body_skip), + &inner_ctx, + &mut spans, + &SpanStyle::default(), + 0, + ); + if !spans.is_empty() { + let wrapped = wrap_spans(spans, inner_ctx.width, 0, 0); inner_lines.extend(wrapped); add_spacing(&inner_ctx, &mut inner_lines); } @@ -1262,26 +1278,101 @@ fn layout_blockquote<'a>( } } -/// Detect GitHub-style admonition: > [!NOTE], > [!WARNING], etc. -fn detect_admonition<'a>(node: &'a AstNode<'a>) -> Option { - if let Some(child) = node.children().next() { - let data = child.data.borrow(); - if let NodeValue::Paragraph = &data.value { - drop(data); - if let Some(inline) = child.children().next() { - let idata = inline.data.borrow(); - if let NodeValue::Text(ref text) = idata.value { - let trimmed = text.trim(); - if trimmed.starts_with("[!") && trimmed.contains(']') { - let end = trimmed.find(']').unwrap(); - let adm_type = &trimmed[2..end]; - return Some(adm_type.to_uppercase()); - } - } +/// The colour and icon family of an admonition; the discriminant indexes +/// `Glyphs::admonition`. +#[derive(Debug, Clone, Copy, PartialEq)] +enum AdmonitionKind { + Note = 0, + Tip = 1, + Important = 2, + Warning = 3, + Caution = 4, + Other = 5, +} + +impl AdmonitionKind { + /// GitHub's five alert types plus Obsidian's callout types and aliases. + fn of(name: &str) -> Self { + match name.to_ascii_lowercase().as_str() { + "note" | "info" | "todo" | "abstract" | "summary" | "tldr" => Self::Note, + "tip" | "hint" | "success" | "check" | "done" => Self::Tip, + "important" => Self::Important, + "warning" | "attention" | "question" | "help" | "faq" => Self::Warning, + "caution" | "danger" | "error" | "failure" | "fail" | "missing" | "bug" => { + Self::Caution } + _ => Self::Other, } } - None +} + +/// A blockquote opening with `[!TYPE]`: a GitHub alert (`> [!NOTE]`) or an +/// Obsidian callout (`> [!info]- Custom title`, the fold marker ignored). +struct Admonition<'a> { + kind: AdmonitionKind, + /// The type as written, upper-cased: the header when there is no title. + name: String, + /// The custom title's start: the marker's text node after the marker. + title_head: String, + /// The inlines after that on the marker's line (the rest of the title). + title_nodes: Vec<&'a AstNode<'a>>, + /// How many of the paragraph's inlines the header line used, its line + /// break included: the body starts after them. + body_skip: usize, +} + +/// Detect `> [!TYPE]` / `> [!TYPE]+ Title` at the start of a blockquote. +fn detect_admonition<'a>(node: &'a AstNode<'a>) -> Option> { + let para = node.children().next()?; + if !matches!(para.data.borrow().value, NodeValue::Paragraph) { + return None; + } + let first = para.children().next()?; + let text = match &first.data.borrow().value { + NodeValue::Text(t) => t.clone(), + _ => return None, + }; + let rest = text.trim_start().strip_prefix("[!")?; + let end = rest.find(']')?; + let name = &rest[..end]; + if name.is_empty() + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + return None; + } + let after = rest[end + 1..] + .strip_prefix(['+', '-']) + .unwrap_or(&rest[end + 1..]); + + // The title is everything up to the paragraph's first line break. + let mut title_nodes = Vec::new(); + let mut body_skip = 1; + let title_head = after.trim_start().to_string(); + let mut siblings = para.children().skip(1); + let mut ended = false; + for sib in siblings.by_ref() { + body_skip += 1; + if matches!( + sib.data.borrow().value, + NodeValue::SoftBreak | NodeValue::LineBreak + ) { + ended = true; + break; + } + title_nodes.push(sib); + } + if !ended { + body_skip = usize::MAX; + } + Some(Admonition { + kind: AdmonitionKind::of(name), + name: name.to_uppercase(), + title_head, + title_nodes, + body_skip, + }) } fn layout_list<'a>( @@ -1403,6 +1494,101 @@ fn layout_list<'a>( add_spacing(ctx, lines); } +/// A definition list (`Term` / `: definition`): each term bold on its own +/// line, each definition indented under it, its first line marked with the +/// list pointer (`▸`, `>` in ASCII mode). Nests like a list: past +/// `MAX_BLOCK_DEPTH` it is flattened. +fn layout_description_list<'a>( + node: &'a AstNode<'a>, + ctx: &LayoutContext, + lines: &mut Vec, +) { + if ctx.block_depth >= MAX_BLOCK_DEPTH { + layout_flattened(node, ctx, lines); + add_spacing(ctx, lines); + return; + } + const INDENT: usize = 4; + let inner_ctx = LayoutContext { + theme: ctx.theme, + width: ctx.width.saturating_sub(INDENT), + indent: 0, + list_depth: ctx.list_depth, + block_depth: ctx.block_depth + 1, + spacing: ctx.spacing, + margin: 0, + syntax_set: ctx.syntax_set, + theme_set: ctx.theme_set, + base_dir: ctx.base_dir, + images: ctx.images, + headings: ctx.headings, + image_specs: ctx.image_specs, + code_blocks: ctx.code_blocks, + graphics_font: ctx.graphics_font, + record_headings: false, + source: ctx.source, + }; + let term_style = SpanStyle { + bold: true, + fg: Some(ctx.theme.colors.bold.clone()), + ..Default::default() + }; + let marker_style = SpanStyle { + fg: Some(ctx.theme.colors.list_bullet.clone()), + ..Default::default() + }; + let marker = format!(" {} ", crate::glyphs::current().pointer); + let items: Vec<_> = node.children().collect(); + for (i, item) in items.iter().enumerate() { + for part in item.children() { + let is_term = match part.data.borrow().value { + NodeValue::DescriptionTerm => true, + NodeValue::DescriptionDetails => false, + _ => continue, + }; + if is_term { + // The term's paragraph(s), bold, at the list's own indent. + for para in part.children() { + let mut spans = Vec::new(); + collect_inlines(para, ctx, &mut spans, &term_style, 0); + lines.extend(wrap_spans(spans, ctx.width, 0, ctx.margin)); + } + continue; + } + let mut detail_lines = Vec::new(); + for child in part.children() { + layout_node(child, &inner_ctx, &mut detail_lines); + } + trim_trailing_blank(&mut detail_lines); + for (j, detail) in detail_lines.into_iter().enumerate() { + if j > 0 && detail.spans.iter().all(|s| s.text.trim().is_empty()) { + lines.push(StyledLine::empty()); + continue; + } + let mut line = StyledLine::new(); + ctx.add_margin(&mut line); + line.push(if j == 0 { + StyledSpan { + text: marker.clone(), + style: marker_style.clone(), + } + } else { + StyledSpan { + text: " ".repeat(INDENT), + style: SpanStyle::default(), + } + }); + line.spans.extend(detail.spans); + lines.push(line); + } + } + if i + 1 < items.len() { + add_spacing(ctx, lines); + } + } + add_spacing(ctx, lines); +} + fn layout_hr(ctx: &LayoutContext, lines: &mut Vec) { let width = ctx.width.min(60); let left_pad = (ctx.width.saturating_sub(width)) / 2; @@ -1465,10 +1651,22 @@ fn collect_inlines<'a>( } return; } + collect_inline_seq(node.children(), ctx, spans, outer_style, depth); +} + +/// `collect_inlines` over a run of sibling inlines (all of a node's children, +/// or the part of a paragraph after an admonition's title line). +fn collect_inline_seq<'a>( + children: impl Iterator>, + ctx: &LayoutContext, + spans: &mut Vec, + outer_style: &SpanStyle, + depth: usize, +) { // Inline HTML arrives one tag per node (``, text, ``), so the // tags open among these siblings style the siblings that follow them. let mut html_styles = html::HtmlStyles::default(); - for child in node.children() { + for child in children { let parent_style = &html_styles.style(outer_style).clone(); let data = child.data.borrow(); // Inside an unclosed inline