import 'dart:convert'; import 'dart:ui'; import 'package:collection/collection.dart'; import '../../../flutter_quill.dart'; import '../../../quill_delta.dart'; import './custom_quill_attributes.dart'; import './utils.dart'; class _AttributeHandler { _AttributeHandler({ this.beforeContent, this.afterContent, }); final void Function( Attribute attribute, Node node, StringSink output, )? beforeContent; final void Function( Attribute attribute, Node node, StringSink output, )? afterContent; } /// Outputs [Embed] element as markdown. typedef EmbedToMarkdown = void Function(Embed embed, StringSink out); extension on Object? { T? asNullable() { final self = this; return self == null ? null : self as T; } } /// Convertor from [Delta] to quill Markdown string. class DeltaToMarkdown extends Converter implements _NodeVisitor { /// DeltaToMarkdown({ Map? customEmbedHandlers, }) { if (customEmbedHandlers != null) { _embedHandlers.addAll(customEmbedHandlers); } } @override String convert(Delta input) { final newDelta = transform(input); final quillDocument = Document.fromDelta(newDelta); final outBuffer = quillDocument.root.accept(this); return outBuffer.toString(); } final Map _blockAttrsHandlers = { Attribute.codeBlock.key: _AttributeHandler( beforeContent: (attribute, node, output) { var infoString = ''; if (node.containsAttr(CodeBlockLanguageAttribute.attrKey)) { infoString = node.getAttrValueOr( CodeBlockLanguageAttribute.attrKey, '', ); } if (infoString.isEmpty) { final linesWithLang = (node as Block).children.where((child) => child.containsAttr(CodeBlockLanguageAttribute.attrKey)); if (linesWithLang.isNotEmpty) { infoString = linesWithLang.first.getAttrValueOr( CodeBlockLanguageAttribute.attrKey, 'or', ); } } output.writeln('```$infoString'); }, afterContent: (attribute, node, output) => output.writeln('```'), ), }; final Map _lineAttrsHandlers = { Attribute.header.key: _AttributeHandler( beforeContent: (attribute, node, output) { output ..write('#' * (attribute.value.asNullable() ?? 1)) ..write(' '); }, ), Attribute.blockQuote.key: _AttributeHandler( beforeContent: (attribute, node, output) => output.write('> '), ), Attribute.list.key: _AttributeHandler( beforeContent: (attribute, node, output) { final indentLevel = node.getAttrValueOr(Attribute.indent.key, 0); final isNumbered = attribute.value == 'ordered'; output ..write((isNumbered ? ' ' : ' ') * indentLevel) ..write('${isNumbered ? '1.' : '-'} '); }, ), }; final Map _textAttrsHandlers = { Attribute.italic.key: _AttributeHandler( beforeContent: (attribute, node, output) { if (node.previous?.containsAttr(attribute.key) != true) { output.write('_'); } }, afterContent: (attribute, node, output) { if (node.next?.containsAttr(attribute.key) != true) { output.write('_'); } }, ), Attribute.bold.key: _AttributeHandler( beforeContent: (attribute, node, output) { if (node.previous?.containsAttr(attribute.key) != true) { output.write('**'); } }, afterContent: (attribute, node, output) { if (node.next?.containsAttr(attribute.key) != true) { output.write('**'); } }, ), Attribute.strikeThrough.key: _AttributeHandler( beforeContent: (attribute, node, output) { if (node.previous?.containsAttr(attribute.key) != true) { output.write('~~'); } }, afterContent: (attribute, node, output) { if (node.next?.containsAttr(attribute.key) != true) { output.write('~~'); } }, ), Attribute.inlineCode.key: _AttributeHandler( beforeContent: (attribute, node, output) { if (node.previous?.containsAttr(attribute.key) != true) { output.write('`'); } }, afterContent: (attribute, node, output) { if (node.next?.containsAttr(attribute.key) != true) { output.write('`'); } }, ), Attribute.link.key: _AttributeHandler( beforeContent: (attribute, node, output) { if (node.previous?.containsAttr(attribute.key, attribute.value) != true) { output.write('['); } }, afterContent: (attribute, node, output) { if (node.next?.containsAttr(attribute.key, attribute.value) != true) { output.write('](${attribute.value.asNullable() ?? ''})'); } }, ), }; final Map _embedHandlers = { BlockEmbed.imageType: (embed, out) => out.write('![](${embed.value.data})'), horizontalRuleType: (embed, out) { // adds new line after it // make --- separated so it doesn't get rendered as header out.writeln('- - -'); }, }; @override StringSink visitRoot(Root root, [StringSink? output]) { final out = output ??= StringBuffer(); for (final container in root.children) { container.accept(this, out); } return out; } @override StringSink visitBlock(Block block, [StringSink? output]) { final out = output ??= StringBuffer(); _handleAttribute(_blockAttrsHandlers, block, output, () { for (final line in block.children) { line.accept(this, out); } }); return out; } @override StringSink visitLine(Line line, [StringSink? output]) { final out = output ??= StringBuffer(); final style = line.style; _handleAttribute(_lineAttrsHandlers, line, output, () { for (final leaf in line.children) { leaf.accept(this, out); } }); if (style.isEmpty || style.values.every((item) => item.scope != AttributeScope.block)) { out.writeln(); } if (style.containsKey(Attribute.list.key) && line.nextLine?.style.containsKey(Attribute.list.key) != true) { out.writeln(); } out.writeln(); return out; } @override StringSink visitText(QuillText text, [StringSink? output]) { final out = output ??= StringBuffer(); final style = text.style; _handleAttribute( _textAttrsHandlers, text, output, () { var content = text.value; if (!(style.containsKey(Attribute.codeBlock.key) || style.containsKey(Attribute.inlineCode.key) || (text.parent?.style.containsKey(Attribute.codeBlock.key) ?? false))) { content = content.replaceAllMapped( RegExp(r'[\\\`\*\_\{\}\[\]\(\)\#\+\-\.\!\>\<]'), (match) { return '\\${match[0]}'; }); } out.write(content); }, sortedAttrsBySpan: true, ); return out; } @override StringSink visitEmbed(Embed embed, [StringSink? output]) { final out = output ??= StringBuffer(); final type = embed.value.type; _embedHandlers[type]!.call(embed, out); return out; } void _handleAttribute( Map handlers, Node node, StringSink output, VoidCallback contentHandler, { bool sortedAttrsBySpan = false, }) { final attrs = sortedAttrsBySpan ? node.attrsSortedByLongestSpan() : node.style.attributes.values.toList(); final handlersToUse = attrs .where((attr) => handlers.containsKey(attr.key)) .map((attr) => MapEntry(attr.key, handlers[attr.key]!)) .toList(); for (final handlerEntry in handlersToUse) { handlerEntry.value.beforeContent?.call( node.style.attributes[handlerEntry.key]!, node, output, ); } contentHandler(); for (final handlerEntry in handlersToUse.reversed) { handlerEntry.value.afterContent?.call( node.style.attributes[handlerEntry.key]!, node, output, ); } } } //// AST with visitor abstract class _NodeVisitor { const _NodeVisitor._(); T visitRoot(Root root, [T? context]); T visitBlock(Block block, [T? context]); T visitLine(Line line, [T? context]); T visitText(QuillText text, [T? context]); T visitEmbed(Embed embed, [T? context]); } extension _NodeX on Node { T accept(_NodeVisitor visitor, [T? context]) { switch (runtimeType) { case Root: return visitor.visitRoot(this as Root, context); case Block: return visitor.visitBlock(this as Block, context); case Line: return visitor.visitLine(this as Line, context); case QuillText: return visitor.visitText(this as QuillText, context); case Embed: return visitor.visitEmbed(this as Embed, context); } throw Exception('Container of type $runtimeType cannot be visited'); } bool containsAttr(String attributeKey, [Object? value]) { if (!style.containsKey(attributeKey)) { return false; } if (value == null) { return true; } return style.attributes[attributeKey]!.value == value; } T getAttrValueOr(String attributeKey, T or) { final attrs = style.attributes; final attrValue = attrs[attributeKey]?.value as T?; return attrValue ?? or; } List> attrsSortedByLongestSpan() { final attrCount = , int>{}; var node = this; // get the first node while (node.previous != null) { node = node.previous!; node.style.attributes.forEach((key, value) { attrCount[value] = (attrCount[value] ?? 0) + 1; }); node = node.next!; } final attrs = style.attributes.values.sorted( (attr1, attr2) => attrCount[attr2]!.compareTo(attrCount[attr1]!)); return attrs; } }