From 5df6c082318f798a7f2df699e5b30ab81100a80c Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Mon, 23 Mar 2026 23:19:31 +0400 Subject: [PATCH] feat: add table parsing (#376) --- clib/htmlconv.c | 194 +++++++++++++++++++++++++++++++++++++++++++++--- clib/htmlconv.h | 1 + clib/types.go | 1 + view/html.go | 113 ++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 11 deletions(-) diff --git a/clib/htmlconv.c b/clib/htmlconv.c index 10d5915f5ee33772e50958ca965b56c5b52a7ff3..35e132a8d00b816b7652119ace2dc5cb772c8e73 100644 --- a/clib/htmlconv.c +++ b/clib/htmlconv.c @@ -2,6 +2,7 @@ #include #include #include +#include // --- Dynamic buffer --- @@ -50,6 +51,21 @@ static void buf_free(Buffer* b) { b->cap = 0; } +// Append a text character with HTML whitespace collapsing. +// In non-pre mode, collapses runs of whitespace to a single space. +static void buf_append_html_char(Buffer* b, char c, int in_pre) { + if (in_pre) { + buf_append_char(b, c); + return; + } + if (c == '\n' || c == '\r' || c == '\t') c = ' '; + if (c == ' ') { + // Skip if buffer already ends with space or is empty + if (b->len == 0 || b->data[b->len - 1] == ' ' || b->data[b->len - 1] == '\n') return; + } + buf_append_char(b, c); +} + // --- Result helpers --- static void result_init(HTMLConvertResult* r) { @@ -291,6 +307,17 @@ typedef struct { char bq_cite[2048]; Buffer bq_prev; // Text before blockquote (for "On...wrote:" detection) int last_was_block; // Last element was a block (for spacing) + // Table state + int table_depth; // Table nesting depth (0 = not in any table) + int capture_depth; // Depth at which we're capturing data table (-1 = not capturing) + int in_thead; // Inside (at capture depth) + int in_tr; // Inside (at capture depth, capturing mode) + int in_td; // Inside / (at capture depth, capturing mode) + int cell_index; // Cell index within current row + int row_index; // Row index within current table + int header_rows; // Number of header rows (rows inside ) + Buffer cell_text; // Current cell text accumulator + Buffer table_data; // Accumulated table data (cells tab-separated, rows newline-separated) } ParseState; HTMLConvertResult html_to_elements(const char* html, size_t len) { @@ -304,10 +331,13 @@ HTMLConvertResult html_to_elements(const char* html, size_t len) { ParseState state; memset(&state, 0, sizeof(state)); + state.capture_depth = -1; // Not capturing buf_init(&state.a_text); buf_init(&state.h_text); buf_init(&state.bq_text); buf_init(&state.bq_prev); + buf_init(&state.cell_text); + buf_init(&state.table_data); Buffer text_buf; buf_init(&text_buf); @@ -357,7 +387,9 @@ HTMLConvertResult html_to_elements(const char* html, size_t len) { //
-> newline if (tag_eq(tag.name, tag.name_len, "br")) { - if (state.in_a) { + if (state.in_td) { + buf_append_char(&state.cell_text, ' '); + } else if (state.in_a) { buf_append_char(&state.a_text, '\n'); } else if (state.in_h1 || state.in_h2) { buf_append_char(&state.h_text, ' '); @@ -509,16 +541,140 @@ HTMLConvertResult html_to_elements(const char* html, size_t len) { continue; } + // + if (tag_eq(tag.name, tag.name_len, "table")) { + if (!tag.is_closing) { + state.table_depth++; + } else if (state.table_depth > 0) { + if (state.table_depth == state.capture_depth) { + // Closing the data table we're capturing + if (state.table_data.len > 0) { + flush_text(&result, &text_buf); + HTMLElement* e = result_add(&result); + e->type = HELEM_TABLE; + e->text = buf_finish(&state.table_data); + buf_init(&state.table_data); + char hdr_buf[16]; + snprintf(hdr_buf, sizeof(hdr_buf), "%d", state.header_rows); + e->attr1 = strdup(hdr_buf); + } else { + buf_free(&state.table_data); + buf_init(&state.table_data); + } + state.capture_depth = -1; + state.in_td = 0; + state.in_tr = 0; + } else { + // Layout or nested table: block spacing + if (state.bq_depth > 0) { + buf_append(&state.bq_text, "\n\n", 2); + } else { + buf_append(&text_buf, "\n\n", 2); + } + state.last_was_block = 1; + } + state.table_depth--; + } + p = after; + continue; + } + + // + if (tag_eq(tag.name, tag.name_len, "thead")) { + if (state.table_depth == state.capture_depth) { + state.in_thead = !tag.is_closing; + } + p = after; + continue; + } + + // , - skip tag + if (tag_eq(tag.name, tag.name_len, "tbody") || + tag_eq(tag.name, tag.name_len, "tfoot")) { + p = after; + continue; + } + + // + if (tag_eq(tag.name, tag.name_len, "tr")) { + if (state.table_depth == state.capture_depth) { + // Data table row + if (!tag.is_closing) { + if (state.row_index > 0) { + buf_append_char(&state.table_data, '\n'); + } + state.in_tr = 1; + state.cell_index = 0; + } else { + state.in_tr = 0; + if (state.in_thead) state.header_rows++; + state.row_index++; + } + } else if (tag.is_closing) { + // Layout table: block spacing + if (state.bq_depth > 0) { + buf_append(&state.bq_text, "\n\n", 2); + } else { + buf_append(&text_buf, "\n\n", 2); + } + state.last_was_block = 1; + } + p = after; + continue; + } + + // , , etc. - skip tag but process children + //
    ,
      ,
      , etc. - skip tag but process children p = after; continue; } @@ -542,7 +698,8 @@ HTMLConvertResult html_to_elements(const char* html, size_t len) { // Handle entities if (*p == '&') { Buffer* target; - if (state.in_a) target = &state.a_text; + if (state.in_td) target = &state.cell_text; + else if (state.in_a) target = &state.a_text; else if (state.in_h1 || state.in_h2) target = &state.h_text; else if (state.bq_depth > 0) target = &state.bq_text; else target = &text_buf; @@ -552,16 +709,18 @@ HTMLConvertResult html_to_elements(const char* html, size_t len) { continue; } - // Regular character + // Regular character — collapse whitespace like HTML (unless in
      )
               char c = *p++;
      -        if (state.in_a) {
      -            buf_append_char(&state.a_text, c);
      +        if (state.in_td) {
      +            buf_append_html_char(&state.cell_text, c, state.in_pre);
      +        } else if (state.in_a) {
      +            buf_append_html_char(&state.a_text, c, state.in_pre);
               } else if (state.in_h1 || state.in_h2) {
      -            buf_append_char(&state.h_text, c);
      +            buf_append_html_char(&state.h_text, c, state.in_pre);
               } else if (state.bq_depth > 0) {
      -            buf_append_char(&state.bq_text, c);
      +            buf_append_html_char(&state.bq_text, c, state.in_pre);
               } else {
      -            buf_append_char(&text_buf, c);
      +            buf_append_html_char(&text_buf, c, state.in_pre);
               }
           }
       
      @@ -598,6 +757,19 @@ HTMLConvertResult html_to_elements(const char* html, size_t len) {
               buf_free(&state.bq_prev);
           }
       
      +    // Flush unclosed table
      +    if (state.capture_depth > 0 && state.table_data.len > 0) {
      +        HTMLElement* e = result_add(&result);
      +        e->type = HELEM_TABLE;
      +        e->text = buf_finish(&state.table_data);
      +        char hdr_buf[16];
      +        snprintf(hdr_buf, sizeof(hdr_buf), "%d", state.header_rows);
      +        e->attr1 = strdup(hdr_buf);
      +    } else {
      +        buf_free(&state.table_data);
      +    }
      +    buf_free(&state.cell_text);
      +
           result.ok = 1;
           return result;
       }
      diff --git a/clib/htmlconv.h b/clib/htmlconv.h
      index 7d9c1fd8510d42ed756030ad47f4cd3c09fbeada..46ed840e11281145b29bc19dae275d1dcf6b195e 100644
      --- a/clib/htmlconv.h
      +++ b/clib/htmlconv.h
      @@ -10,6 +10,7 @@ enum {
           HELEM_LINK       = 3,
           HELEM_IMAGE      = 4,
           HELEM_BLOCKQUOTE = 5,
      +    HELEM_TABLE      = 6,
       };
       
       typedef struct {
      diff --git a/clib/types.go b/clib/types.go
      index 3aefa83af827a8eebf76a6cb558e086abeb15446..64611e55c47fdf898ed375211e881085cabf1409 100644
      --- a/clib/types.go
      +++ b/clib/types.go
      @@ -15,6 +15,7 @@ const (
       	HElemLink       = 3
       	HElemImage      = 4
       	HElemBlockquote = 5
      +	HElemTable      = 6
       )
       
       // HTMLElement represents a parsed element from an HTML document.
      diff --git a/view/html.go b/view/html.go
      index a0ceb9524bfa1d0c4bb93e72909cd2120d8aec75..d72279b5744e9ce0fb643481e0942bed3ff0a130 100644
      --- a/view/html.go
      +++ b/view/html.go
      @@ -663,6 +663,15 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
       				text.WriteString(fmt.Sprintf("\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
       			}
       
      +		case clib.HElemTable:
      +			headerRows := 0
      +			if elem.Attr1 != "" {
      +				fmt.Sscanf(elem.Attr1, "%d", &headerRows)
      +			}
      +			text.WriteString("\n")
      +			text.WriteString(renderTable(elem.Text, headerRows))
      +			text.WriteString("\n")
      +
       		case clib.HElemBlockquote:
       			var from, date string
       			prevText := elem.Attr2
      @@ -721,6 +730,110 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
       	return bodyStyle.Render(result), placements, nil
       }
       
      +func tableHeaderStyle() lipgloss.Style {
      +	return lipgloss.NewStyle().Bold(true).Foreground(theme.ActiveTheme.Accent)
      +}
      +
      +func tableBorderStyle() lipgloss.Style {
      +	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Secondary)
      +}
      +
      +// renderTable renders table data as a Unicode box-drawing table.
      +// data is tab-separated cells, newline-separated rows.
      +// headerRows is the number of header rows.
      +func renderTable(data string, headerRows int) string {
      +	rows := strings.Split(data, "\n")
      +	if len(rows) == 0 {
      +		return ""
      +	}
      +
      +	// Parse into 2D grid and trim cell whitespace
      +	var grid [][]string
      +	maxCols := 0
      +	for _, row := range rows {
      +		cells := strings.Split(row, "\t")
      +		trimmed := make([]string, len(cells))
      +		for i, c := range cells {
      +			trimmed[i] = strings.TrimSpace(c)
      +		}
      +		grid = append(grid, trimmed)
      +		if len(trimmed) > maxCols {
      +			maxCols = len(trimmed)
      +		}
      +	}
      +
      +	// Normalize: ensure all rows have the same number of columns
      +	for i := range grid {
      +		for len(grid[i]) < maxCols {
      +			grid[i] = append(grid[i], "")
      +		}
      +	}
      +
      +	// Calculate column widths
      +	colWidths := make([]int, maxCols)
      +	for _, row := range grid {
      +		for j, cell := range row {
      +			if len(cell) > colWidths[j] {
      +				colWidths[j] = len(cell)
      +			}
      +		}
      +	}
      +
      +	// Minimum width per column
      +	for i := range colWidths {
      +		if colWidths[i] < 3 {
      +			colWidths[i] = 3
      +		}
      +	}
      +
      +	bs := tableBorderStyle()
      +	hs := tableHeaderStyle()
      +
      +	// Build horizontal borders
      +	buildBorder := func(left, mid, right, fill string) string {
      +		var b strings.Builder
      +		b.WriteString(bs.Render(left))
      +		for j, w := range colWidths {
      +			b.WriteString(bs.Render(strings.Repeat(fill, w+2)))
      +			if j < len(colWidths)-1 {
      +				b.WriteString(bs.Render(mid))
      +			}
      +		}
      +		b.WriteString(bs.Render(right))
      +		return b.String()
      +	}
      +
      +	topBorder := buildBorder("┌", "┬", "┐", "─")
      +	midBorder := buildBorder("├", "┼", "┤", "─")
      +	botBorder := buildBorder("└", "┴", "┘", "─")
      +
      +	var out strings.Builder
      +	out.WriteString(topBorder)
      +	out.WriteString("\n")
      +
      +	for i, row := range grid {
      +		out.WriteString(bs.Render("│"))
      +		for j, cell := range row {
      +			padded := cell + strings.Repeat(" ", colWidths[j]-len(cell))
      +			if i < headerRows {
      +				out.WriteString(" " + hs.Render(padded) + " ")
      +			} else {
      +				out.WriteString(" " + padded + " ")
      +			}
      +			out.WriteString(bs.Render("│"))
      +		}
      +		out.WriteString("\n")
      +
      +		if i < headerRows && (i+1 == headerRows || i+1 == len(grid)) {
      +			out.WriteString(midBorder)
      +			out.WriteString("\n")
      +		}
      +	}
      +
      +	out.WriteString(botBorder)
      +	return out.String()
      +}
      +
       func quoteBoxStyle() lipgloss.Style {
       	return lipgloss.NewStyle().
       		Border(lipgloss.RoundedBorder()).
      
, + if (tag_eq(tag.name, tag.name_len, "td") || + tag_eq(tag.name, tag.name_len, "th")) { + // enables capture mode for the current table depth + if (tag_eq(tag.name, tag.name_len, "th") && !tag.is_closing && + state.capture_depth < 0 && state.table_depth > 0) { + state.capture_depth = state.table_depth; + // Start tracking the current row + state.in_tr = 1; + state.cell_index = 0; + state.row_index = 0; + state.header_rows = 0; + state.in_thead = 1; // implies header + buf_free(&state.table_data); + buf_init(&state.table_data); + } + + if (state.table_depth == state.capture_depth && state.in_tr) { + if (!tag.is_closing) { + state.in_td = 1; + buf_free(&state.cell_text); + buf_init(&state.cell_text); + } else if (state.in_td) { + state.in_td = 0; + // Append cell to row (tab-separated) + if (state.cell_index > 0) { + buf_append_char(&state.table_data, '\t'); + } + if (state.cell_text.len > 0) { + buf_append(&state.table_data, state.cell_text.data, state.cell_text.len); + } + buf_free(&state.cell_text); + buf_init(&state.cell_text); + state.cell_index++; + } + } + // For layout tables (not capturing at this depth): td/th tags are + // ignored, text content flows through to text_buf naturally + p = after; + continue; + } + // Block elements: add spacing if (tag_eq(tag.name, tag.name_len, "p") || tag_eq(tag.name, tag.name_len, "div") || tag_eq(tag.name, tag.name_len, "li") || - tag_eq(tag.name, tag.name_len, "tr") || - tag_eq(tag.name, tag.name_len, "table") || tag_eq(tag.name, tag.name_len, "hr")) { if (tag.is_closing || tag_eq(tag.name, tag.name_len, "hr")) { if (state.bq_depth > 0) { buf_append(&state.bq_text, "\n\n", 2); + } else if (state.in_td) { + buf_append_char(&state.cell_text, ' '); } else { buf_append(&text_buf, "\n\n", 2); } @@ -528,7 +684,7 @@ HTMLConvertResult html_to_elements(const char* html, size_t len) { continue; } - //
    ,
      ,
      ,