diff --git a/packages/notus/lib/convert.dart b/packages/notus/lib/convert.dart index 853fd4919..041b61d5e 100644 --- a/packages/notus/lib/convert.dart +++ b/packages/notus/lib/convert.dart @@ -5,9 +5,12 @@ /// Provides codecs to convert Notus documents to other formats. library notus.convert; +import 'src/convert/html.dart'; import 'src/convert/markdown.dart'; +export 'src/convert/html.dart'; export 'src/convert/markdown.dart'; /// Markdown codec for Notus documents. const NotusMarkdownCodec notusMarkdown = const NotusMarkdownCodec(); +const NotusHTMLCodec notusHTML = const NotusHTMLCodec(); diff --git a/packages/notus/lib/src/convert/html.dart b/packages/notus/lib/src/convert/html.dart new file mode 100644 index 000000000..a7fd01689 --- /dev/null +++ b/packages/notus/lib/src/convert/html.dart @@ -0,0 +1,767 @@ +// Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; +import 'dart:convert'; + +import 'package:quill_delta/quill_delta.dart'; +import 'package:notus/notus.dart'; +import 'package:html/parser.dart' show parse; +import 'package:html/dom.dart'; + +class NotusHTMLCodec extends Codec { + const NotusHTMLCodec(); + + @override + Converter get decoder => _HTMLNotusDecoder(); + + @override + Converter get encoder => _NotusHTMLEncoder(); +} + +class keys { + static final line = "line"; + static final inline = "inline"; +} + +class deltaKeys { + static const ol = "ol"; + static const ul = "ul"; + static const a = "a"; + static const i = "i"; + static const b = "b"; + static const quote = "quote"; + static const code = "code"; + static const type = "type"; + static const block = "block"; + static const image = "image"; + static const imageSrc = "source"; + static const hr = "hr"; + static const insert = "insert"; + static const attributes = "attributes"; + static const heading = "heading"; + static const embed = "embed"; + static const container = "container"; +} + +class htmlKeys { + static const blockquote = "blockquote"; + static const unorderedList = "ul"; + static const orderedList = "ol"; + static const list = "li"; + static const heading = "h"; + static const h1 = "h1"; + static const h2 = "h2"; + static const h3 = "h3"; + static const anchor = "a"; + static const anchorHref = "href"; + static const bold = "b"; + static const italic = "i"; + static const horizontalRule = "hr"; + static const image = "img"; + static const imageSrc = "src"; + static const br = "br"; + static const preformatted = "pre"; +} + +String htmlTagNameToDeltaAttributeName(String htmlTag) { + switch (htmlTag) { + case htmlKeys.blockquote: + case htmlKeys.unorderedList: + case htmlKeys.orderedList: + case htmlKeys.preformatted: + return deltaKeys.block; + case htmlKeys.h1: + case htmlKeys.h2: + case htmlKeys.h3: + return deltaKeys.heading; + case htmlKeys.anchor: + return deltaKeys.a; + case htmlKeys.bold: + return deltaKeys.b; + case htmlKeys.italic: + return deltaKeys.i; + case htmlKeys.horizontalRule: + case htmlKeys.image: + return deltaKeys.embed; + case htmlKeys.br: + case htmlKeys.list: + default: + return null; + } +} + +class _NotusHTMLEncoder extends Converter { + static final kSimpleBlocks = { + NotusAttribute.bq: htmlKeys.blockquote, + NotusAttribute.ul: htmlKeys.unorderedList, + NotusAttribute.ol: htmlKeys.orderedList, + }; + Map container; + String buildContainer(String key) { + if (container == null || !container.containsKey(key)) { + return ''; + } + final attributes = Map.from(container[key]); + final buffer = StringBuffer(); + int counter = 0; + int length = attributes.length; + buffer.write(" "); + attributes.forEach((key, val) { + if (val == null) { + buffer.write(key); + } else { + buffer.write("$key=\"$val\""); + } + counter++; + if (counter < length) { + buffer.write(" "); + } + }); + return buffer.toString(); + } + + Map getContainer(Map attr) { + if (attr != null && attr.containsKey(deltaKeys.container)) { + return attr[deltaKeys.container]; + } + return null; + } + + @override + String convert(Delta input) { + final iterator = new DeltaIterator(input); + final buffer = new StringBuffer(); + final lineBuffer = new StringBuffer(); + NotusAttribute currentBlockStyle; + NotusStyle currentInlineStyle = new NotusStyle(); + List currentBlockLines = []; + + void _handleBlock(NotusAttribute blockStyle) { + if (currentBlockLines.isEmpty) { + return; // Empty block + } + + if (blockStyle == null) { + buffer.write(currentBlockLines.join('\n')); + } else if (blockStyle == NotusAttribute.bq || + blockStyle == NotusAttribute.code) { + _writeBlockTag(buffer, blockStyle, start: true); + buffer.write(currentBlockLines.join("\n")); + _writeBlockTag(buffer, blockStyle, close: true); + } else { + for (var i = 0; i < currentBlockLines.length; i++) { + var line = currentBlockLines[i]; + if (i == 0) { + _writeBlockTag(buffer, blockStyle, start: true); + buffer.writeln(); + } + buffer.write("<${htmlKeys.list}>"); + buffer.write(line); + buffer.write(""); + buffer.writeln(); + if (i == currentBlockLines.length - 1) { + _writeBlockTag(buffer, blockStyle, close: true); + } + } + } + buffer.writeln(); + } + + void _handleSpan(String text, Map attributes) { + final style = NotusStyle.fromJson(attributes); + currentInlineStyle = + _writeInline(lineBuffer, text, style, currentInlineStyle); + } + + void _handleLine(Map attributes) { + final style = NotusStyle.fromJson(attributes); + final lineBlock = style.get(NotusAttribute.block); + if (lineBlock == currentBlockStyle) { + currentBlockLines.add(_writeLine(lineBuffer.toString(), style)); + } else { + _handleBlock(currentBlockStyle); + currentBlockLines.clear(); + currentBlockLines.add(_writeLine(lineBuffer.toString(), style)); + + currentBlockStyle = lineBlock; + } + lineBuffer.clear(); + } + + String removeZeroWidthSpace(String text) { + return text.replaceAll(String.fromCharCode(8203), ""); + } + + while (iterator.hasNext) { + final op = iterator.next(); + + final lf = op.data.indexOf('\n'); + container = getContainer(op.attributes); + if (lf == -1) { + _handleSpan(removeZeroWidthSpace(op.data), op.attributes); + } else { + StringBuffer span = StringBuffer(); + for (var i = 0; i < op.data.length; i++) { + if (op.data.codeUnitAt(i) == 0x0A) { + if (span.isNotEmpty) { + // Write the span if it's not empty. + _handleSpan(span.toString(), op.attributes); + } + // Close any open inline styles. + _handleSpan('', null); + _handleLine(op.attributes); + span.clear(); + } else { + span.writeCharCode(op.data.codeUnitAt(i)); + } + } + // Remaining span + if (span.isNotEmpty) { + _handleSpan(removeZeroWidthSpace(span.toString()), op.attributes); + } + } + // container = null; + } + _handleBlock(currentBlockStyle); // Close the last block + return buffer.toString(); + } + + String _writeLine(String text, NotusStyle style) { + StringBuffer buffer = new StringBuffer(); + if (style.contains(NotusAttribute.heading)) { + _writeAttribute(buffer, style.get(NotusAttribute.heading)); + } + + // Write the text itself + buffer.write(text); + if (style.contains(NotusAttribute.heading)) { + _writeAttribute(buffer, style.get(NotusAttribute.heading), close: true); + } + return buffer.toString(); + } + + String _trimRight(StringBuffer buffer) { + String text = buffer.toString(); + if (!text.endsWith(' ')) return ''; + final result = text.trimRight(); + buffer.clear(); + buffer.write(result); + return ' ' * (text.length - result.length); + } + + NotusStyle _writeInline(StringBuffer buffer, String text, NotusStyle style, + NotusStyle currentStyle) { + // First close any current styles if needed + for (final value in currentStyle.values.toList().reversed) { + if (value.scope == NotusAttributeScope.line) continue; + if (style.containsSame(value)) continue; + final padding = _trimRight(buffer); + _writeAttribute(buffer, value, close: true); + if (padding.isNotEmpty) buffer.write(padding); + } + // Now open any new styles. + for (final value in style.values) { + if (value.scope == NotusAttributeScope.line) continue; + if (currentStyle.containsSame(value)) continue; + final originalText = text; + text = text.trimLeft(); + final padding = ' ' * (originalText.length - text.length); + if (padding.isNotEmpty) buffer.write(padding); + _writeAttribute(buffer, value); + } + // Write the text itself + buffer.write(text); + return style; + } + + void _writeAttribute(StringBuffer buffer, NotusAttribute attribute, + {bool close: false}) { + if (attribute == NotusAttribute.bold) { + _writeBoldTag(buffer, close: close); + } else if (attribute == NotusAttribute.italic) { + _writeItalicTag(buffer, close: close); + } else if (attribute.key == NotusAttribute.link.key) { + _writeLinkTag(buffer, attribute, close: close); + } else if (attribute.key == NotusAttribute.heading.key) { + _writeHeadingTag(buffer, attribute, close: close); + } else if (attribute.key == NotusAttribute.block.key) { + _writeBlockTag(buffer, attribute, close: close); + } else if (attribute.key == NotusAttribute.embed.key) { + _writeEmbedTag(buffer, attribute, close: close); + } else if (attribute.key == NotusAttribute.container.key) { + // do nothing + } else { + throw new ArgumentError('Cannot handle $attribute'); + } + } + + void _writeEmbedTag( + StringBuffer buffer, NotusAttribute> embed, + {bool close: false}) { + if (embed.value[deltaKeys.type] == deltaKeys.image) { + if (close) { + return; + } + buffer.write( + "<${htmlKeys.image} ${htmlKeys.imageSrc}=\"${embed.value["source"]}\"${buildContainer(deltaKeys.embed)} />"); + } else if (embed.value[deltaKeys.type] == deltaKeys.hr) { + if (close) { + return; + } + buffer.write( + "<${htmlKeys.horizontalRule}${buildContainer(deltaKeys.embed)} />"); + } + } + + void _writeBoldTag(StringBuffer buffer, {bool close: false}) { + if (close) { + buffer.write(''); + } else { + buffer.write("<${htmlKeys.bold}${buildContainer(deltaKeys.b)}>"); + } + } + + void _writeItalicTag(StringBuffer buffer, {bool close: false}) { + if (close) { + buffer.write(''); + } else { + buffer.write("<${htmlKeys.italic}${buildContainer(deltaKeys.i)}>"); + } + } + + void _writeLinkTag(StringBuffer buffer, NotusAttribute link, + {bool close: false}) { + if (close) { + buffer.write(''); + } else { + buffer.write( + '<${htmlKeys.anchor} ${htmlKeys.anchorHref}=\"${link.value}\"${buildContainer(deltaKeys.a)}>'); + } + } + + void _writeHeadingTag(StringBuffer buffer, NotusAttribute heading, + {bool close: false}) { + var level = heading.value; + if (close) { + buffer.write(''); + } else { + buffer.write( + '<${htmlKeys.heading}$level${buildContainer(deltaKeys.heading)}>'); + } + } + + void _writeBlockTag(StringBuffer buffer, NotusAttribute block, + {bool close: false, bool start: false}) { + if (block == NotusAttribute.code) { + if (start) { + buffer.write('<${htmlKeys.preformatted}${buildContainer(block.key)}>'); + } else if (close) { + buffer.write(''); + } + } else { + final tag = kSimpleBlocks[block]; + if (start) { + buffer.write("<${tag}${buildContainer(block.key)}>"); + } else if (close) { + buffer.write(""); + } else {} + } + } +} + +var _allowedHTMLTag = Set.from([ + htmlKeys.anchor, + htmlKeys.bold, + htmlKeys.unorderedList, + htmlKeys.orderedList, + htmlKeys.list, + htmlKeys.blockquote, + htmlKeys.horizontalRule, + htmlKeys.italic, + htmlKeys.h1, + htmlKeys.h2, + htmlKeys.h3, + htmlKeys.image, + htmlKeys.preformatted, +]); + +void setDeltaAllowedTagForHTMLDecoder(Set tagList) { + _allowedHTMLTag = tagList; +} + +bool isAllowedHTML(Element elem) { + if (elem.localName == htmlKeys.br && elem.children.isEmpty) { + return true; + } + Queue queue = Queue(); + queue.add(elem); + while (queue.isNotEmpty) { + Element target = queue.removeFirst(); + if (!_allowedHTMLTag.contains(target.localName)) { + return false; + } + queue.addAll(target.children); + } + return true; +} + +bool isInlineAttribute(String tag) { + if (tag == htmlKeys.anchor || + tag == htmlKeys.bold || + tag == htmlKeys.italic || + tag == htmlKeys.horizontalRule || + tag == htmlKeys.image) { + return true; + } + return false; +} + +class _HTMLNotusDecoder extends Converter { + Map> toDeltaAttribute(Queue elemStack) { + var deltaAttributeInline = Map(); + var deltaAttributeLine = Map(); + for (final elem in elemStack) { + switch (elem.localName) { + case htmlKeys.image: + deltaAttributeInline[deltaKeys.embed] = { + deltaKeys.type: deltaKeys.image, + deltaKeys.imageSrc: elem.attributes[htmlKeys.imageSrc], + }; + break; + case htmlKeys.preformatted: + deltaAttributeLine[deltaKeys.block] = deltaKeys.code; + break; + case htmlKeys.blockquote: + deltaAttributeLine[deltaKeys.block] = deltaKeys.quote; + break; + case htmlKeys.horizontalRule: + deltaAttributeInline[deltaKeys.embed] = { + deltaKeys.type: deltaKeys.hr + }; + break; + case htmlKeys.list: + if (elem.parent.localName == htmlKeys.orderedList) { + deltaAttributeLine[deltaKeys.block] = deltaKeys.ol; + } else if (elem.parent.localName == htmlKeys.unorderedList) { + deltaAttributeLine[deltaKeys.block] = deltaKeys.ul; + } + break; + case htmlKeys.orderedList: + case htmlKeys.unorderedList: + break; + case htmlKeys.h1: + case htmlKeys.h2: + case htmlKeys.h3: + deltaAttributeLine[deltaKeys.heading] = + (int.parse(elem.localName[1])); + break; + case htmlKeys.anchor: + deltaAttributeInline[deltaKeys.a] = + elem.attributes[htmlKeys.anchorHref]; + break; + case htmlKeys.bold: + case htmlKeys.italic: + deltaAttributeInline[elem.localName] = true; + break; + case htmlKeys.br: + break; + default: + throw Exception("${elem.localName} not allowed"); + } + final attr = Map.from(elem.attributes); + if (elem.localName == htmlKeys.anchor) { + attr.remove(htmlKeys.anchorHref); + } + if (elem.localName == htmlKeys.image) { + attr.remove(htmlKeys.imageSrc); + } + final deltaKeyForContainer = + htmlTagNameToDeltaAttributeName(elem.localName); + if (deltaKeyForContainer != null && attr.isNotEmpty) { + addContainerAttribute(attr, key, attrMap) { + if (attrMap.containsKey(deltaKeys.container)) { + attrMap[deltaKeys.container] + .addAll(Map.from({key: attr})); + } else { + attrMap[deltaKeys.container] = + Map.from({key: attr}); + } + } + + mapEmptyToNullForEmptyHtmlDataAttribute(attr) => + attr.map((key, val) => MapEntry( + key, val is String && val.isEmpty ? null : val)); + + addContainerAttribute( + mapEmptyToNullForEmptyHtmlDataAttribute(attr), + deltaKeyForContainer, + isInlineAttribute(elem.localName) + ? deltaAttributeInline + : deltaAttributeLine); + } + } + return Map>.from({ + keys.inline: deltaAttributeInline, + keys.line: deltaAttributeLine, + }); + } + + List> toDeltaFormatList(Element element) { + final deltaFormatList = List(); + void insert(idx, String text, elemStack) { + Map> attrMap = toDeltaAttribute(elemStack); + Map attrLine = attrMap[keys.line]; + Map attrInline = attrMap[keys.inline]; + if (text.isEmpty && attrInline.isEmpty && attrLine.isEmpty) return; + final int originalLength = deltaFormatList.length; + int shiftIdx() => idx + deltaFormatList.length - originalLength; + void insertText(txt) { + if (txt.isNotEmpty) { + if (attrInline.isEmpty) { + deltaFormatList.insert( + shiftIdx(), + {deltaKeys.insert: txt}, + ); + } else { + deltaFormatList.insert(shiftIdx(), { + deltaKeys.insert: txt, + deltaKeys.attributes: attrInline, + }); + } + } else if (attrInline.containsKey("embed")) { + deltaFormatList.insert(shiftIdx(), { + deltaKeys.insert: String.fromCharCode(8203), + deltaKeys.attributes: attrInline, + }); + } + if (attrLine.isNotEmpty && + (txt.isNotEmpty || attrInline.containsKey("embed"))) { + deltaFormatList.insert(shiftIdx(), { + deltaKeys.insert: "\n", + deltaKeys.attributes: attrLine, + }); + } + } + + if (attrLine.containsKey("block")) { + for (final lineText in text.split("\n")) { + insertText(lineText); + } + } else { + insertText(text); + } + } + + final elemStack = Queue.from([element]); + if (element.children.isEmpty) { + if (element.localName == htmlKeys.br) { + insert(0, "\n", Queue()); + } else { + insert(0, element.text, elemStack); + } + return List>.from(deltaFormatList); + } + deltaFormatList.add(element); + Map> map = {element: elemStack}; + while (deltaFormatList.map((e) => e is Element).contains(true)) { + for (int i = 0; i < deltaFormatList.length; i++) { + final val = deltaFormatList[i]; + if ((val is Element) == false) continue; + deltaFormatList.removeAt(i); + Element elem = val; + final currentElemStack = map[elem]; + int cursor = 0; + final htmlString = elem.innerHtml; + final int originalLength = deltaFormatList.length; + int shiftIdx() => deltaFormatList.length - originalLength; + for (var j = 0; j < elem.children.length; j++) { + Element child = elem.children[j]; + int tagIdx = htmlString.indexOf(child.outerHtml, cursor); + var intervalText = htmlString.substring(cursor, tagIdx); + cursor = tagIdx + child.outerHtml.length; + if ((elem.localName != htmlKeys.orderedList && + elem.localName != htmlKeys.unorderedList) || + intervalText != '\n') { + insert(i + shiftIdx(), intervalText, currentElemStack); + } + final Queue newElemStack = Queue.from(currentElemStack); + newElemStack.addLast(child); + if (child.children.isNotEmpty) { + deltaFormatList.insert(i + shiftIdx(), child); + map[child] = newElemStack; + } else { + insert(i + shiftIdx(), child.text, newElemStack); + } + } + final lastInvervalText = + htmlString.substring(cursor, htmlString.length); + if ((elem.localName != htmlKeys.orderedList && + elem.localName != htmlKeys.unorderedList) || + lastInvervalText != '\n') { + insert(i + shiftIdx(), lastInvervalText, currentElemStack); + } + break; + } + } + return List>.from(deltaFormatList); + } + + Element getRootHTML(inputHTML) { + final parsedHTML = parse(inputHTML); + try { + return parsedHTML.children[0].children[1]; + } on RangeError catch (e) { + print(e); + return null; + } + } + + @override + Delta convert(String inputHTML) { + Element rootHTML = getRootHTML(inputHTML); + if (rootHTML == null || !rootHTML.hasContent()) { + return Delta()..insert("\n"); + } + String htmlString = rootHTML.innerHtml; + final deltaFormatList = List>(); + void addPlainTextToDeltaList(text) { + if (text.isNotEmpty && text != String.fromCharCode(8203)) { + if (0 < deltaFormatList.length && + deltaFormatList[deltaFormatList.length - 1][deltaKeys.attributes] == + null) { + deltaFormatList[deltaFormatList.length - 1][deltaKeys.insert] += text; + } else { + deltaFormatList.add({ + deltaKeys.insert: text, + deltaKeys.attributes: null, + }); + } + } + } + + int cursor = 0; + for (Element firstLayerTag in rootHTML.children) { + int tagIdx = htmlString.indexOf(firstLayerTag.outerHtml, cursor); + final invervalText = htmlString.substring(cursor, tagIdx); + addPlainTextToDeltaList(invervalText); + cursor = tagIdx + firstLayerTag.outerHtml.length; + if (isAllowedHTML(firstLayerTag)) { + deltaFormatList.addAll(toDeltaFormatList(firstLayerTag)); + } else { + addPlainTextToDeltaList(firstLayerTag.outerHtml); + } + } + final lastInvervalText = htmlString.substring(cursor, htmlString.length); + addPlainTextToDeltaList(lastInvervalText); + + removeRedundantNewLine(deltaList) { + for (int i = 1; i < deltaList.length; i++) { + Map prev = deltaList[i - 1]; + Map current = deltaList[i]; + String text = current[deltaKeys.insert]; + if (text.startsWith("\n")) { + Map attr = prev[deltaKeys.attributes]; + if (attr != null && + (attr.containsKey("block") || attr.containsKey("heading"))) { + if (text == "\n") { + deltaList.remove(current); + } else { + current[deltaKeys.insert] = text.replaceFirst("\n", ""); + } + } + } + } + } + + avoidConcatinatingPlainTextAndLineAttributes(deltaList) { + bool isIncludeLineAttributes(Map jsonDelta) { + if (!jsonDelta.containsKey(deltaKeys.attributes)) { + return false; + } + Map attr = jsonDelta[deltaKeys.attributes]; + if (attr == null) { + return false; + } + if (attr.containsKey(deltaKeys.heading) || + attr.containsKey(deltaKeys.quote) || + attr.containsKey(deltaKeys.ol) || + attr.containsKey(deltaKeys.ul)) { + return true; + } + return false; + } + + for (int i = 1; i < deltaList.length - 1; i++) { + Map next = deltaList[i + 1]; + Map current = deltaList[i]; + Map prev = deltaList[i - 1]; + if (isIncludeLineAttributes(next) && + !prev[deltaKeys.insert].endsWith('\n')) { + current[deltaKeys.insert] = '\n' + current[deltaKeys.insert]; + } + } + } + + ensureEndWithNewLine(deltaList) { + int lastIndex = deltaList.length - 1; + String lastText = deltaList[lastIndex][deltaKeys.insert]; + if (!lastText.endsWith("\n")) { + if (deltaList[lastIndex][deltaKeys.attributes] != null) { + deltaList.add({deltaKeys.insert: "\n"}); + } else { + deltaList[lastIndex][deltaKeys.insert] = lastText + "\n"; + } + } + } + + removeZeroWidthSpaceFromNonEmbed(deltaList) { + for (Map deltaFormat in deltaList) { + Map attr = deltaFormat[deltaKeys.attributes]; + if (attr != null && attr.containsKey(deltaKeys.embed)) { + continue; + } + String text = deltaFormat[deltaKeys.insert]; + text = text.replaceAll(String.fromCharCode(8203), ""); + deltaFormat[deltaKeys.insert] = text; + } + } + + insertNewlineAfterConsecutiveAnchorTagWithImage(deltaList) { + for (int i = 0; i < deltaList.length; i++) { + Map prev = (i == 0) ? null : deltaList[i - 1]; + Map next = + (i == deltaList.length - 1) ? null : deltaList[i + 1]; + Map current = deltaList[i]; + Map nextAttr = + (i == deltaList.length - 1) ? null : next[deltaKeys.attributes]; + Map currentAttr = current[deltaKeys.attributes]; + Map prevAttr = + (i == 0) ? null : prev[deltaKeys.attributes]; + if (currentAttr == null || + !currentAttr.containsKey(deltaKeys.a) || + !currentAttr.containsKey(deltaKeys.embed) || + currentAttr[deltaKeys.embed][deltaKeys.type] != deltaKeys.image) { + continue; + } + if (nextAttr != null && nextAttr.containsKey(deltaKeys.a)) { + current[deltaKeys.insert] = current[deltaKeys.insert] + "\n"; + } + if (prevAttr != null && prevAttr.containsKey(deltaKeys.a)) { + current[deltaKeys.insert] = current[deltaKeys.insert] + "\n"; + prev[deltaKeys.insert] = prev[deltaKeys.insert] + "\n"; + } + } + } + + removeRedundantNewLine(deltaFormatList); + avoidConcatinatingPlainTextAndLineAttributes(deltaFormatList); + removeZeroWidthSpaceFromNonEmbed(deltaFormatList); + insertNewlineAfterConsecutiveAnchorTagWithImage(deltaFormatList); + ensureEndWithNewLine(deltaFormatList); + + Delta delta = Delta.fromJson(deltaFormatList); + return delta; + } +} diff --git a/packages/notus/lib/src/document/attributes.dart b/packages/notus/lib/src/document/attributes.dart index 57197488e..f19b9be99 100644 --- a/packages/notus/lib/src/document/attributes.dart +++ b/packages/notus/lib/src/document/attributes.dart @@ -77,6 +77,7 @@ class NotusAttribute implements NotusAttributeBuilder { NotusAttribute.heading.key: NotusAttribute.heading, NotusAttribute.block.key: NotusAttribute.block, NotusAttribute.embed.key: NotusAttribute.embed, + NotusAttribute.container.key: NotusAttribute.container, }; // Inline attributes @@ -122,6 +123,9 @@ class NotusAttribute implements NotusAttributeBuilder { /// Embed style attribute. static const embed = const EmbedAttributeBuilder._(); + /// Container attribute + static const container = const ContainerAttributeBuilder._(); + factory NotusAttribute._fromKeyValue(String key, T value) { if (!_registry.containsKey(key)) throw new ArgumentError.value( @@ -449,3 +453,49 @@ class EmbedAttribute extends NotusAttribute> { return hashObjects(objects); } } + +class ContainerAttributeBuilder + extends NotusAttributeBuilder> { + const ContainerAttributeBuilder._() + : super._(ContainerAttribute._kContainer, NotusAttributeScope.inline); + + @override + NotusAttribute> get unset => ContainerAttribute._(null); + + NotusAttribute> withValue(Map value) => + ContainerAttribute._(value); +} + +class ContainerAttribute extends NotusAttribute> { + static const _kValueEquality = const MapEquality(); + static const _kContainer = 'container'; + + ContainerAttribute._(Map value) + : super._(_kContainer, NotusAttributeScope.inline, value); + + @override + NotusAttribute> get unset => ContainerAttribute._(null); + + @override + bool operator ==(other) { + if (identical(this, other)) return true; + if (other is! ContainerAttribute) return false; + ContainerAttribute typedOther = other; + return key == typedOther.key && + scope == typedOther.scope && + _kValueEquality.equals(value, typedOther.value); + } + + @override + int get hashCode { + final objects = [key, scope]; + if (value != null) { + final valueHashes = + value.entries.map((entry) => hash2(entry.key, entry.value)); + objects.addAll(valueHashes); + } else { + objects.add(value); + } + return hashObjects(objects); + } +} diff --git a/packages/notus/test/convert/html_test.dart b/packages/notus/test/convert/html_test.dart new file mode 100644 index 000000000..476763083 --- /dev/null +++ b/packages/notus/test/convert/html_test.dart @@ -0,0 +1,709 @@ +// Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:quill_delta/quill_delta.dart'; +import 'package:notus/notus.dart'; +import 'package:notus/convert.dart'; + +void main() { + group('$NotusHTMLCodec.decode', () { + test('decode Normal text', () { + final delta = Delta()..insert('Some text\n'); + final html = "Some text\n"; + final result = notusHTML.decode(html); + expect(result, delta); + }); + + test('decode unsupported HTML tag as normal text', () { + final expected = Delta() + ..insert('piyo') + ..insert('\n'); + final html = 'piyo\n'; + final result = notusHTML.decode(html); + expect(result, expected); + }); + + test('decode italic with container attribute', () { + final i = NotusAttribute.italic.toJson(); + final ic = Map.from(i); + ic.addAll(NotusAttribute.container.withValue({ + "i": {"foo": "bar"} + }).toJson()); + final delta = Delta()..insert('txt', ic)..insert('\n'); + final html = 'txt\n'; + final result = notusHTML.decode(html); + expect(result.toString(), delta.toString()); + }); + test('decode bold with container attribute', () { + final b = NotusAttribute.bold.toJson(); + final bc = Map.from(b); + bc.addAll(NotusAttribute.container.withValue({ + "b": {"foo": "bar"} + }).toJson()); + final delta = Delta()..insert('txt', bc)..insert('\n'); + final html = 'txt\n'; + final result = notusHTML.decode(html); + expect(result.toString(), delta.toString()); + }); + test('decode multiple container attribute', () { + final b = NotusAttribute.bold.toJson(); + final bc = Map.from(b); + bc.addAll(NotusAttribute.container.withValue({ + "b": {"foo": "bar", "hidden": null} + }).toJson()); + final delta = Delta()..insert('txt', bc)..insert('\n'); + final html = '\n'; + final result = notusHTML.decode(html); + expect(result.toString(), delta.toString()); + }); + + test('empty text', () { + final delta = Delta()..insert('\n'); + final html = ""; + final result = notusHTML.decode(html); + expect(result, delta); + }); + + test('decode Bold tag', () { + final b = NotusAttribute.bold.toJson(); + final delta = Delta()..insert('foo', b)..insert('\n'); + final html = "foo"; + final result = notusHTML.decode(html); + expect(result, delta); + }); + + test('removeZeroWidthSpaceFromNonEmbed', () { + final b = NotusAttribute.bold.toJson(); + final delta = Delta()..insert('foo', b)..insert('\n'); + final html = "${String.fromCharCode(8203)}foo"; + final result = notusHTML.decode(html); + expect(result, delta); + }); + + test('decode intersecting inline ', () { + final b = NotusAttribute.bold.toJson(); + final i = NotusAttribute.italic.toJson(); + final bi = new Map.from(b); + bi.addAll(i); + final delta = new Delta() + ..insert('This') + ..insert('house', b) + ..insert('is a', bi) + ..insert('circus', b) + ..insert('desu') + ..insert('\n'); + final html = "Thishouseis acircusdesu\n"; + final result = notusHTML.decode(html); + expect(result, delta); + }); + + test('decode a tag', () { + final l = NotusAttribute.link.fromString('http://foo.com'); + final delta = new Delta()..insert('a tag', l.toJson())..insert("\n"); + final html = 'a tag\n'; + final result = notusHTML.decode(html); + expect(result, delta); + }); + + test('decode br tag', () { + final delta = new Delta()..insert('\n')..insert("\n"); + final html = '
\n'; + final result = notusHTML.decode(html); + expect(result, delta); + }); + test('decode nested br tag ', () { + final delta = new Delta()..insert('a
')..insert("\n"); + final html = 'a
\n'; + final result = notusHTML.decode(html); + expect(result, delta); + }); + + test('decode plain text + inlined line attributes ', () { + final doc = + r'[{"insert":"foo"},{"insert":"\nhead text"},{"insert":"\n","attributes":{"heading":1}}]'; + final delta = Delta.fromJson(json.decode(doc)); + final html = 'foo

