Skip to content

Commit 2e5200d

Browse files
content: Handle <table> elements
Fixes: #360
1 parent 9e42f26 commit 2e5200d

File tree

5 files changed

+585
-5
lines changed

5 files changed

+585
-5
lines changed

lib/model/content.dart

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,82 @@ class EmbedVideoNode extends BlockContentNode {
504504
}
505505
}
506506

507+
class TableNode extends BlockContentNode {
508+
const TableNode({super.debugHtmlNode, required this.rows});
509+
510+
final List<TableRowNode> rows;
511+
512+
@override
513+
List<DiagnosticsNode> debugDescribeChildren() {
514+
return rows
515+
.mapIndexed((i, row) => row.toDiagnosticsNode(name: 'row $i'))
516+
.toList();
517+
}
518+
}
519+
520+
class TableRowNode extends BlockContentNode {
521+
const TableRowNode({
522+
super.debugHtmlNode,
523+
required this.cells,
524+
required this.isHeader,
525+
});
526+
527+
final List<TableCellNode> cells;
528+
529+
/// Indicates whether this row is the header row.
530+
final bool isHeader;
531+
532+
@override
533+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
534+
super.debugFillProperties(properties);
535+
properties.add(DiagnosticsProperty<bool>('isHeader', isHeader));
536+
}
537+
538+
@override
539+
List<DiagnosticsNode> debugDescribeChildren() {
540+
return cells
541+
.mapIndexed((i, cell) => cell.toDiagnosticsNode(name: 'cell $i'))
542+
.toList();
543+
}
544+
}
545+
546+
// The text-alignment setting that applies to a cell's column, from the delimiter row.
547+
//
548+
// See GitHub-flavored Markdown:
549+
// https://github.github.com/gfm/#tables-extension-
550+
enum TableColumnTextAlignment {
551+
/// All cells' text left-aligned, represented in Markdown as `|: --- |`.
552+
left, // TODO(i18n) RTL issues? https://github.com/zulip/zulip/issues/32265
553+
/// All cells' text center-aligned, represented in Markdown as `|: --- :|`.
554+
center,
555+
/// All cells' text right-aligned, represented in Markdown as `| --- :|`.
556+
right, // TODO(i18n) RTL issues? https://github.com/zulip/zulip/issues/32265
557+
/// Cells' text aligned the default way, represented in Markdown as `| --- |`.
558+
defaults
559+
}
560+
561+
class TableCellNode extends BlockInlineContainerNode {
562+
const TableCellNode({
563+
super.debugHtmlNode,
564+
required super.nodes,
565+
required super.links,
566+
required this.textAlignment,
567+
});
568+
569+
/// The table column text-alignment to be used for this cell.
570+
// In Markdown, alignment is defined per column using the delimiter row.
571+
// However, the generated HTML specifies alignment for each cell in a row
572+
// individually, that matches the UI widget implementation which is also
573+
// row based and needs alignment information to be per cell.
574+
final TableColumnTextAlignment textAlignment;
575+
576+
@override
577+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
578+
super.debugFillProperties(properties);
579+
properties.add(DiagnosticsProperty<TableColumnTextAlignment>('textAlign', textAlignment));
580+
}
581+
}
582+
507583
/// A content node that expects an inline layout context from its parent.
508584
///
509585
/// When rendered into a Flutter widget tree, an inline content node
@@ -1222,6 +1298,136 @@ class _ZulipContentParser {
12221298
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
12231299
}
12241300

