Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add preliminary support for tables #290

Merged
merged 2 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Use `cargo release` to create a new release.

## [Unreleased]

### Added
- mdcat now has limited support for tables (see [GH-290]).
Inline markups and text wrapping are still unsupported in tables.

[GH-290]: https://github.com/swsnr/mdcat/pull/290

## [2.3.1] – 2024-08-04

### Changed
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ Then it

Not supported:

* CommonMark extensions for footnotes and tables.
* CommonMark extensions for footnotes.
* Wrapping and inline markups in tables.

[syntect]: https://github.com/trishume/syntect
[osc8]: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
Expand Down
2 changes: 1 addition & 1 deletion mdcat.1.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ See <https://docs.rs/tracing-subscriber/latest/tracing_subscriber/struct.EnvFilt

mdcat supports version 0.30 of the https://spec.commonmark.org/[CommonMark Spec], plus https://github.github.com/gfm/#task-list-items-extension-[Task lists] and https://github.github.com/gfm/#strikethrough-extension-[strikethrough], through https://github.com/raphlinus/pulldown-cmark[pulldown-cmark].

mdcat does **not** yet support footnotes and https://github.github.com/gfm/#tables-extension-[tables].
mdcat does **not** yet support footnotes and support for https://github.github.com/gfm/#tables-extension-[tables] is limited (e.g., inline markups in tables are stripped).
mdcat parses HTML blocks and inline tags but does not apply special rendering; it prints HTML as is.

=== Terminal support
Expand Down
2 changes: 1 addition & 1 deletion pulldown-cmark-mdcat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ It supports:
- Inline images on terminal emulators with either the iTerm2 or the Kitty protocol, as well as on Terminology.
- Jump marks in iTerm2.

It does not support commonmark table and footnote extension syntax.
It does not support commonmark footnote extension syntax.

[mdcat]: https://github.com/swsnr/mdcat
[pulldown-cmark]: https://github.com/raphlinus/pulldown-cmark
Expand Down
86 changes: 85 additions & 1 deletion pulldown-cmark-mdcat/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ use crate::references::*;
use state::*;
use write::*;

use crate::render::data::CurrentLine;
use crate::render::data::{CurrentLine, CurrentTable};
use crate::render::state::MarginControl::{Margin, NoMargin};
use crate::terminal::capabilities::StyleCapability;
use crate::terminal::osc::{clear_link, set_link_url};
Expand Down Expand Up @@ -145,6 +145,23 @@ pub fn write_event<'a, W: Write>(
.and_data(data)
.ok()
}
(TopLevel(attrs), Start(Table(alignments))) => {
if attrs.margin_before != NoMargin {
writeln!(writer)?;
}
let current_table = CurrentTable {
alignments,
..data.current_table
};
let data = StateData {
current_table,
..data
};
State::stack_onto(TopLevelAttrs::margin_before())
.current(TableBlock)
.and_data(data)
.ok()
}
(TopLevel(attrs), Html(html)) => {
if attrs.margin_before == Margin {
writeln!(writer)?;
Expand Down Expand Up @@ -760,6 +777,73 @@ pub fn write_event<'a, W: Write>(
}
}

// Tables
(Stacked(stack, TableBlock), Start(TableHead))
| (Stacked(stack, TableBlock), Start(TableRow))
| (Stacked(stack, TableBlock), Start(TableCell)) => {
Stacked(stack, TableBlock).and_data(data).ok()
}
(Stacked(stack, TableBlock), End(TableHead)) => {
let current_table = data.current_table.end_head();
let data = StateData {
current_table,
..data
};
Stacked(stack, TableBlock).and_data(data).ok()
}
(Stacked(stack, TableBlock), End(TableRow)) => {
let current_table = data.current_table.end_row();
let data = StateData {
current_table,
..data
};
Stacked(stack, TableBlock).and_data(data).ok()
}
(Stacked(stack, TableBlock), End(TableCell)) => {
let current_table = data.current_table.end_cell();
let data = StateData {
current_table,
..data
};
Stacked(stack, TableBlock).and_data(data).ok()
}
(Stacked(stack, TableBlock), Text(text)) | (Stacked(stack, TableBlock), Code(text)) => {
let current_table = data.current_table.push_fragment(text);
let data = StateData {
current_table,
..data
};
Stacked(stack, TableBlock).and_data(data).ok()
}
(Stacked(stack, TableBlock), End(Table(_))) => {
write_table(
writer,
&settings.terminal_capabilities,
&settings.terminal_size,
data.current_table,
)?;
let current_table = data::CurrentTable::empty();
let data = StateData {
current_table,
..data
};
stack.pop().and_data(data).ok()
}
// Ignore all inline elements in a table.
// TODO: Support those events.
(Stacked(stack, TableBlock), Start(Emphasis))
| (Stacked(stack, TableBlock), Start(Strong))
| (Stacked(stack, TableBlock), Start(Strikethrough))
| (Stacked(stack, TableBlock), Start(Link(_, _, _)))
| (Stacked(stack, TableBlock), Start(Image(_, _, _)))
| (Stacked(stack, TableBlock), End(Emphasis))
| (Stacked(stack, TableBlock), End(Strong))
| (Stacked(stack, TableBlock), End(Strikethrough))
| (Stacked(stack, TableBlock), End(Link(_, _, _)))
| (Stacked(stack, TableBlock), End(Image(_, _, _))) => {
Stacked(stack, TableBlock).and_data(data).ok()
}