head text

\n'; + final result = notusHTML.decode(html); + expect(result, delta); + }); + + test('decode consecutive a tag with image', () { + final f = NotusAttribute.link.fromString('http://bar.com'); + final delta = Delta.fromJson([ + { + "insert": String.fromCharCode(8203) + '\n', + "attributes": { + "a": "http://foo.com", + "embed": {"type": "image", "source": "http://foo.jpg"}, + } + }, + ]) + ..insert('a tag', f.toJson()) + ..insert('\n'); + final html = + 'a tag\n'; + final result = notusHTML.decode(html); + expect(result.toString(), delta.toString()); + }); + + test('decode image', () { + final delta = Delta.fromJson([ + { + "insert": String.fromCharCode(8203), + "attributes": { + "embed": {"type": "image", "source": "http://img.jpg"}, + } + } + ]) + ..insert('\n'); + final html = '\n'; + final result = notusHTML.decode(html); + + expect(result.toString(), delta.toString()); + }); + test('decode end with new line policy', () { + final doc = r'[{"insert":"foo\n"}]'; + final delta = Delta.fromJson(json.decode(doc)); + final html = 'foo'; + final result = notusHTML.decode(html); + expect(result, delta); + }); + test('decode heading styles', () { + runFor(NotusAttribute attribute, String source, String html) { + final delta = new Delta() + ..insert(source) + ..insert('\n', attribute.toJson()); + final result = notusHTML.decode(html); + expect(result, delta); + } + + runFor(NotusAttribute.h1, 'Title', '

