Repository: superlistapp/super_editor

Issue:
[Markdown] - Add table parsing
Within our larger efforts related to tables, one thing we want to support is Markdown tables.

There's no standard spec for Markdown tables. However, there is a popular extension specification:
https://www.markdownguide.org/extended-syntax/#tables

It looks like GitHub replicates this particular extension:
https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables

For this ticket, handle all possible parsing of this syntax. 

Sometime after this ticket we'll send this data into a new `TableNode`, but that's outside the scope of this ticket. For now, create whatever data structure makes sense and we'll rework it in a followup ticket.

Ensure that all obvious edge cases are covered with tests (Note: There is a table generator linked from the markdown guide above - that might help with assembling test tables).

Make sure you note the statement in the guide about the types of inline formatting that are allowed, but that block level formatting isn't supported in tables for Markdown.




Fix patch:
diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart
index ba6fa1e96..879a71977 100644
--- a/super_editor/lib/src/default_editor/text.dart
+++ b/super_editor/lib/src/default_editor/text.dart
@@ -677,6 +677,12 @@ mixin TextComponentViewModel on SingleColumnLayoutComponentViewModel {
       showComposingRegionUnderline.hashCode;
 }
 
+/// Keys to access metadata that are specific to a [TextNode].
+class TextNodeMetadata {
+  /// The [TextAlign] of the [TextNode].
+  static const String textAlign = 'textAlign';
+}
+
 /// Document component that displays hint text when its content text
 /// is empty.
 ///
diff --git a/super_editor_markdown/lib/src/markdown_inline_parser.dart b/super_editor_markdown/lib/src/markdown_inline_parser.dart
new file mode 100644
index 000000000..a5a0ba689
--- /dev/null
+++ b/super_editor_markdown/lib/src/markdown_inline_parser.dart
@@ -0,0 +1,116 @@
+import 'package:markdown/markdown.dart' as md;
+import 'package:super_editor/super_editor.dart';
+import 'package:super_editor_markdown/src/image_syntax.dart';
+import 'package:super_editor_markdown/super_editor_markdown.dart';
+
+/// Parses inline markdown content.
+///
+/// Supports strikethrough, underline, bold, italics, code and links.
+///
+/// The given [syntax] controls how the [text] is parsed, e.g., [MarkdownSyntax.normal]
+/// for strict Markdown parsing, or [MarkdownSyntax.superEditor] to use Super Editor's
+/// extended syntax.
+///
+/// If [encodeHtml] is `true`, it escapes HTML symbols like &, <, and >. For example,
+/// `&` becomes `&amp;`, `<` becomes `&lt;`, and `>` becomes `&gt;`.
+AttributedText parseInlineMarkdown(
+  String text, {
+  MarkdownSyntax syntax = MarkdownSyntax.superEditor,
+  bool encodeHtml = false,
+}) {
+  final inlineParser = md.InlineParser(
+    text,
+    md.Document(
+      inlineSyntaxes: [
+        SingleStrikethroughSyntax(), // this needs to be before md.StrikethroughSyntax to be recognized
+        md.StrikethroughSyntax(),
+        UnderlineSyntax(),
+        if (syntax == MarkdownSyntax.superEditor) //
+          SuperEditorImageSyntax(),
+      ],
+      encodeHtml: encodeHtml,
+    ),
+  );
+  final inlineVisitor = _InlineMarkdownToDocument();
+  final inlineNodes = inlineParser.parse();
+  for (final inlineNode in inlineNodes) {
+    inlineNode.accept(inlineVisitor);
+  }
+  return inlineVisitor.attributedText;
+}
+
+/// Parses inline markdown content.
+///
+/// Apply [_InlineMarkdownToDocument] to a text [md.Element] to
+/// obtain an [AttributedText] that represents the inline
+/// styles within the given text.
+///
+/// [_InlineMarkdownToDocument] does not support parsing text
+/// that contains image tags. If any non-image text is found,
+/// the content is treated as styled text.
+class _InlineMarkdownToDocument implements md.NodeVisitor {
+  _InlineMarkdownToDocument();
+
+  AttributedText get attributedText => _textStack.first;
+
+  final List<AttributedText> _textStack = [AttributedText()];
+
+  @override
+  bool visitElementBefore(md.Element element) {
+    _textStack.add(AttributedText());
+
+    return true;
+  }
+
+  @override
+  void visitText(md.Text text) {
+    final attributedText = _textStack.removeLast();
+    _textStack.add(attributedText.copyAndAppend(AttributedText(text.text)));
+  }
+
+  @override
+  void visitElementAfter(md.Element element) {
+    // Reset to normal text style because a plain text element does
+    // not receive a call to visitElementBefore().
+    final styledText = _textStack.removeLast();
+
+    if (element.tag == 'strong') {
+      styledText.addAttribution(
+        boldAttribution,
+        SpanRange(0, styledText.length - 1),
+      );
+    } else if (element.tag == 'em') {
+      styledText.addAttribution(
+        italicsAttribution,
+        SpanRange(0, styledText.length - 1),
+      );
+    } else if (element.tag == "del") {
+      styledText.addAttribution(
+        strikethroughAttribution,
+        SpanRange(0, styledText.length - 1),
+      );
+    } else if (element.tag == "code") {
+      styledText.addAttribution(
+        codeAttribution,
+        SpanRange(0, styledText.length - 1),
+      );
+    } else if (element.tag == "u") {
+      styledText.addAttribution(
+        underlineAttribution,
+        SpanRange(0, styledText.length - 1),
+      );
+    } else if (element.tag == 'a') {
+      styledText.addAttribution(
+        LinkAttribution.fromUri(Uri.parse(element.attributes['href']!)),
+        SpanRange(0, styledText.length - 1),
+      );
+    }
+
+    if (_textStack.isNotEmpty) {
+      final surroundingText = _textStack.removeLast();
+      _textStack.add(surroundingText.copyAndAppend(styledText));
+    } else {
+      _textStack.add(styledText);
+    }
+  }
+}
diff --git a/super_editor_markdown/lib/src/markdown_to_document_parsing.dart b/super_editor_markdown/lib/src/markdown_to_document_parsing.dart
index 7cfc88cfa..108a2441f 100644
--- a/super_editor_markdown/lib/src/markdown_to_document_parsing.dart
+++ b/super_editor_markdown/lib/src/markdown_to_document_parsing.dart
@@ -1,8 +1,11 @@
 import 'dart:convert';
 