1301+
BlockContentNode parseTableContent(dom.Element tableElement) {
1302+
assert(_debugParserContext == _ParserContext.block);
1303+
assert(tableElement.localName == 'table'
1304+
&& tableElement.className.isEmpty);
1305+
1306+
TableCellNode? parseTableCell(dom.Element node, bool isHeader) {
1307+
assert(node.localName == (isHeader ? 'th' : 'td'));
1308+
assert(node.className.isEmpty);
1309+
assert(node.attributes.length <= 1);
1310+
1311+
final cellStyle = node.attributes['style'];
1312+
final TableColumnTextAlignment? textAlignment;
1313+
switch (cellStyle) {
1314+
case null:
1315+
textAlignment = TableColumnTextAlignment.defaults;
1316+
case 'text-align: left;':
1317+
textAlignment = TableColumnTextAlignment.left;
1318+
case 'text-align: center;':
1319+
textAlignment = TableColumnTextAlignment.center;
1320+
case 'text-align: right;':
1321+
textAlignment = TableColumnTextAlignment.right;
1322+
default:
1323+
return null;
1324+
}
1325+
final parsed = parseBlockInline(node.nodes);
1326+
return TableCellNode(
1327+
nodes: parsed.nodes,
1328+
links: parsed.links,
1329+
textAlignment: textAlignment);
1330+
}
1331+
1332+
List<TableCellNode>? parseTableCells(dom.NodeList cellNodes, bool isHeader) {
1333+
final cells = <TableCellNode>[];
1334+
for (final node in cellNodes) {
1335+
if (node is dom.Text && node.text == '\n') continue;
1336+
1337+
if (node is! dom.Element) return null;
1338+
if (node.localName != (isHeader ? 'th' : 'td')) return null;
1339+
if (node.className.isNotEmpty) return null;
1340+
if (node.attributes.length > 1) return null;
1341+
1342+
final cell = parseTableCell(node, isHeader);
1343+
if (cell == null) return null;
1344+
cells.add(cell);
1345+
}
1346+
return cells;
1347+
}
1348+
1349+
TableRowNode? parseTableHeaderRow(dom.NodeList theadNodes) {
1350+
if (theadNodes case [
1351+
dom.Text(data: '\n'),
1352+
dom.Element(localName: 'tr') && final rowElement,
1353+
dom.Text(data: '\n'),
1354+
]) {
1355+
if (rowElement.className.isNotEmpty) return null;
1356+
if (rowElement.attributes.isNotEmpty) return null;
1357+
if (rowElement.nodes.isEmpty) return null;
1358+
1359+
final cells = parseTableCells(rowElement.nodes, true);
1360+
if (cells == null) return null;
1361+
return TableRowNode(cells: cells, isHeader: true);
1362+
} else {
1363+
return null;
1364+
}
1365+
}
1366+
1367+
bool parseTableBodyRows(dom.NodeList tbodyNodes, int headerColumnCount, List<TableRowNode> parsedRows) {
1368+
for (final node in tbodyNodes) {
1369+
if (node is dom.Text && node.text == '\n') continue;
1370+
1371+
if (node is! dom.Element) return false;
1372+
if (node.localName != 'tr') return false;
1373+
if (node.className.isNotEmpty) return false;
1374+
if (node.attributes.isNotEmpty) return false;
1375+
if (node.nodes.isEmpty) return false;
1376+
1377+
final cells = parseTableCells(node.nodes, false);
1378+
if (cells == null) return false;
1379+
1380+
// Ensure that the number of columns in this row matches
1381+
// the header row.
1382+
if (cells.length != headerColumnCount) return false;
1383+
parsedRows.add(TableRowNode(cells: cells, isHeader: false));
1384+
}
1385+
return true;
1386+
}
1387+
1388+
final TableNode? tableNode = (() {
1389+
late final int headerColumnCount;
1390+
final parsedRows = <TableRowNode>[];
1391+
for (final node in tableElement.nodes) {
1392+
if (node is dom.Text && node.text == '\n') continue;
1393+
1394+
switch (node) {
1395+
case dom.Element(localName: 'thead') && final theadElement:
1396+
if (theadElement.className.isNotEmpty) return null;
1397+
if (theadElement.attributes.isNotEmpty) return null;
1398+
if (theadElement.nodes.isEmpty) return null;
1399+
1400+
// The first row to be parsed should be the header row.
1401+
if (parsedRows.isNotEmpty) return null;
1402+
1403+
final headerRow = parseTableHeaderRow(theadElement.nodes);
1404+
if (headerRow == null) return null;
1405+
headerColumnCount = headerRow.cells.length;
1406+
parsedRows.add(headerRow);
1407+
1408+
case dom.Element(localName: 'tbody') && final tbodyElement:
1409+
if (tbodyElement.className.isNotEmpty) return null;
1410+
if (tbodyElement.attributes.isNotEmpty) return null;
1411+
if (tbodyElement.nodes.isEmpty) return null;
1412+
1413+
// The header row should have been parsed already.
1414+
if (parsedRows.isEmpty) return null;
1415+
1416+
final nodes = tbodyElement.nodes;
1417+
if (!parseTableBodyRows(nodes, headerColumnCount, parsedRows)) {
1418+
return null;
1419+
}
1420+
1421+
default:
1422+
return null;
1423+
}
1424+
}
1425+
return TableNode(rows: parsedRows);
1426+
})();
1427+
1428+
return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement);
1429+
}
1430+
12251431
BlockContentNode parseBlockContent(dom.Node node) {
12261432
assert(_debugParserContext == _ParserContext.block);
12271433
final debugHtmlNode = kDebugMode ? node : null;
@@ -1290,6 +1496,10 @@ class _ZulipContentParser {
12901496
parseBlockContentList(element.nodes));
12911497
}
12921498

1499+
if (localName == 'table' && className.isEmpty) {
1500+
return parseTableContent(element);
1501+
}
1502+
12931503
if (localName == 'div' && className == 'spoiler-block') {
12941504
return parseSpoilerNode(element);
12951505
}
@@ -1335,6 +1545,7 @@ class _ZulipContentParser {
13351545
case 'h6':
13361546
case 'blockquote':
13371547
case 'div':
1548+
case 'table':
13381549
return false;
13391550
default:
13401551
return true;

0 commit comments

Comments
 (0)