Title

\n'); + runFor(NotusAttribute.h2, 'Title', '

Title

\n'); + runFor(NotusAttribute.h3, 'Title', '

Title

\n'); + }); + + test('decode heading styles with container attribute', () { + runFor(NotusAttribute attribute, String source, String html) { + final attr = attribute.toJson(); + attr.addAll(NotusAttribute.container.withValue({ + "heading": {"foo": "bar"} + }).toJson()); + final delta = new Delta()..insert(source)..insert('\n', attr); + final result = notusHTML.decode(html); + expect(result.toString(), delta.toString()); + } + + runFor(NotusAttribute.h1, 'Title', '

Title

\n'); + runFor(NotusAttribute.h2, 'Title', '

Title

\n'); + runFor(NotusAttribute.h3, 'Title', '

Title

\n'); + }); + + test('decode singe block with container: quote', () { + runFor(NotusAttribute attribute, String source, String html) { + var attr = attribute.toJson(); + attr.addAll(NotusAttribute.container.withValue({ + "block": {"foo": "bar"} + }).toJson()); + final delta = Delta()..insert(source)..insert('\n', attr); + final result = notusHTML.decode(html); + expect(result.toString(), delta.toString()); + } + + runFor(NotusAttribute.bq, 'item', + '
\nitem\n
\n'); + }); + test('decode singe block with container: list', () { + runFor(NotusAttribute attribute, String source, String html) { + var attr = NotusAttribute.container.withValue({ + "block": {"foo": "bar"} + }).toJson(); + attr.addAll(attribute.toJson()); + final delta = Delta()..insert(source)..insert('\n', attr); + final result = notusHTML.decode(html); + expect(result.toString(), delta.toString()); + } + + runFor( + NotusAttribute.ul, 'item', '
    \n
  • item
  • \n
