feat: add table parsing (#376)

Drew Smirnoff created

Change summary

clib/htmlconv.c | 194 ++++++++++++++++++++++++++++++++++++++++++++++++--
clib/htmlconv.h |   1 
clib/types.go   |   1 
view/html.go    | 113 +++++++++++++++++++++++++++++
4 files changed, 298 insertions(+), 11 deletions(-)

Detailed changes

clib/htmlconv.c 🔗

@@ -2,6 +2,7 @@
 #include <stdlib.h>
 #include <string.h>
 #include <ctype.h>
+#include <stdio.h>
 
 // --- 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 <thead> (at capture depth)
+    int in_tr;          // Inside <tr> (at capture depth, capturing mode)
+    int in_td;          // Inside <td>/<th> (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 <thead>)
+    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) {
 
             // <br> -> 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;
             }
 
+            // <table>
+            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;
+            }
+
+            // <thead>
+            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;
+            }
+
+            // <tbody>, <tfoot> - skip tag
+            if (tag_eq(tag.name, tag.name_len, "tbody") ||
+                tag_eq(tag.name, tag.name_len, "tfoot")) {
+                p = after;
+                continue;
+            }
+
+            // <tr>
+            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;
+            }
+
+            // <td>, <th>
+            if (tag_eq(tag.name, tag.name_len, "td") ||
+                tag_eq(tag.name, tag.name_len, "th")) {
+                // <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; // <th> 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;
             }
 
-            // <ul>, <ol>, <dl>, <thead>, <tbody>, etc. - skip tag but process children
+            // <ul>, <ol>, <dl>, 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 <pre>)
         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;
 }

clib/htmlconv.h 🔗

@@ -10,6 +10,7 @@ enum {
     HELEM_LINK       = 3,
     HELEM_IMAGE      = 4,
     HELEM_BLOCKQUOTE = 5,
+    HELEM_TABLE      = 6,
 };
 
 typedef struct {

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.

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()).