Skip to content

Commit 979b7ce

Browse files
content: Handle <table> elements
Fixes: zulip#360
1 parent fe16ad1 commit 979b7ce

File tree

5 files changed

+591
-5
lines changed

5 files changed

+591
-5
lines changed

lib/model/content.dart

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,88 @@ 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 that applies to text inside the table cell.
547+
///
548+
/// See GitHub-flavored Markdown:
549+
/// https://github.github.com/gfm/#tables-extension-
550+
// In Markdown, alignment is defined per column using the delimiter row.
551+
// However, the generated HTML specifies alignment for each cell in a row
552+
// individually, that matches the UI widget implementation which is also
553+
// row based and needs alignment information to be per cell.
554+
enum TableCellTextAlignment {
555+
/// Text inside the table cell is left-aligned.
556+
// Represented in markdown as `|: --- |` for the specific column in the delimeter row.
557+
// TODO(i18n) RTL issues? https://github.com/zulip/zulip/issues/32265
558+
left,
559+
/// Text inside the table cell is center-aligned.
560+
// Represented in markdown as `|: --- :|` for the specific column in the delimeter row.
561+
center,
562+
/// Text inside the table cell is right-aligned.
563+
// Represented in markdown as `| --- :|` for the specific column in the delimeter row.
564+
// TODO(i18n) RTL issues? https://github.com/zulip/zulip/issues/32265
565+
right,
566+
/// Text inside the table cell is aligned the default way.
567+
// Represented in markdown as `| --- |` for the specific column in the delimeter row.
568+
defaults
569+
}
570+
571+
class TableCellNode extends BlockInlineContainerNode {
572+
const TableCellNode({
573+
super.debugHtmlNode,
574+
required super.nodes,
575+
required super.links,
576+
required this.textAlignment,
577+
});
578+
579+
/// The text-alignment for text inside the table cell.
580+
final TableCellTextAlignment textAlignment;
581+
582+
@override
583+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
584+
super.debugFillProperties(properties);
585+
properties.add(DiagnosticsProperty<TableCellTextAlignment>('textAlign', textAlignment));
586+
}
587+
}
588+
507589
/// A content node that expects an inline layout context from its parent.
508590
///
509591
/// When rendered into a Flutter widget tree, an inline content node
@@ -1222,6 +1304,136 @@ class _ZulipContentParser {
12221304
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
12231305
}
12241306

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

1505+
if (localName == 'table' && className.isEmpty) {
1506+
return parseTableContent(element);
1507+
}
1508+
12931509
if (localName == 'div' && className == 'spoiler-block') {
12941510
return parseSpoilerNode(element);
12951511
}
@@ -1335,6 +1551,7 @@ class _ZulipContentParser {
13351551
case 'h6':
13361552
case 'blockquote':
13371553
case 'div':
1554+
case 'table':
13381555
return false;
13391556
default:
13401557
return true;

0 commit comments

Comments
 (0)