\n'); + runFor( + NotusAttribute.ol, 'item', '
    \n
  1. item
  2. \n
\n'); + }); + + test('decode singe block', () { + runFor(NotusAttribute attribute, String source, String html) { + final delta = new Delta() + ..insert(source) + ..insert('\n', attribute.toJson()); + final result = notusHTML.decode(html); + expect(result, delta); + } + + runFor(NotusAttribute.ul, 'item', '
    \n
  • item
  • \n
\n'); + runFor(NotusAttribute.ol, 'item', '
    \n
  1. item
  2. \n
\n'); + runFor(NotusAttribute.bq, 'item', '
\nitem\n
\n'); + runFor(NotusAttribute.ul, 'item', '
  • item
\n'); + }); + + test('decode multi line block', () { + runFor(NotusAttribute attribute, String source, String html) { + final delta = Delta() + ..insert(source) + ..insert('\n', attribute.toJson()) + ..insert(source) + ..insert('\n', attribute.toJson()); + final result = notusHTML.decode(html); + expect(result, delta); + } + + runFor(NotusAttribute.ul, 'item', '
    \n
  • item\nitem
  • \n
\n'); + runFor(NotusAttribute.ol, 'item', '
    \n
  1. item\nitem
  2. \n
\n'); + runFor(NotusAttribute.bq, 'item', + '
\nitem\nitem\n
\n'); + runFor(NotusAttribute.code, 'item', '
\nitem\nitem\n
\n'); + }); + test('decode: img tag inside a tag', () { + final expected = Delta.fromJson([ + { + "insert": String.fromCharCode(8203), + "attributes": { + "a": "https://foo.com", + "embed": {"type": "image", "source": "http://img.jpg"}, + } + }, + ]) + ..insert('\n'); + + var html = '\n'; + final result = notusHTML.decode(html); + expect(result.toString(), expected.toString()); + }); + + test('decode: img tag + link text inside a tag', () { + final expected = Delta.fromJson([ + { + "insert": String.fromCharCode(8203) + '\n', + "attributes": { + "a": "https://foo.com", + "embed": {"type": "image", "source": "http://img.jpg"}, + } + }, + ]) + ..insert( + "bar", NotusAttribute.link.fromString('https://foo.com').toJson()) + ..insert('\n'); + + var html = + 'bar\n'; + final result = notusHTML.decode(html); + expect(result.toString(), expected.toString()); + }); + + test('decode complex intersecting inline ', () { + final b = NotusAttribute.bold.toJson(); + final i = NotusAttribute.italic.toJson(); + final bi = new Map.from(b); + final bia = new Map.from(b); + final biaimage = new Map.from(b); + final l = NotusAttribute.link.fromString('https://github.com').toJson(); + bi.addAll(i); + bia.addAll(i); + bia.addAll(l); + biaimage.addAll(i); + biaimage.addAll(l); + biaimage.addAll({ + "embed": {"type": "image", "source": "http://img.jpg"} + }); + final ki = "insert"; + final ka = "attributes"; + final delta = Delta.fromJson([ + {ki: "c", ka: null}, + {ki: "d", ka: b}, + {ki: "e", ka: bi}, + {ki: "f\n", ka: bia}, + {ki: String.fromCharCode(8203) + "\n", ka: biaimage}, + {ki: "g", ka: bi}, + {ki: "h", ka: b}, + {ki: "k\n", ka: null}, + ]); + final html = + 'cdefghk\n'; + final result = notusHTML.decode(html); + expect(result.toString(), delta.toString()); + }); + test('decode multiple styles', () { + final result = notusHTML.decode(expectedHTML); + expect(result.toString(), delta.toString()); + }); + test('decode hr with container attribute', () { + runFor(String html) { + final expected = Delta.fromJson([ + { + "insert": String.fromCharCode(8203), + "attributes": { + "embed": {"type": "hr"}, + "container": { + "embed": {"foo": "bar"} + } + }, + }, + ]) + ..insert('\n'); + final delta = notusHTML.decode(html); + expect(delta.toString(), expected.toString()); + } + + runFor('
\n'); + }); + }); + + group('$NotusHTMLCodec.encode', () { + test('encode split adjacent paragraphs', () { + final delta = Delta()..insert('First line\nSecond line\n'); + final result = notusHTML.encode(delta); + expect(result, 'First line\nSecond line\n'); + }); + + test('encode italic with container attribute', () { + final i = NotusAttribute.italic.toJson(); + final ic = Map.from(i); + ic.addAll(NotusAttribute.container.withValue({ + "i": {"foo": "bar"} + }).toJson()); + final delta = Delta()..insert('txt', ic)..insert('\n'); + final expected = 'txt\n'; + final result = notusHTML.encode(delta); + expect(result.toString(), expected.toString()); + }); + test('encode bold with container attribute', () { + final b = NotusAttribute.bold.toJson(); + final bc = Map.from(b); + bc.addAll(NotusAttribute.container.withValue({ + "b": {"foo": "bar"} + }).toJson()); + final delta = Delta()..insert('txt', bc)..insert('\n'); + final expected = 'txt\n'; + final result = notusHTML.encode(delta); + expect(result.toString(), expected.toString()); + }); + test('encode bold with multiple container attribute', () { + final b = NotusAttribute.bold.toJson(); + final bc = Map.from(b); + bc.addAll(NotusAttribute.container.withValue({ + "b": {"foo": "bar", "hidden": null} + }).toJson()); + final delta = Delta()..insert('txt', bc)..insert('\n'); + final expected = '\n'; + final result = notusHTML.encode(delta); + expect(result.toString(), expected.toString()); + }); + + test('encode bold italic', () { + runFor(NotusAttribute attribute, String expected) { + final delta = new Delta() + ..insert('This ') + ..insert('house', attribute.toJson()) + ..insert(' is a ') + ..insert('circus', attribute.toJson()) + ..insert('\n'); + + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor(NotusAttribute.bold, 'This house is a circus\n'); + runFor(NotusAttribute.italic, 'This house is a circus\n'); + }); + + test('encode intersecting inline styles', () { + final b = NotusAttribute.bold.toJson(); + final i = NotusAttribute.italic.toJson(); + final bi = new Map.from(b); + bi.addAll(i); + + final delta = new Delta() + ..insert('This ') + ..insert('house', b) + ..insert(' is a ', bi) + ..insert('circus', b) + ..insert('\n'); + + final result = notusHTML.encode(delta); + expect(result, 'This house is a circus\n'); + }); + + test('encode intersecting inline styles 2', () { + final b = NotusAttribute.bold.toJson(); + final i = NotusAttribute.italic.toJson(); + final bi = Map.from(b); + bi.addAll(i); + + final delta = Delta()..insert('e', bi)..insert('\n'); + + final result = notusHTML.encode(delta); + expect(result, 'e\n'); + }); + + test('encode intersecting inline styles 3', () { + final b = NotusAttribute.bold.toJson(); + final i = NotusAttribute.italic.toJson(); + final bia = Map.from(b); + final a = NotusAttribute.link.fromString('https://foo.com').toJson(); + bia.addAll(i); + bia.addAll(a); + + final delta = Delta()..insert('e', bia)..insert('\n'); + + final result = notusHTML.encode(delta); + expect(result, 'e\n'); + }); + + test('encode img tag inside a tag', () { + final delta = Delta.fromJson([ + { + "insert": String.fromCharCode(8203), + "attributes": { + "a": "https://foo.com", + "embed": {"type": "image", "source": "http://img.jpg"}, + } + }, + ]) + ..insert('\n'); + + final result = notusHTML.encode(delta); + var expected = + '\n'; + expect(result, expected); + }); + + test('encode img tag + link text inside a tag', () { + final delta = Delta.fromJson([ + { + "insert": String.fromCharCode(8203), + "attributes": { + "a": "https://foo.com", + "embed": {"type": "image", "source": "http://img.jpg"}, + } + }, + ]) + ..insert( + "bar", NotusAttribute.link.fromString('https://foo.com').toJson()) + ..insert('\n'); + + final result = notusHTML.encode(delta); + var expected = + 'bar\n'; + expect(result, expected); + }); + + test('encode normalize inline styles', () { + final b = NotusAttribute.bold.toJson(); + final i = NotusAttribute.italic.toJson(); + final delta = new Delta() + ..insert('This') + ..insert(' house ', b) + ..insert('is a') + ..insert(' circus ', i) + ..insert('\n'); + + final result = notusHTML.encode(delta); + expect(result, 'This house is a circus \n'); + }); + + test('encode links', () { + final b = NotusAttribute.bold.toJson(); + final link = NotusAttribute.link.fromString('https://github.com'); + final delta = new Delta() + ..insert('This') + ..insert(' house ', b) + ..insert('is a') + ..insert(' circus ', link.toJson()) + ..insert('\n'); + + final result = notusHTML.encode(delta); + expect(result, + 'This house is a circus \n'); + }); + + test('encode heading styles', () { + runFor(NotusAttribute attribute, String source, String expected) { + final delta = new Delta() + ..insert(source) + ..insert('\n', attribute.toJson()); + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor(NotusAttribute.h1, 'Title', '