// Unconditional returns to previous states
(Stacked(stack, _), End(BlockQuote)) => stack.pop().and_data(data).ok(),
(Stacked(stack, _), End(List(_))) => stack.pop().and_data(data).ok(),
Expand Down
93 changes: 92 additions & 1 deletion pulldown-cmark-mdcat/src/render/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

use anstyle::Style;
use pulldown_cmark::CowStr;
use pulldown_cmark::{Alignment, CowStr};

/// The definition of a reference link, i.e. a numeric index for a link.
#[derive(Debug, PartialEq)]
Expand Down Expand Up @@ -39,6 +39,94 @@ impl CurrentLine {
}
}

/// A cell in the table.
#[derive(Debug)]
pub struct TableCell<'a> {
// TODO: Support styles of fragments.
/// Renderable fragments in a table cell.
pub(super) fragments: Vec<CowStr<'a>>,
}

impl<'a> TableCell<'a> {
/// A new empty table cell.
pub(super) fn empty() -> Self {
Self {
fragments: Vec::new(),
}
}
}

/// A row in the table.
#[derive(Debug)]
pub struct TableRow<'a> {
/// Completed cells of the table row.
pub(super) cells: Vec<TableCell<'a>>,
/// Current incomplete cell of the table row.
pub(super) current_cell: TableCell<'a>,
}

impl<'a> TableRow<'a> {
pengjiz marked this conversation as resolved.
Show resolved Hide resolved
/// A new empty table row.
pub(super) fn empty() -> Self {
Self {
cells: Vec::new(),
current_cell: TableCell::empty(),
}
}
}

/// The state of the current table.
#[derive(Debug)]
pub struct CurrentTable<'a> {
/// Head row of the table.
pub(super) head: Option<TableRow<'a>>,
/// Complete rows of the table.
pub(super) rows: Vec<TableRow<'a>>,
/// Current incomplete row of the table.
pub(super) current_row: TableRow<'a>,
/// Alignments of columns.
pub(super) alignments: Vec<Alignment>,
}

impl<'a> CurrentTable<'a> {
/// A new empty table.
pub(super) fn empty() -> Self {
Self {
head: None,
rows: Vec::new(),
current_row: TableRow::empty(),
alignments: Vec::new(),
}
}

/// Push a fragment to the current cell of the current row.
pub(super) fn push_fragment(mut self, fragment: CowStr<'a>) -> Self {
self.current_row.current_cell.fragments.push(fragment);
self
}

/// Complete the current cell and start a new cell in the current row.
pub(super) fn end_cell(mut self) -> Self {
self.current_row.cells.push(self.current_row.current_cell);
self.current_row.current_cell = TableCell::empty();
self
}

/// Complete the head row and start a new row.
pub(super) fn end_head(mut self) -> Self {
self.head = Some(self.current_row);
self.current_row = TableRow::empty();
self
}

/// Complete the current row and start a new row.
pub(super) fn end_row(mut self) -> Self {
self.rows.push(self.current_row);
self.current_row = TableRow::empty();
self
}
}

/// Data associated with rendering state.
///
/// Unlike state attributes state data represents cross-cutting
Expand All @@ -54,6 +142,8 @@ pub struct StateData<'a> {
pub(super) next_link: u16,
/// The state of the current line for render.md.wrapping.
pub(super) current_line: CurrentLine,
/// The state of the current table.
pub(super) current_table: CurrentTable<'a>,
}

