diff --git a/packages/parchment/example/image_demo.dart b/packages/parchment/example/image_demo.dart new file mode 100644 index 00000000..92df188b --- /dev/null +++ b/packages/parchment/example/image_demo.dart @@ -0,0 +1,40 @@ +import 'package:parchment/codecs.dart'; +import 'package:parchment/parchment.dart'; + +void main() { + print('=== Image Support in Parchment Markdown Codec ===\n'); + + // Test 1: Markdown to Parchment (Decoding) + print('1. Decoding Markdown with image to Parchment:'); + final markdown = '![Alt text](https://example.com/image.jpg)'; + print('Input: $markdown'); + + final document = parchmentMarkdown.decode(markdown); + final delta = document.toDelta(); + + final embed = delta.elementAt(0).data as BlockEmbed; + print('Output: Image embed with source: ${embed.data['source']}'); + print(''); + + // Test 2: Parchment to Markdown (Encoding) + print('2. Encoding Parchment with image to Markdown:'); + final imageEmbed = BlockEmbed.image('https://example.com/my-image.png'); + final deltaWithImage = Delta() + ..insert(imageEmbed) + ..insert('\n'); + final documentWithImage = ParchmentDocument.fromDelta(deltaWithImage); + + final encodedMarkdown = parchmentMarkdown.encode(documentWithImage); + print('Output: $encodedMarkdown'); + + // Test 3: Round-trip conversion + print('3. Round-trip conversion:'); + final originalMarkdown = '![Test image](https://example.com/test.jpg)'; + final roundTripDocument = parchmentMarkdown.decode(originalMarkdown); + final backToMarkdown = parchmentMarkdown.encode(roundTripDocument); + + print('Original: $originalMarkdown'); + print('Round-trip: ${backToMarkdown.trim()}'); + print( + 'Success: ${backToMarkdown.contains('![](https://example.com/test.jpg)')}'); +} diff --git a/packages/parchment/lib/src/codecs/markdown.dart b/packages/parchment/lib/src/codecs/markdown.dart index 18e141ae..7de08b2c 100644 --- a/packages/parchment/lib/src/codecs/markdown.dart +++ b/packages/parchment/lib/src/codecs/markdown.dart @@ -5,6 +5,7 @@ import 'package:parchment_delta/parchment_delta.dart'; import '../document.dart'; import '../document/attributes.dart'; import '../document/block.dart'; +import '../document/embeds.dart'; import '../document/leaf.dart'; import '../document/line.dart'; @@ -38,6 +39,7 @@ class _ParchmentMarkdownDecoder extends Converter { ); static final _linkRegExp = RegExp(r'\[(.+?)\]\(([^)]+)\)'); + static final _imageRegExp = RegExp(r'!\[([^\]]*)\]\(([^)]+)\)'); static final _ulRegExp = RegExp(r'^( *)[-*+] +(.*)'); static final _olRegExp = RegExp(r'^( *)\d+[.)] +(.*)'); static final _clRegExp = RegExp(r'^( *)- +\[( |x|X)\] +(.*)'); @@ -64,6 +66,9 @@ class _ParchmentMarkdownDecoder extends Converter { return; } + if (_handleImage(line, delta, style)) { + return; + } if (_handleBlockQuote(line, delta, style)) { return; } @@ -86,6 +91,21 @@ class _ParchmentMarkdownDecoder extends Converter { } } + bool _handleImage(String line, Delta delta, [ParchmentStyle? style]) { + final match = _imageRegExp.matchAsPrefix(line); + if (match != null) { + final imageUrl = match.group(2); + if (imageUrl != null) { + // Create an image block embed + final imageEmbed = BlockEmbed.image(imageUrl); + delta.insert(imageEmbed); + delta.insert('\n'); + return true; + } + } + return false; + } + // Markdown supports headings and blocks within blocks (except for within code) // but not blocks within headers, or ul within bool _handleBlock(String line, Delta delta, [ParchmentStyle? style]) { @@ -409,7 +429,22 @@ class _ParchmentMarkdownEncoder extends Converter { ParchmentAttribute? currentBlockAttribute; void handleLine(LineNode node) { - if (node.hasBlockEmbed) return; + if (node.hasBlockEmbed) { + // Handle image embeds specifically + if (node.children.length == 1 && + node.children.single is EmbedNode && + (node.children.single as EmbedNode).value.type == 'image') { + final embedNode = node.children.single as EmbedNode; + final imageEmbed = embedNode.value; + final source = imageEmbed.data['source'] as String?; + if (source != null) { + lineBuffer.write('![]($source)'); + buffer.write(lineBuffer); + lineBuffer.clear(); + } + } + return; + } for (final attr in node.style.lineAttributes) { if (attr.key == ParchmentAttribute.block.key) { diff --git a/packages/parchment/test/codecs/markdown_test.dart b/packages/parchment/test/codecs/markdown_test.dart index 9af33088..4338aea0 100644 --- a/packages/parchment/test/codecs/markdown_test.dart +++ b/packages/parchment/test/codecs/markdown_test.dart @@ -469,6 +469,72 @@ void main() { expect(delta, exp); }); + test('image embed decode', () { + final markdown = '![alt text](https://example.com/image.jpg)'; + final document = parchmentMarkdown.decode(markdown); + final delta = document.toDelta(); + + expect(delta.length, 2); + final embed = delta.elementAt(0).data; + expect(embed, isA()); + expect((embed as BlockEmbed).type, 'image'); + expect(embed.data['source'], 'https://example.com/image.jpg'); + expect(delta.elementAt(1).data, '\n'); + }); + + test('image embed decode without alt text', () { + final markdown = '![](https://example.com/image.png)'; + final document = parchmentMarkdown.decode(markdown); + final delta = document.toDelta(); + + expect(delta.length, 2); + final embed = delta.elementAt(0).data; + expect(embed, isA()); + expect((embed as BlockEmbed).type, 'image'); + expect(embed.data['source'], 'https://example.com/image.png'); + }); + + test('multiple images', () { + final markdown = '''![First](https://example.com/image1.jpg) +![Second](https://example.com/image2.png)'''; + final document = parchmentMarkdown.decode(markdown); + final delta = document.toDelta(); + + expect(delta.length, 4); + + // First image + final firstEmbed = delta.elementAt(0).data; + expect(firstEmbed, isA()); + expect((firstEmbed as BlockEmbed).type, 'image'); + expect(firstEmbed.data['source'], 'https://example.com/image1.jpg'); + expect(delta.elementAt(1).data, '\n'); + + // Second image + final secondEmbed = delta.elementAt(2).data; + expect(secondEmbed, isA()); + expect((secondEmbed as BlockEmbed).type, 'image'); + expect(secondEmbed.data['source'], 'https://example.com/image2.png'); + expect(delta.elementAt(3).data, '\n'); + }); + + test('image with text around', () { + final markdown = '''Some text before +![Image](https://example.com/image.jpg) +Some text after'''; + final document = parchmentMarkdown.decode(markdown); + final delta = document.toDelta(); + + expect(delta.length, 3); + expect(delta.elementAt(0).data, 'Some text before\n'); + + final embed = delta.elementAt(1).data; + expect(embed, isA()); + expect((embed as BlockEmbed).type, 'image'); + expect(embed.data['source'], 'https://example.com/image.jpg'); + + expect(delta.elementAt(2).data, '\nSome text after\n'); + }); + test('multiple styles', () { final delta = parchmentMarkdown.decode(markdown); final andBack = parchmentMarkdown.encode(delta); @@ -477,6 +543,48 @@ void main() { }); group('ParchmentMarkdownCodec.encode', () { + test('image embed encode', () { + final imageEmbed = BlockEmbed.image('https://example.com/image.jpg'); + final delta = Delta() + ..insert(imageEmbed) + ..insert('\n'); + final document = ParchmentDocument.fromDelta(delta); + + final markdown = parchmentMarkdown.encode(document); + expect(markdown, '![](https://example.com/image.jpg)\n\n'); + }); + + test('multiple images encode', () { + final delta = Delta() + ..insert(BlockEmbed.image('https://example.com/image1.jpg')) + ..insert('\n') + ..insert(BlockEmbed.image('https://example.com/image2.png')) + ..insert('\n'); + final document = ParchmentDocument.fromDelta(delta); + + final markdown = parchmentMarkdown.encode(document); + expect(markdown, + '![](https://example.com/image1.jpg)\n\n![](https://example.com/image2.png)\n\n'); + }); + + test('image round trip conversion', () { + final originalMarkdown = '![](https://example.com/image.jpg)'; + final document = parchmentMarkdown.decode(originalMarkdown); + final backToMarkdown = parchmentMarkdown.encode(document); + + // The output will have extra formatting but should contain the image + expect(backToMarkdown, contains('![](https://example.com/image.jpg)')); + + // Round trip back to ensure data integrity + final roundTripDocument = parchmentMarkdown.decode(backToMarkdown); + final roundTripDelta = roundTripDocument.toDelta(); + + final embed = roundTripDelta.elementAt(0).data; + expect(embed, isA()); + expect((embed as BlockEmbed).type, 'image'); + expect(embed.data['source'], 'https://example.com/image.jpg'); + }); + test('split adjacent paragraphs', () { final delta = Delta()..insert('First line\nSecond line\n'); final result =