Title

\n'); + runFor(NotusAttribute.h2, 'Title', '

Title

\n'); + runFor(NotusAttribute.h3, 'Title', '

Title

\n'); + }); + test('encode heading styles with container attribute', () { + runFor(NotusAttribute attribute, String source, String expected) { + final attr = attribute.toJson(); + attr.addAll(NotusAttribute.container.withValue({ + "heading": {"foo": "bar"} + }).toJson()); + final delta = new Delta()..insert(source)..insert('\n', attr); + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor(NotusAttribute.h1, 'Title', '

Title

\n'); + runFor(NotusAttribute.h2, 'Title', '

Title

\n'); + runFor(NotusAttribute.h3, 'Title', '

Title

\n'); + }); + + test('encode singe block with container', () { + runFor(NotusAttribute attribute, String source, String expected) { + var attr = attribute.toJson(); + attr.addAll(NotusAttribute.container.withValue({ + "block": {"foo": "bar"} + }).toJson()); + final delta = Delta()..insert(source)..insert('\n', attr); + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor( + NotusAttribute.ul, 'item', '
    \n
  • item
  • \n
\n'); + runFor( + NotusAttribute.ol, 'item', '
    \n
  1. item
  2. \n
\n'); + runFor(NotusAttribute.bq, 'item', + '
item
\n'); + }); + + test('encode block styles: ol, ul', () { + runFor(NotusAttribute attribute, String source, String expected) { + final delta = Delta()..insert(source)..insert('\n', attribute.toJson()); + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor( + NotusAttribute.ul, 'List item', '
    \n
  • List item
  • \n