impl<'a> StateData<'a> {
Expand Down Expand Up @@ -104,6 +194,7 @@ impl<'a> Default for StateData<'a> {
pending_link_definitions: Vec::new(),
next_link: 1,
current_line: CurrentLine::empty(),
current_table: CurrentTable::empty(),
}
}
}
2 changes: 2 additions & 0 deletions pulldown-cmark-mdcat/src/render/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ pub enum StackedState {
/// We move to this state when we can render an image directly to the terminal, in order to
/// suppress intermediate events, namely the image title.
RenderedImage,
/// A table block.
TableBlock,
/// Some inline markup.
Inline(InlineState, InlineAttrs),
}
Expand Down
95 changes: 92 additions & 3 deletions pulldown-cmark-mdcat/src/render/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

use std::cmp::{max, min};
use std::io::{Result, Write};
use std::iter::zip;

use anstyle::Style;
use pulldown_cmark::{CodeBlockKind, HeadingLevel};
use pulldown_cmark::{Alignment, CodeBlockKind, HeadingLevel};
use syntect::highlighting::HighlightState;
use syntect::parsing::{ParseState, ScopeStack};
use textwrap::core::{display_width, Word};
use textwrap::WordSeparator;

use crate::references::*;
use crate::render::data::{CurrentLine, LinkReferenceDefinition};
use crate::render::data::{CurrentLine, CurrentTable, LinkReferenceDefinition, TableCell};
use crate::render::highlighting::highlighter;
use crate::render::state::*;
use crate::terminal::capabilities::{MarkCapability, StyleCapability, TerminalCapabilities};
Expand Down Expand Up @@ -263,7 +265,7 @@ pub fn write_link_refs<W: Write>(
writer,
capabilities,
&link.style,
&format!("[{}]: ", link.index),
format!("[{}]: ", link.index),
)?;

// If we can resolve the link try to write it as inline link to make the URL
Expand Down Expand Up @@ -360,3 +362,90 @@ pub fn write_start_heading<W: Write>(
InlineAttrs { style, indent: 0 },
))
}

fn calculate_column_widths(table: &CurrentTable) -> Option<Vec<usize>> {
let first_row = table.head.as_ref().or(table.rows.first())?;
let mut widths = vec![0; first_row.cells.len()];
let rows = table.head.iter().chain(table.rows.as_slice());
for row in rows {
let current = row.cells.as_slice().iter().map(|cell| {
cell.fragments
.as_slice()
.iter()
.fold(0, |acc, x| acc + x.len())
});
widths = zip(widths, current).map(|(a, b)| max(a, b)).collect();
}
Some(widths)
}

// TODO: Support themes for table rule.
fn write_table_rule<W: Write>(
writer: &mut W,
capabilities: &TerminalCapabilities,
length: u16,
) -> Result<()> {
let rule = "\u{2500}".repeat(length.into());
write_styled(writer, capabilities, &Style::new(), rule)?;
writeln!(writer)
}

fn format_table_cell(cell: TableCell, width: usize, alignment: Alignment) -> String {
use Alignment::*;
let content = cell.fragments.join("");
match alignment {
Left | None => format!(" {:<width$} ", content),
Center => format!(" {:^width$} ", content),
Right => format!(" {:>width$} ", content),
}
}

pub fn write_table<W: Write>(
writer: &mut W,
capabilities: &TerminalCapabilities,
terminal_size: &TerminalSize,
table: CurrentTable,
) -> Result<()> {
if let Some(widths) = calculate_column_widths(&table) {
// Calculate length of the table rule.
let total_width: usize = widths.iter().sum();
let rule_length = min(
// We use two spaces for padding for each cell in format_table_cell.
(total_width + 2 * widths.len())
.try_into()
.unwrap_or(u16::MAX),
terminal_size.columns,
);
write_table_rule(writer, capabilities, rule_length)?;

// Write the table head in bold if any.
if let Some(head) = table.head {
for ((cell, &width), &alignment) in zip(zip(head.cells, &widths), &table.alignments) {
write_styled(
writer,
capabilities,
&Style::new().bold(),
format_table_cell(cell, width, alignment),
)?;
}
writeln!(writer)?;
write_table_rule(writer, capabilities, rule_length)?;
}

// Write table body.
for row in table.rows {
for ((cell, &width), &alignment) in zip(zip(row.cells, &widths), &table.alignments) {
write_styled(
writer,
capabilities,
&Style::new(),
format_table_cell(cell, width, alignment),
)?;
}
writeln!(writer)?;
}
write_table_rule(writer, capabilities, rule_length)?;
}
// Do nothing when there are no rows in the table, which should be impossible.
Ok(())
}
Loading