+import 'package:collection/collection.dart';
 import 'package:markdown/markdown.dart' as md;
 import 'package:super_editor/super_editor.dart';
 import 'package:super_editor_markdown/src/image_syntax.dart';
+import 'package:super_editor_markdown/src/markdown_inline_parser.dart';
+import 'package:super_editor_markdown/src/table.dart';
 import 'package:super_editor_markdown/super_editor_markdown.dart';
 
 import 'super_editor_syntax.dart';
@@ -23,10 +26,13 @@ MutableDocument deserializeMarkdownToDocument(
   List<ElementToNodeConverter> customElementToNodeConverters = const [],
   bool encodeHtml = false,
 }) {
-  final markdownLines = const LineSplitter().convert(markdown).map<md.Line>((String l) {
-    return md.Line(l);
-  }).toList();
+  final markdownLines = const LineSplitter().convert(markdown).map<md.Line>(
+    (String l) {
+      return md.Line(l);
+    },
+  ).toList();
 
+  // Parse markdown string to structured markdown.
   final markdownDoc = md.Document(
     encodeHtml: encodeHtml,
     blockSyntaxes: [
@@ -37,11 +43,10 @@ MutableDocument deserializeMarkdownToDocument(
       ],
       const _EmptyLinePreservingParagraphSyntax(),
       const md.UnorderedListWithCheckboxSyntax(),
+      const md.TableSyntax(),
     ],
   );
   final blockParser = md.BlockParser(markdownLines, markdownDoc);
-
-  // Parse markdown string to structured markdown.
   final markdownNodes = blockParser.parseLines();
 
   // Convert structured markdown to a Document.
@@ -62,7 +67,7 @@ MutableDocument deserializeMarkdownToDocument(
   }
 
   // Add 1 hanging line for every 2 blank lines at the end, need this to preserve behavior pre markdown 7.2.1
-  final hangingEmptyLines = markdownLines.reversed.takeWhile((md.Line l) => l.isBlankLine);
+  final hangingEmptyLines = markdownLines.reversed.takeWhile((line) => _blankLinePattern.hasMatch(line.content));
   if (hangingEmptyLines.isNotEmpty && documentNodes.lastOrNull is ListItemNode) {
     for (var i = 0; i < hangingEmptyLines.length ~/ 2; i++) {
       documentNodes.add(ParagraphNode(id: Editor.createNodeId(), text: AttributedText()));
@@ -161,23 +166,14 @@ class _MarkdownToDocument implements md.NodeVisitor {
         _addHeader(element, level: 6);
         break;
       case 'p':
-        final inlineVisitor = _parseInline(element.textContent);
-
-        if (inlineVisitor.isImage) {
-          _addImage(
-            // TODO: handle null image URL
-            imageUrl: inlineVisitor.imageUrl!,
-            altText: inlineVisitor.imageAltText!,
-            expectedBitmapSize: inlineVisitor.width != null || inlineVisitor.height != null
-                ? ExpectedSize(
-                    inlineVisitor.width != null ? int.tryParse(inlineVisitor.width!) : null,
-                    inlineVisitor.height != null ? int.tryParse(inlineVisitor.height!) : null,
-                  )
-                : null,
-          );
+        final blockImage = _maybeParseBlockImage(element.textContent);
+        if (blockImage != null) {
+          _addImage(blockImage);
         } else {
-          _addParagraph(inlineVisitor.attributedText, element.attributes);
+          final attributedText = parseInlineMarkdown(element.textContent, syntax: syntax, encodeHtml: _encodeHtml);
+          _addParagraph(attributedText, element.attributes);
         }
+
         break;
       case 'blockquote':
         _addBlockquote(element);
@@ -232,6 +228,11 @@ class _MarkdownToDocument implements md.NodeVisitor {
       case 'hr':
         _addHorizontalRule();
         break;
+      case 'table':
+        _addTable(element);
+
+        // Skip any children because we already processed the whole table.
+        return false;
     }
 
     return true;
@@ -340,17 +341,18 @@ class _MarkdownToDocument implements md.NodeVisitor {
     );
   }
 
-  void _addImage({
-    required String imageUrl,
-    required String altText,
-    ExpectedSize? expectedBitmapSize,
-  }) {
+  void _addImage(_MarkdownImage image) {
     _content.add(
       ImageNode(
         id: Editor.createNodeId(),
-        imageUrl: imageUrl,
-        altText: altText,
-        expectedBitmapSize: expectedBitmapSize,
+        imageUrl: image.url,
+        altText: image.altText ?? '',
+        expectedBitmapSize: image.width != null || image.height != null
+            ? ExpectedSize(
+                image.width != null ? int.tryParse(image.width!) : null,
+                image.height != null ? int.tryParse(image.height!) : null,
+              )
+            : null,
       ),
     );
   }
@@ -406,132 +408,28 @@ class _MarkdownToDocument implements md.NodeVisitor {
     );
   }
 
-  AttributedText _parseInlineText(String text) {
-    final inlineVisitor = _parseInline(text);
-    return inlineVisitor.attributedText;
+  void _addTable(md.Element element) {
+    _content.add(element.asTable());
   }
 
-  _InlineMarkdownToDocument _parseInline(String text) {
-    final inlineParser = md.InlineParser(
+  AttributedText _parseInlineText(String text) {
+    return parseInlineMarkdown(
       text,
-      md.Document(
-        inlineSyntaxes: [
-          SingleStrikethroughSyntax(), // this needs to be before md.StrikethroughSyntax to be recognized
-          md.StrikethroughSyntax(),
-          UnderlineSyntax(),
-          if (syntax == MarkdownSyntax.superEditor) //
-            SuperEditorImageSyntax(),
-        ],
-        encodeHtml: _encodeHtml,
-      ),
+      syntax: syntax,
+      encodeHtml: _encodeHtml,
     );
-    final inlineVisitor = _InlineMarkdownToDocument();
-    final inlineNodes = inlineParser.parse();
-    for (final inlineNode in inlineNodes) {
-      inlineNode.accept(inlineVisitor);
-    }
-    return inlineVisitor;
   }
-}
-
-/// Parses inline markdown content.
-///
-/// Apply [_InlineMarkdownToDocument] to a text [Element] to
-/// obtain an [AttributedText] that represents the inline
-/// styles within the given text.
-///
-/// Apply [_InlineMarkdownToDocument] to an [Element] whose
-/// content is an image tag to obtain image data.
-///
-/// [_InlineMarkdownToDocument] does not support parsing text
-/// that contains image tags. If any non-image text is found,
-/// the content is treated as styled text.
-class _InlineMarkdownToDocument implements md.NodeVisitor {
-  _InlineMarkdownToDocument();
 
-  // For our purposes, we only support block-level images. Therefore,
-  // if we find an image without any text, we're parsing an image.
-  // Otherwise, if there is any text, then we're parsing a paragraph
-  // and we ignore the image.
-  bool get isImage => _imageUrl != null && attributedText.isEmpty;
-
-  String? _imageUrl;
-  String? get imageUrl => _imageUrl;
-
-  String? _imageAltText;
-  String? get imageAltText => _imageAltText;
-
-  String? get width => _width;
-  String? _width;
-
-  String? get height => _height;
-  String? _height;
-
-  AttributedText get attributedText => _textStack.first;
-
-  final List<AttributedText> _textStack = [AttributedText()];
-
-  @override
-  bool visitElementBefore(md.Element element) {
-    if (element.tag == 'img') {
-      // TODO: handle missing "src" attribute
-      _imageUrl = element.attributes['src']!;
-      _imageAltText = element.attributes['alt'] ?? '';
-      _width = element.attributes['width'];
-      _height = element.attributes['height'];
-      return true;
-    }
-
-    _textStack.add(AttributedText());
-
-    return true;
-  }
-
-  @override
-  void visitText(md.Text text) {
-    final attributedText = _textStack.removeLast();
-    _textStack.add(attributedText.copyAndAppend(AttributedText(text.text)));
-  }
-
-  @override
-  void visitElementAfter(md.Element element) {
-    // Reset to normal text style because a plain text element does
-    // not receive a call to visitElementBefore().
-    final styledText = _textStack.removeLast();
-
-    if (element.tag == 'strong') {
-      styledText.addAttribution(
-        boldAttribution,
-        SpanRange(0, styledText.length - 1),
-      );
-    } else if (element.tag == 'em') {
-      styledText.addAttribution(
-        italicsAttribution,
-        SpanRange(0, styledText.length - 1),
-      );
-    } else if (element.tag == "del") {
-      styledText.addAttribution(
-        strikethroughAttribution,
-        SpanRange(0, styledText.length - 1),
-      );
-    } else if (element.tag == "u") {
-      styledText.addAttribution(
-        underlineAttribution,
-        SpanRange(0, styledText.length - 1),
-      );
-    } else if (element.tag == 'a') {
-      styledText.addAttribution(
-        LinkAttribution.fromUri(Uri.parse(element.attributes['href']!)),
-        SpanRange(0, styledText.length - 1),
-      );
+  _MarkdownImage? _maybeParseBlockImage(String markdown) {
+    if (!markdown.startsWith("![")) {
+      // Text doesn't start with Markdown image syntax. Return.
+      return null;
     }
 
-    if (_textStack.isNotEmpty) {
-      final surroundingText = _textStack.removeLast();
-      _textStack.add(surroundingText.copyAndAppend(styledText));
-    } else {
-      _textStack.add(styledText);
-    }
+    return _MarkdownBlockImageParser().maybeParseBlockImage(
+      markdown,
+      syntax: syntax,
+    );
   }
 }
 
@@ -895,6 +793,123 @@ class _HeaderWithAlignmentSyntax extends md.BlockSyntax {
   }
 }
 
+class _MarkdownImage {
+  _MarkdownImage({
+    required this.url,
+    this.altText,
+    this.width,
+    this.height,
+  });
+
+  final String url;
+  final String? altText;
+  final String? width;
+  final String? height;
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is _MarkdownImage &&
+          runtimeType == other.runtimeType &&
+          url == other.url &&
+          altText == other.altText &&
+          width == other.width &&
+          height == other.height;
+
+  @override
+  int get hashCode => url.hashCode ^ altText.hashCode ^ width.hashCode ^ height.hashCode;
+}
+
+class _MarkdownBlockImageParser {
+  /// Parses a block-level image from the given [markdown].
+  ///
+  /// A block-level image is a paragraph that contains an image tag
+  /// and no other text.
+  _MarkdownImage? maybeParseBlockImage(
+    String markdown, {
+    MarkdownSyntax syntax = MarkdownSyntax.superEditor,
+  }) {
+    final inlineParser = md.InlineParser(
+      markdown,
+      md.Document(
+        inlineSyntaxes: [
+          if (syntax == MarkdownSyntax.superEditor) //
+            SuperEditorImageSyntax(),
+        ],
+      ),
+    );
+    final inlineVisitor = _InlineMarkdownImageVisitor();
+    final inlineNodes = inlineParser.parse();
+    for (final inlineNode in inlineNodes) {
+      inlineNode.accept(inlineVisitor);
+    }
+    if (!inlineVisitor.isImage) {
+      return null;
+    }
+
+    return _MarkdownImage(
+      url: inlineVisitor.imageUrl!,
+      altText: inlineVisitor.imageAltText,
+      width: inlineVisitor.width,
+      height: inlineVisitor.height,
+    );
+  }
+}
+
+/// A [md.NodeVisitor] that extracts an image from inline Markdown nodes.
+class _InlineMarkdownImageVisitor implements md.NodeVisitor {
+  _InlineMarkdownImageVisitor();
+
+  /// Returns `true` if the parsed image is a block-level image.
+  ///
+  /// A block-level image is an image that is not part of a paragraph.
+  /// It has no text content, and it is not inline with other text.
+  ///
+  // For our purposes, we only support block-level images. Therefore,
+  // if we find an image without any text, we're parsing an image.
+  // Otherwise, if there is any text, then we're parsing a paragraph
+  // and we ignore the image.
+  bool get isImage => _imageUrl != null && _textStack.first.isEmpty;
+
+  String? _imageUrl;
+  String? get imageUrl => _imageUrl;
+
+  String? _imageAltText;
+  String? get imageAltText => _imageAltText;
+
+  String? get width => _width;
+  String? _width;
+
+  String? get height => _height;
+  String? _height;
+
+  final List<AttributedText> _textStack = [AttributedText()];
+
+  @override
+  bool visitElementBefore(md.Element element) {
+    if (element.tag == 'img' && element.attributes.containsKey('src')) {
+      _imageUrl = element.attributes['src']!;
+      _imageAltText = element.attributes['alt'] ?? '';
+      _width = element.attributes['width'];
+      _height = element.attributes['height'];
+      return true;
+    }
+
+    _textStack.add(AttributedText());
+
+    return true;
+  }
+
+  @override
+  void visitText(md.Text text) {
+    final attributedText = _textStack.removeLast();
+    _textStack.add(attributedText.copyAndAppend(AttributedText(text.text)));
+  }
+
+  @override
+  void visitElementAfter(md.Element element) {}
+}
+
 /// Matches empty lines or lines containing only whitespace.
 final _blankLinePattern = RegExp(r'^(?:[ \t]*)$');
 
diff --git a/super_editor_markdown/lib/src/table.dart b/super_editor_markdown/lib/src/table.dart
new file mode 100644
index 000000000..147c913c9
--- /dev/null
+++ b/super_editor_markdown/lib/src/table.dart
@@ -0,0 +1,97 @@
+import 'dart:ui';
+
+import 'package:markdown/markdown.dart' as md;
+import 'package:super_editor/super_editor.dart';
+import 'package:super_editor_markdown/src/markdown_inline_parser.dart';
+
+extension ElementTableExtension on md.Element {
+  /// Converts this element to a [TableBlockNode].
+  ///
+  /// The element must have a `table` tag.
+  ///
+  /// Throws an exception if the element is not a valid table structure.
+  TableBlockNode asTable() {
+    if (tag != 'table') {
+      throw Exception('Cannot parse a table from an element with tag "$tag"');
+    }
+
+    if (children == null || children!.isEmpty) {
+      throw Exception('A table must have at least one child element');
+    }
+
+    final cells = <List<TextNode>>[];
+
+    final headerElement = children![0];
+    if (headerElement is! md.Element || headerElement.tag != 'thead') {
+      throw Exception('Table header must be a <thead> element');
+    }
+    if (headerElement.children == null || headerElement.children!.isEmpty) {
+      throw Exception('Table header must have a row');
+    }
+
+    final headerRow = headerElement.children![0];
+    if (headerRow is! md.Element || headerRow.tag != 'tr') {
+      throw Exception('Table header row must be a <tr> element');
+    }
+
+    final headerNodes = <TextNode>[];
+    for (final headerCell in headerRow.children!) {
+      if (headerCell is! md.Element || headerCell.tag != 'th') {
+        throw Exception('Table header cells must be <th> elements');
+      }
+      headerNodes.add(
+        TextNode(
+          id: Editor.createNodeId(),
+          text: parseInlineMarkdown(headerCell.textContent),
+          metadata: const {
+            NodeMetadata.blockType: tableHeaderAttribution,
+            TextNodeMetadata.textAlign: TextAlign.center,
+          },
+        ),
+      );
+    }
+    cells.add(headerNodes);
+
+    if (children!.length >= 2) {
+      // The table contains the table body element.
+      final bodyElement = children![1];
+      if (bodyElement is! md.Element || bodyElement.tag != 'tbody') {
+        throw Exception('Table body must be a <tbody> element');
+      }
+
+      for (final rowElement in bodyElement.children!) {
+        if (rowElement is! md.Element || rowElement.tag != 'tr') {
+          throw Exception('Table body rows must be <tr> elements');
+        }
+
+        final row = <TextNode>[];
+        for (int i = 0; i < rowElement.children!.length; i++) {
+          final cellElement = rowElement.children![i];
+          if (cellElement is! md.Element || cellElement.tag != 'td') {
+            throw Exception('Table body cells must be <td> elements');
+          }
+          final textAlign = switch ((headerRow.children![i] as md.Element).attributes['align']) {
+            'left' => TextAlign.left,
+            'center' => TextAlign.center,
+            'right' => TextAlign.right,
+            _ => TextAlign.left,
+          };
+
+          row.add(TextNode(
+            id: Editor.createNodeId(),
+            text: parseInlineMarkdown(cellElement.textContent),
+            metadata: {
+              if (textAlign != TextAlign.left) TextNodeMetadata.textAlign: textAlign,
+            },
+          ));
+        }
+        cells.add(row);
+      }
+    }
+
+    return TableBlockNode(
+      id: Editor.createNodeId(),
+      cells: cells,
+    );
+  }
+}
diff --git a/super_editor_markdown/pubspec.yaml b/super_editor_markdown/pubspec.yaml
index dbe6a6ae2..05ed0abd0 100644
--- a/super_editor_markdown/pubspec.yaml
+++ b/super_editor_markdown/pubspec.yaml
@@ -14,6 +14,7 @@ dependencies:
   super_editor: ^0.3.0-dev.10
   logging: ^1.3.0
   markdown: ^7.2.1
+  collection: ^1.15.0
 
 dependency_overrides:
   # Override to local mono-repo path so devs can test this repo
@@ -33,5 +34,8 @@ dev_dependencies:
   flutter_test_runners: ^0.0.4
   golden_toolkit: ^0.15.0
 
+  super_text_layout: ^0.1.12
+  text_table: ^4.0.1
+
 flutter:
 # no Flutter configuration


Test patch:
diff --git a/super_editor_markdown/test/super_editor_markdown_test.dart b/super_editor_markdown/test/super_editor_markdown_test.dart
index a1aa93d14..28b4ff4c5 100644
--- a/super_editor_markdown/test/super_editor_markdown_test.dart
+++ b/super_editor_markdown/test/super_editor_markdown_test.dart
@@ -1571,6 +1571,18 @@ with multiple lines
         expect(styledText.getAllAttributionsAt(7).contains(underlineAttribution), false);
       });
 
+      test('paragraph with inline code', () {
+        final doc = deserializeMarkdownToDocument('`This is` a paragraph.');
+        final styledText = (doc.getNodeAt(0)! as ParagraphNode).text;
+
+        // Ensure text within the range is attributed.
+        expect(styledText.getAllAttributionsAt(0).contains(codeAttribution), true);
+        expect(styledText.getAllAttributionsAt(6).contains(codeAttribution), true);
+
+        // Ensure text outside the range isn't attributed.
+        expect(styledText.getAllAttributionsAt(7).contains(codeAttribution), false);
+      });
+
       test('paragraph with left alignment', () {
         final doc = deserializeMarkdownToDocument(':---\nParagraph1');
 
diff --git a/super_editor_markdown/test/table_parser_test.dart b/super_editor_markdown/test/table_parser_test.dart
new file mode 100644
index 000000000..8d795c910
--- /dev/null
+++ b/super_editor_markdown/test/table_parser_test.dart
@@ -0,0 +1,859 @@
+import 'dart:math';
+import 'dart:ui';
+
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:super_editor/super_editor.dart';
+import 'package:super_editor_markdown/super_editor_markdown.dart';
+import 'package:text_table/text_table.dart';
+
+void main() {
+  group('Markdown > deserialization > tables >', () {
+    test('table with single column', () {
+      expect(
+        _parseMarkdownTable('''| header 1 |
+|---|
+| data 1 |'''),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              )
+            ],
+            [TextNode(id: '1-2', text: AttributedText('data 1'))],
+          ],
+        )),
+      );
+    });
+
+    test('table with two columns', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 |
+|---|---|
+| data 1 | data 2 |''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table with multiple rows', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 |
+|---|---|
+| data 1 | data 2 |
+| data 3 | data 4 |
+| data 5 | data 6 |''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              )
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+            ],
+            [
+              TextNode(id: '3-1', text: AttributedText('data 3')),
+              TextNode(id: '3-2', text: AttributedText('data 4')),
+            ],
+            [
+              TextNode(id: '4-1', text: AttributedText('data 5')),
+              TextNode(id: '4-2', text: AttributedText('data 6')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table with alignment', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 | header 3 |
+|:---|:---:|---:|
+| data 1 | data 2 | data 3 |''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-3',
+                text: AttributedText('header 3'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(
+                id: '2-2',
+                text: AttributedText('data 2'),
+                metadata: const {
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '2-3',
+                text: AttributedText('data 3'),
+                metadata: const {
+                  TextNodeMetadata.textAlign: TextAlign.right,
+                },
+              ),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table with inline markdown', () {
+      expect(
+        _parseMarkdownTable(
+          '''| **header 1** |
+|---|
+| [link](https://example.org) |''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText(
+                  'header 1',
+                  AttributedSpans(
+                    attributions: [
+                      const SpanMarker(
+                        attribution: boldAttribution,
+                        offset: 0,
+                        markerType: SpanMarkerType.start,
+                      ),
+                      const SpanMarker(
+                        attribution: boldAttribution,
+                        offset: 7,
+                        markerType: SpanMarkerType.end,
+                      ),
+                    ],
+                  ),
+                ),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              )
+            ],
+            [
+              TextNode(
+                id: '2-1',
+                text: AttributedText(
+                  'link',
+                  AttributedSpans(
+                    attributions: [
+                      const SpanMarker(
+                          attribution: LinkAttribution('https://example.org'),
+                          offset: 0,
+                          markerType: SpanMarkerType.start),
+                      const SpanMarker(
+                          attribution: LinkAttribution('https://example.org'),
+                          offset: 3,
+                          markerType: SpanMarkerType.end),
+                    ],
+                  ),
+                ),
+              )
+            ]
+          ],
+        )),
+      );
+    });
+
+    test('table without body', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 |
+|---|---|''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table with text containing pipe', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header\\|1 |
+|---|
+| data\\|1 |''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header|1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data|1')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table with inline markdown containing pipe', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header **\\|1** |
+|---|
+| data 1 |''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText(
+                  'header |1',
+                  AttributedSpans(
+                    attributions: [
+                      const SpanMarker(
+                        attribution: boldAttribution,
+                        offset: 7,
+                        markerType: SpanMarkerType.start,
+                      ),
+                      const SpanMarker(
+                        attribution: boldAttribution,
+                        offset: 8,
+                        markerType: SpanMarkerType.end,
+                      ),
+                    ],
+                  ),
+                ),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [TextNode(id: '2-1', text: AttributedText('data 1'))],
+          ],
+        )),
+      );
+    });
+
+    test('table with delimiter with more than three hyphens', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 |
+|---|----------|
+| data 1 | data 2 |''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table without leading pipe', () {
+      expect(
+        _parseMarkdownTable(
+          '''header 1 | header 2 |
+---|---|
+ data 1 | data 2 |''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table without trailing pipe', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 
+|---|---
+| data 1 | data 2 ''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table without leading and trailing pipe', () {
+      expect(
+        _parseMarkdownTable(
+          '''header 1 | header 2 
+---|---
+data 1 | data 2 ''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table with row with missing column', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 |
+|---|---|
+| data 1''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table with row with extra column', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 |
+|---|---|
+| data 1 | data 2 | extra data |''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table with tabs between columns', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 	| header 2 	| header 3 	|
+|---	|---	|---	|
+| data 1 	| data 2 	| data 3 	|''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-3',
+                text: AttributedText('header 3'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+              TextNode(id: '2-3', text: AttributedText('data 3')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table broken by block level element', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 |
+|---|---|
+| data 1 | data 2 |
+> blockquote''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+            ],
+          ],
+        )),
+      );
+    });
+
+    test('table broken by empty line', () {
+      expect(
+        _parseMarkdownTable(
+          '''| header 1 | header 2 |
+|---|---|
+| data 1 | data 2 |
+data 3
+
+paragraph''',
+        ),
+        _matchesTableContent(TableBlockNode(
+          id: '1',
+          cells: [
+            [
+              TextNode(
+                id: '1-1',
+                text: AttributedText('header 1'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+              TextNode(
+                id: '1-2',
+                text: AttributedText('header 2'),
+                metadata: const {
+                  NodeMetadata.blockType: tableHeaderAttribution,
+                  TextNodeMetadata.textAlign: TextAlign.center,
+                },
+              ),
+            ],
+            [
+              TextNode(id: '2-1', text: AttributedText('data 1')),
+              TextNode(id: '2-2', text: AttributedText('data 2')),
+            ],
+            [
+              TextNode(id: '3-1', text: AttributedText('data 3')),
+              TextNode(id: '3-2', text: AttributedText('')),
+            ]
+          ],
+        )),
+      );
+    });
+
+    test('does not parse table with delimiter with mismatch cell count', () {
+      final document = deserializeMarkdownToDocument('''| header 1 | header 2 |
+|---|
+| data 1 | data 2 |''');
+
+      expect(document.nodeCount, 1);
+      expect(document.first, isA<ParagraphNode>());
+      expect(
+        (document.first as ParagraphNode).text.toPlainText(includePlaceholders: false),
+        '''| header 1 | header 2 |
+|---|
+| data 1 | data 2 |''',
+      );
+    });
+  });
+}
+
+/// Parses the given [markdown] string and attempts to extract a table
+/// from the first element.
+///
+/// All subsequent elements are ignored.
+TableBlockNode _parseMarkdownTable(String markdown) {
+  final document = deserializeMarkdownToDocument(markdown);
+
+  expect(document.nodeCount, greaterThanOrEqualTo(1));
+  expect(document.first, isA<TableBlockNode>());
+
+  return document.first as TableBlockNode;
+}
+
+/// Checks whether a [TableBlockNode] has equivalent content to an expected [TableBlockNode].
+///
+/// We cannot use the default equality operator because it would compare
+/// the node ids, which are generated randomly.
+///
+/// This matcher checks that the number of rows and columns are the same,
+/// and that the text and metadata of each cell are the same.
+_TableBlockNodeMatcher _matchesTableContent(
+  TableBlockNode expected,
+) {
+  return _TableBlockNodeMatcher(expected);
+}
+
+class _TableBlockNodeMatcher extends Matcher {
+  const _TableBlockNodeMatcher(this._expected);
+
+  final TableBlockNode _expected;
+
+  @override
+  Description describe(Description description) {
+    return description.add("given TableBlockNode has equivalent content to expected TableBlockNode");
+  }
+
+  @override
+  bool matches(covariant Object target, Map<dynamic, dynamic> matchState) {
+    return _calculateMismatchReason(target, matchState) == null;
+  }
+
+  @override
+  Description describeMismatch(
+    covariant Object target,
+    Description mismatchDescription,
+    Map matchState,
+    bool verbose,
+  ) {
+    final mismatchReason = _calculateMismatchReason(target, matchState);
+    if (mismatchReason != null) {
+      mismatchDescription.add(mismatchReason);
+    }
+    return mismatchDescription;
+  }
+
+  String? _calculateMismatchReason(
+    Object target,
+    Map<dynamic, dynamic> matchState,
+  ) {
+    if (target is! TableBlockNode) {
+      return 'Expected a TableBlockNode, but got ${target.runtimeType}';
+    }
+
+    final messages = <String>[];
+    bool rowCountMismatch = false;
+    bool rowContentMismatch = false;
+
+    if (_expected.rowCount != target.rowCount) {
+      messages.add("expected ${_expected.rowCount} rows but found ${target.rowCount}");
+      rowCountMismatch = true;
+    } else {
+      messages.add("table have the same number of rows");
+    }
+
+    final maxRowCount = max(_expected.rowCount, target.rowCount);
+    final rowComparisons = List.generate(maxRowCount, (index) => ["", "", " "]);
+    for (int i = 0; i < maxRowCount; i += 1) {
+      if (i < _expected.rowCount && i < target.rowCount) {
+        rowComparisons[i][0] = _tableRowToString(_expected.getRow(i));
+        rowComparisons[i][1] = _tableRowToString(target.getRow(i));
+
+        bool columnCountMismatch = _expected.getRow(i).length != target.getRow(i).length;
+        if (columnCountMismatch) {
+          rowComparisons[i][2] = "Column count mismatch";
+          rowContentMismatch = true;
+        } else {
+          rowComparisons[i][2] = "Same number of columns";
+
+          for (int j = 0; j < _expected.getRow(i).length; j++) {
+            final expectedCell = _expected.getRow(i)[j];
+            final targetCell = target.getRow(i)[j];
+
+            if (expectedCell.text != targetCell.text) {
+              rowComparisons[i][2] = "Content mismatch in row $i column $j";
+              rowContentMismatch = true;
+
+              // No need to check further columns in this row.
+              continue;
+            }
+
+            final expectedMetadata = expectedCell.metadata;
+            final targetMetadata = targetCell.metadata;
+
+            if (expectedMetadata.length != targetMetadata.length) {
+              rowComparisons[i][0] = expectedMetadata.entries.map((e) => '${e.key}: ${e.value}').join(', ');
+              rowComparisons[i][1] = targetMetadata.entries.map((e) => '${e.key}: ${e.value}').join(', ');
+              rowComparisons[i][2] = "Metadata length mismatch in row $i column $j";
+              rowContentMismatch = true;
+              continue;
+            }
+
+            for (final key in expectedMetadata.keys) {
+              if (!targetMetadata.containsKey(key)) {
+                rowComparisons[i][2] = "Metadata key '$key' missing in row $i column $j";
+                rowContentMismatch = true;
+                break;
+              }
+              if (expectedMetadata[key] != targetMetadata[key]) {
+                rowComparisons[i][0] = expectedMetadata[key].toString();
+                rowComparisons[i][1] = targetMetadata[key].toString();
+                rowComparisons[i][2] = "Metadata value mismatch for key '$key' in row $i column $j";
+                rowContentMismatch = true;
+                break;
+              }
+            }
+          }
+        }
+      } else if (i < _expected.rowCount) {
+        rowComparisons[i][0] = _tableRowToString(_expected.getRow(i));
+        rowComparisons[i][1] = "NA";
+        rowComparisons[i][2] = "Missing Row";
+      } else if (i < target.rowCount) {
+        rowComparisons[i][0] = "NA";
+        rowComparisons[i][1] = _tableRowToString(target.getRow(i));
+        rowComparisons[i][2] = "Missing Row";
+      }
+    }
+
+    if (rowCountMismatch || rowContentMismatch) {
+      String messagesList = messages.join(", ");
+      messagesList += "\n";
+      messagesList += const TableRenderer().render(rowComparisons, columns: ["Expected", "Actual", "Difference"]);
+      return messagesList;
+    }
+
+    return null;
+  }
+
+  String _tableRowToString(List<TextNode> row) {
+    return '| ${row.map((e) => e.text.toPlainText(includePlaceholders: false)).join(" | ")} |';
+  }
+}