\n'); + runFor( + NotusAttribute.ol, 'List item', '
    \n
  1. List item
  2. \n
\n'); + runFor(NotusAttribute.bq, 'List item', + '
List item
\n'); + }); + + test('encode block styles: code, bq', () { + runFor(NotusAttribute attribute, String source, String expected) { + List items = source.split("\n"); + final delta = Delta() + ..insert(items[0]) + ..insert('\n', attribute.toJson()) + ..insert(items[1]) + ..insert('\n', attribute.toJson()); + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor(NotusAttribute.code, 'item1\nitem2', '
item1\nitem2
\n'); + runFor(NotusAttribute.bq, 'item1\nitem2', + '
item1\nitem2
\n'); + }); + + test('encode image', () { + runFor(String expected) { + final delta = Delta.fromJson([ + { + "insert": "", + "attributes": { + "embed": {"type": "image", "source": "http://images.jpg"}, + } + } + ]) + ..insert('\n'); + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor('\n'); + }); + test('encode hr', () { + runFor(String expected) { + final delta = Delta.fromJson([ + { + "insert": "", + "attributes": { + "embed": {"type": "hr"} + }, + }, + ]) + ..insert('\n'); + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor('
\n'); + }); + test('encode hr with container attribute', () { + runFor(String expected) { + final delta = Delta.fromJson([ + { + "insert": "", + "attributes": { + "embed": {"type": "hr"}, + "container": { + "embed": {"foo": "bar"} + } + }, + }, + ]) + ..insert('\n'); + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor('
\n'); + }); + test('encode multiline blocks', () { + runFor(NotusAttribute attribute, String source, String expected) { + final delta = new Delta() + ..insert(source) + ..insert('\n', attribute.toJson()) + ..insert(source) + ..insert('\n', attribute.toJson()); + final result = notusHTML.encode(delta); + expect(result, expected); + } + + runFor(NotusAttribute.ul, 'text', + '
    \n
  • text
  • \n
  • text
  • \n
\n'); + runFor(NotusAttribute.ol, 'text', + '
    \n
  1. text
  2. \n
  3. text
  4. \n
\n'); + runFor( + NotusAttribute.bq, 'text', '
text\ntext
\n'); + + // runFor(NotusAttribute.code, 'text', '```\ntext\ntext\n```\n\n'); + }); + + test('encode multiple styles', () { + final result = notusHTML.encode(delta); + expect(result, expectedHTML); + }); + }); +} + +final doc = + r'[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert": "​","attributes": {"embed": {"type": "hr"}}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\nZefyr is an "},{"insert":"early preview","attributes":{"b":true}},{"insert":" open source library.\n"},{"insert":"Documentation"},{"insert":"\n","attributes":{"heading":3}},{"insert":"Quick Start"},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Data format and Document Model"},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Style attributes"},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Heuristic rules"},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Clean and modern look"},{"insert":"\n","attributes":{"heading":2}},{"insert":"rich text editor is built with simplicity and flexibility in mind.\n"}]'; +final delta = Delta.fromJson(json.decode(doc)); + +final expectedHTML = ''' +

Zefyr

+
Soft and gentle rich text editing for Flutter applications. +Zefyr is an early preview open source library. +

Documentation

+
    +
  • Quick Start
  • +
  • Data format and Document Model
  • +
  • Style attributes
  • +
  • Heuristic rules
  • +
+

Clean and modern look

+rich text editor is built with simplicity and flexibility in mind. +''';