diff --git a/config.lock.yaml b/config.lock.yaml index 6f69c2b..a7d011f 100644 --- a/config.lock.yaml +++ b/config.lock.yaml @@ -1,6 +1,7 @@ adaptive_number: ea9178fdd4d82ac45cf0ec966ac870dae661124f dots_indicator: 508f5883ac79bdbc10254092de3f28f571d261cd ed25519_edwards: 7353ba759ea9f4646cbf481c2ef949625c8ce4cf +flutter_markdown_plus: dc1185c933fbf9dba559ef6c91586ff1503be3ee flutter_sharing_intent: aa1672f547d6579585fa27df0b28ffa2a2544aaa hand_signature: 1beedb164d093643365b0832277c377353c7464f hashlib: bc9c2f8dd7bbc72f47ccab0ce1111d40259c49bc diff --git a/config.yaml b/config.yaml index 65e243f..4c9c28f 100644 --- a/config.yaml +++ b/config.yaml @@ -55,4 +55,8 @@ restart_app: git: https://github.com/gabrimatic/restart_app no_screenshot: - git: https://github.com/FlutterPlaza/no_screenshot.git \ No newline at end of file + git: https://github.com/FlutterPlaza/no_screenshot.git + + +flutter_markdown_plus: + git: https://github.com/foresightmobile/flutter_markdown_plus.git \ No newline at end of file diff --git a/flutter_markdown_plus/LICENSE b/flutter_markdown_plus/LICENSE new file mode 100644 index 0000000..c6823b8 --- /dev/null +++ b/flutter_markdown_plus/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/flutter_markdown_plus/lib/flutter_markdown_plus.dart b/flutter_markdown_plus/lib/flutter_markdown_plus.dart new file mode 100644 index 0000000..12ed0a9 --- /dev/null +++ b/flutter_markdown_plus/lib/flutter_markdown_plus.dart @@ -0,0 +1,10 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A library to render markdown formatted text. +library flutter_markdown_plus; + +export 'src/builder.dart'; +export 'src/style_sheet.dart'; +export 'src/widget.dart'; diff --git a/flutter_markdown_plus/lib/src/_functions_io.dart b/flutter_markdown_plus/lib/src/_functions_io.dart new file mode 100644 index 0000000..7482d5b --- /dev/null +++ b/flutter_markdown_plus/lib/src/_functions_io.dart @@ -0,0 +1,119 @@ +// Copyright 2013 The Flutter Authors. 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:io'; + +import 'package:flutter/cupertino.dart' show CupertinoTheme; +import 'package:flutter/material.dart' show Theme; +import 'package:flutter/widgets.dart'; + +import 'style_sheet.dart'; +import 'widget.dart'; + +/// Type for a function that creates image widgets. +typedef ImageBuilder = Widget Function(Uri uri, String? imageDirectory, double? width, double? height); + +/// A default image builder handling http/https, resource, and file URLs. +// ignore: prefer_function_declarations_over_variables +final ImageBuilder kDefaultImageBuilder = ( + Uri uri, + String? imageDirectory, + double? width, + double? height, +) { + if (uri.scheme == 'http' || uri.scheme == 'https') { + return Image.network( + uri.toString(), + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } else if (uri.scheme == 'data') { + return _handleDataSchemeUri(uri, width, height); + } else if (uri.scheme == 'resource') { + return Image.asset( + uri.path, + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } else { + final Uri fileUri = imageDirectory != null ? Uri.parse(imageDirectory + uri.toString()) : uri; + if (fileUri.scheme == 'http' || fileUri.scheme == 'https') { + return Image.network( + fileUri.toString(), + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } else { + try { + return Image.file( + File.fromUri(fileUri), + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } catch (error, stackTrace) { + // Handle any invalid file URI's. + return Builder( + builder: (BuildContext context) { + return kDefaultImageErrorWidgetBuilder(context, error, stackTrace); + }, + ); + } + } + } +}; + +/// A default error widget builder for handling image errors. +// ignore: prefer_function_declarations_over_variables +final ImageErrorWidgetBuilder kDefaultImageErrorWidgetBuilder = ( + BuildContext context, + Object error, + StackTrace? stackTrace, +) { + return const SizedBox(); +}; + +/// A default style sheet generator. +final MarkdownStyleSheet Function(BuildContext, MarkdownStyleSheetBaseTheme?) +// ignore: prefer_function_declarations_over_variables + kFallbackStyle = ( + BuildContext context, + MarkdownStyleSheetBaseTheme? baseTheme, +) { + MarkdownStyleSheet result; + switch (baseTheme) { + case MarkdownStyleSheetBaseTheme.platform: + result = (Platform.isIOS || Platform.isMacOS) + ? MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context)) + : MarkdownStyleSheet.fromTheme(Theme.of(context)); + case MarkdownStyleSheetBaseTheme.cupertino: + result = MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context)); + case MarkdownStyleSheetBaseTheme.material: + // ignore: no_default_cases + default: + result = MarkdownStyleSheet.fromTheme(Theme.of(context)); + } + + return result.copyWith( + textScaler: MediaQuery.textScalerOf(context), + ); +}; + +Widget _handleDataSchemeUri(Uri uri, final double? width, final double? height) { + final String mimeType = uri.data!.mimeType; + if (mimeType.startsWith('image/')) { + return Image.memory( + uri.data!.contentAsBytes(), + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } else if (mimeType.startsWith('text/')) { + return Text(uri.data!.contentAsString()); + } + return const SizedBox(); +} diff --git a/flutter_markdown_plus/lib/src/_functions_web.dart b/flutter_markdown_plus/lib/src/_functions_web.dart new file mode 100644 index 0000000..7d33249 --- /dev/null +++ b/flutter_markdown_plus/lib/src/_functions_web.dart @@ -0,0 +1,124 @@ +// Copyright 2013 The Flutter Authors. 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:js_interop'; + +import 'package:flutter/cupertino.dart' show CupertinoTheme; +import 'package:flutter/material.dart' show Theme; +import 'package:flutter/widgets.dart'; +import 'package:path/path.dart' as p; + +import 'style_sheet.dart'; +import 'widget.dart'; + +/// Type for a function that creates image widgets. +typedef ImageBuilder = Widget Function(Uri uri, String? imageDirectory, double? width, double? height); + +/// A default image builder handling http/https, resource, data, and file URLs. +// ignore: prefer_function_declarations_over_variables +final ImageBuilder kDefaultImageBuilder = ( + Uri uri, + String? imageDirectory, + double? width, + double? height, +) { + if (uri.scheme == 'http' || uri.scheme == 'https') { + return Image.network( + uri.toString(), + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } else if (uri.scheme == 'data') { + return _handleDataSchemeUri(uri, width, height); + } else if (uri.scheme == 'resource') { + return Image.asset( + uri.path, + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } else { + final Uri fileUri; + + if (imageDirectory != null) { + try { + fileUri = Uri.parse(p.join(imageDirectory, uri.toString())); + } catch (error, stackTrace) { + // Handle any invalid file URI's. + return Builder( + builder: (BuildContext context) { + return kDefaultImageErrorWidgetBuilder(context, error, stackTrace); + }, + ); + } + } else { + fileUri = uri; + } + + if (fileUri.scheme == 'http' || fileUri.scheme == 'https') { + return Image.network( + fileUri.toString(), + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } else { + final String src = p.join(p.current, fileUri.toString()); + return Image.network( + src, + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } + } +}; + +/// A default error widget builder for handling image errors. +// ignore: prefer_function_declarations_over_variables +final ImageErrorWidgetBuilder kDefaultImageErrorWidgetBuilder = ( + BuildContext context, + Object error, + StackTrace? stackTrace, +) { + return const SizedBox(); +}; + +/// A default style sheet generator. +final MarkdownStyleSheet Function(BuildContext, MarkdownStyleSheetBaseTheme?) +// ignore: prefer_function_declarations_over_variables + kFallbackStyle = ( + BuildContext context, + MarkdownStyleSheetBaseTheme? baseTheme, +) { + final MarkdownStyleSheet result = switch (baseTheme) { + MarkdownStyleSheetBaseTheme.platform when _userAgent.toDart.contains('Mac OS X') => + MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context)), + MarkdownStyleSheetBaseTheme.cupertino => MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context)), + _ => MarkdownStyleSheet.fromTheme(Theme.of(context)), + }; + + return result.copyWith( + textScaler: MediaQuery.textScalerOf(context), + ); +}; + +Widget _handleDataSchemeUri(Uri uri, final double? width, final double? height) { + final String mimeType = uri.data!.mimeType; + if (mimeType.startsWith('image/')) { + return Image.memory( + uri.data!.contentAsBytes(), + width: width, + height: height, + errorBuilder: kDefaultImageErrorWidgetBuilder, + ); + } else if (mimeType.startsWith('text/')) { + return Text(uri.data!.contentAsString()); + } + return const SizedBox(); +} + +@JS('window.navigator.userAgent') +external JSString get _userAgent; diff --git a/flutter_markdown_plus/lib/src/builder.dart b/flutter_markdown_plus/lib/src/builder.dart new file mode 100644 index 0000000..31a7c95 --- /dev/null +++ b/flutter_markdown_plus/lib/src/builder.dart @@ -0,0 +1,1033 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:markdown/markdown.dart' as md; + +import '_functions_io.dart' if (dart.library.js_interop) '_functions_web.dart'; +import 'style_sheet.dart'; +import 'widget.dart'; + +final List _kBlockTags = [ + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'li', + 'blockquote', + 'pre', + 'ol', + 'ul', + 'hr', + 'table', + 'thead', + 'tbody', + 'tr', + 'section', +]; + +const List _kListTags = ['ul', 'ol']; + +bool _isBlockTag(String? tag) => _kBlockTags.contains(tag); + +bool _isListTag(String tag) => _kListTags.contains(tag); + +class _BlockElement { + _BlockElement(this.tag); + + final String? tag; + final List children = []; + + int nextListIndex = 0; +} + +class _TableElement { + final List rows = []; +} + +/// A collection of widgets that should be placed adjacent to (inline with) +/// other inline elements in the same parent block. +/// +/// Inline elements can be textual (a/em/strong) represented by [Text.rich] +/// widgets or images (img) represented by [Image.network] widgets. +/// +/// Inline elements can be nested within other inline elements, inheriting their +/// parent's style along with the style of the block they are in. +/// +/// When laying out inline widgets, first, any adjacent Text.rich widgets are +/// merged, then, all inline widgets are enclosed in a parent [Wrap] widget. +class _InlineElement { + _InlineElement(this.tag, {this.style}); + + final String? tag; + + /// Created by merging the style defined for this element's [tag] in the + /// delegate's [MarkdownStyleSheet] with the style of its parent. + final TextStyle? style; + + final List children = []; +} + +/// A delegate used by [MarkdownBuilder] to control the widgets it creates. +abstract class MarkdownBuilderDelegate { + /// Returns the [BuildContext] of the [MarkdownWidget]. + /// + /// The context will be passed down to the + /// [MarkdownElementBuilder.visitElementBefore] method and allows elements to + /// get information from the context. + BuildContext get context; + + /// Returns a gesture recognizer to use for an `a` element with the given + /// text, `href` attribute, and title. + GestureRecognizer createLink(String text, String? href, String title); + + /// Returns formatted text to use to display the given contents of a `pre` + /// element. + /// + /// The `styleSheet` is the value of [MarkdownBuilder.styleSheet]. + TextSpan formatText(MarkdownStyleSheet styleSheet, String code); +} + +/// Builds a [Widget] tree from parsed Markdown. +/// +/// See also: +/// +/// * [Markdown], which is a widget that parses and displays Markdown. +class MarkdownBuilder implements md.NodeVisitor { + /// Creates an object that builds a [Widget] tree from parsed Markdown. + MarkdownBuilder({ + required this.delegate, + required this.selectable, + required this.styleSheet, + required this.imageDirectory, + required this.imageBuilder, + required this.checkboxBuilder, + required this.bulletBuilder, + required this.builders, + required this.paddingBuilders, + required this.listItemCrossAxisAlignment, + this.fitContent = false, + this.onSelectionChanged, + this.onTapText, + this.softLineBreak = false, + }); + + /// A delegate that controls how link and `pre` elements behave. + final MarkdownBuilderDelegate delegate; + + /// If true, the text is selectable. + /// + /// Defaults to false. + final bool selectable; + + /// Defines which [TextStyle] objects to use for each type of element. + final MarkdownStyleSheet styleSheet; + + /// The base directory holding images referenced by Img tags with local or network file paths. + final String? imageDirectory; + + /// Call when build an image widget. + final MarkdownImageBuilder? imageBuilder; + + /// Call when build a checkbox widget. + final MarkdownCheckboxBuilder? checkboxBuilder; + + /// Called when building a custom bullet. + final MarkdownBulletBuilder? bulletBuilder; + + /// Call when build a custom widget. + final Map builders; + + /// Call when build a padding for widget. + final Map paddingBuilders; + + /// Whether to allow the widget to fit the child content. + final bool fitContent; + + /// Controls the cross axis alignment for the bullet and list item content + /// in lists. + /// + /// Defaults to [MarkdownListItemCrossAxisAlignment.baseline], which + /// does not allow for intrinsic height measurements. + final MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment; + + /// Called when the user changes selection when [selectable] is set to true. + final MarkdownOnSelectionChangedCallback? onSelectionChanged; + + /// Default tap handler used when [selectable] is set to true + final VoidCallback? onTapText; + + /// The soft line break is used to identify the spaces at the end of aline of + /// text and the leading spaces in the immediately following the line of text. + /// + /// Default these spaces are removed in accordance with the Markdown + /// specification on soft line breaks when lines of text are joined. + final bool softLineBreak; + + final List _listIndents = []; + final List<_BlockElement> _blocks = <_BlockElement>[]; + final List<_TableElement> _tables = <_TableElement>[]; + final List<_InlineElement> _inlines = <_InlineElement>[]; + final List _linkHandlers = []; + String? _currentBlockTag; + String? _lastVisitedTag; + bool _isInBlockquote = false; + + /// Returns widgets that display the given Markdown nodes. + /// + /// The returned widgets are typically used as children in a [ListView]. + List build(List nodes) { + _listIndents.clear(); + _blocks.clear(); + _tables.clear(); + _inlines.clear(); + _linkHandlers.clear(); + _isInBlockquote = false; + + builders.forEach((String key, MarkdownElementBuilder value) { + if (value.isBlockElement()) { + _kBlockTags.add(key); + } + }); + + _blocks.add(_BlockElement(null)); + + for (final md.Node node in nodes) { + assert(_blocks.length == 1); + node.accept(this); + } + + _addAnonymousBlockIfNeeded(); + + assert(_tables.isEmpty); + assert(_inlines.isEmpty); + assert(!_isInBlockquote); + return _blocks.single.children; + } + + @override + bool visitElementBefore(md.Element element) { + final String tag = element.tag; + _currentBlockTag ??= tag; + _lastVisitedTag = tag; + + if (builders.containsKey(tag)) { + builders[tag]!.visitElementBefore(element); + } + + if (paddingBuilders.containsKey(tag)) { + paddingBuilders[tag]!.visitElementBefore(element); + } + + int? start; + final bool isBlock = _isBlockTag(tag) || (builders.containsKey(tag) && builders[tag]!.isBlockElement()); + if (isBlock) { + _addAnonymousBlockIfNeeded(); + if (_isListTag(tag)) { + _listIndents.add(tag); + if (element.attributes['start'] != null) { + start = int.parse(element.attributes['start']!) - 1; + } + } else if (tag == 'blockquote') { + _isInBlockquote = true; + } else if (tag == 'table') { + _tables.add(_TableElement()); + } else if (tag == 'tr') { + final int length = _tables.single.rows.length; + Decoration? decoration = styleSheet.tableCellsDecoration; + + if (length == 0) { + decoration = styleSheet.tableHeadCellsDecoration ?? styleSheet.tableCellsDecoration; + } else if (length.isEven) { + decoration = styleSheet.tableCellsDecoration; + } else { + decoration = null; + } + _tables.single.rows.add(TableRow( + decoration: decoration, + // TODO(stuartmorgan): This should be fixed, not suppressed; enabling + // this lint warning exposed that the builder is modifying the + // children of TableRows, even though they are @immutable. + // ignore: prefer_const_literals_to_create_immutables + children: [], + )); + } + final _BlockElement bElement = _BlockElement(tag); + if (start != null) { + bElement.nextListIndex = start; + } + _blocks.add(bElement); + } else { + if (tag == 'a' && !builders.containsKey('a')) { + final String? text = extractTextFromElement(element); + // Don't add empty links + if (text == null) { + return false; + } + final String? destination = element.attributes['href']; + final String title = element.attributes['title'] ?? ''; + + _linkHandlers.add( + delegate.createLink(text, destination, title), + ); + } + + _addParentInlineIfNeeded(_blocks.last.tag); + + // The Markdown parser passes empty table data tags for blank + // table cells. Insert a text node with an empty string in this + // case for the table cell to get properly created. + if (element.tag == 'td' && element.children != null && element.children!.isEmpty) { + element.children!.add(md.Text('')); + } + + final TextStyle? parentStyle = _inlines.last.style; + final TextStyle? tagStyle = styleSheet.styles[tag]; + _inlines.add(_InlineElement(tag, style: parentStyle != null ? parentStyle.merge(tagStyle) : tagStyle)); + } + + return true; + } + + /// Returns the text, if any, from [element] and its descendants. + String? extractTextFromElement(md.Node element) { + return element is md.Element && (element.children?.isNotEmpty ?? false) + ? element.children!.map((md.Node e) => e is md.Text ? e.text : extractTextFromElement(e)).join() + : (element is md.Element && (element.attributes.isNotEmpty) ? element.attributes['alt'] : ''); + } + + @override + void visitText(md.Text text) { + // Don't allow text directly under the root. + if (_blocks.last.tag == null) { + return; + } + + _addParentInlineIfNeeded(_blocks.last.tag); + + // Define trim text function to remove spaces from text elements in + // accordance with Markdown specifications. + String trimText(String text) { + // The leading spaces pattern is used to identify spaces + // at the beginning of a line of text. + final RegExp leadingSpacesPattern = RegExp(r'^ *'); + + // The soft line break is used to identify the spaces at the end of a line + // of text and the leading spaces in the immediately following the line + // of text. These spaces are removed in accordance with the Markdown + // specification on soft line breaks when lines of text are joined. + final RegExp softLineBreakPattern = RegExp(r' ?\n *'); + + // Leading spaces following a hard line break are ignored. + // https://github.github.com/gfm/#example-657 + // Leading spaces in paragraph or list item are ignored + // https://github.github.com/gfm/#example-192 + // https://github.github.com/gfm/#example-236 + if (const ['ul', 'ol', 'li', 'p', 'br'].contains(_lastVisitedTag)) { + text = text.replaceAll(leadingSpacesPattern, ''); + } + + if (softLineBreak) { + return text; + } + return text.replaceAll(softLineBreakPattern, ' '); + } + + Widget? child; + if (_blocks.isNotEmpty && builders.containsKey(_blocks.last.tag)) { + child = builders[_blocks.last.tag!]!.visitText(text, styleSheet.styles[_blocks.last.tag!]); + } else if (_blocks.last.tag == 'pre') { + child = _ScrollControllerBuilder( + builder: (BuildContext context, ScrollController preScrollController, Widget? child) { + return Scrollbar( + controller: preScrollController, + child: SingleChildScrollView( + controller: preScrollController, + scrollDirection: Axis.horizontal, + padding: styleSheet.codeblockPadding, + child: child, + ), + ); + }, + child: _buildRichText(delegate.formatText(styleSheet, text.text))); + } else { + child = _buildRichText( + TextSpan( + style: _isInBlockquote ? styleSheet.blockquote : _inlines.last.style, + text: trimText(text.text), + recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null, + ), + textAlign: _textAlignForBlockTag(_currentBlockTag), + ); + } + if (child != null) { + _inlines.last.children.add(child); + } + + _lastVisitedTag = null; + } + + @override + void visitElementAfter(md.Element element) { + final String tag = element.tag; + + final bool isBlock = _isBlockTag(tag) || (builders.containsKey(tag) && builders[tag]!.isBlockElement()); + if (isBlock) { + _addAnonymousBlockIfNeeded(); + + final _BlockElement current = _blocks.removeLast(); + + Widget defaultChild() { + if (current.children.isNotEmpty) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: fitContent ? CrossAxisAlignment.start : CrossAxisAlignment.stretch, + children: current.children, + ); + } else { + return const SizedBox(); + } + } + + Widget child = builders[tag]?.visitElementAfterWithContext( + delegate.context, + element, + styleSheet.styles[tag], + _inlines.isNotEmpty ? _inlines.last.style : null, + ) ?? + defaultChild(); + + if (_isListTag(tag)) { + assert(_listIndents.isNotEmpty); + _listIndents.removeLast(); + } else if (tag == 'li') { + if (_listIndents.isNotEmpty) { + if (element.children!.isEmpty) { + element.children!.add(md.Text('')); + } + Widget bullet; + final dynamic el = element.children![0]; + if (el is md.Element && el.attributes['type'] == 'checkbox') { + final bool val = el.attributes.containsKey('checked'); + bullet = _buildCheckbox(val); + } else { + bullet = _buildBullet(_listIndents.last); + } + child = Row( + mainAxisSize: fitContent ? MainAxisSize.min : MainAxisSize.max, + textBaseline: + listItemCrossAxisAlignment == MarkdownListItemCrossAxisAlignment.start ? null : TextBaseline.alphabetic, + crossAxisAlignment: listItemCrossAxisAlignment == MarkdownListItemCrossAxisAlignment.start + ? CrossAxisAlignment.start + : CrossAxisAlignment.baseline, + children: [ + SizedBox( + width: + styleSheet.listIndent! + styleSheet.listBulletPadding!.left + styleSheet.listBulletPadding!.right, + child: bullet, + ), + Flexible( + fit: fitContent ? FlexFit.loose : FlexFit.tight, + child: child, + ) + ], + ); + } + } else if (tag == 'table') { + if (styleSheet.tableColumnWidth is FixedColumnWidth || styleSheet.tableColumnWidth is IntrinsicColumnWidth) { + child = _ScrollControllerBuilder( + builder: (BuildContext context, ScrollController tableScrollController, Widget? child) { + return Scrollbar( + controller: tableScrollController, + thumbVisibility: styleSheet.tableScrollbarThumbVisibility, + child: SingleChildScrollView( + controller: tableScrollController, + scrollDirection: Axis.horizontal, + padding: styleSheet.tablePadding, + child: child, + ), + ); + }, + child: _buildTable(), + ); + } else { + child = _buildTable(); + } + } else if (tag == 'blockquote') { + _isInBlockquote = false; + child = DecoratedBox( + decoration: styleSheet.blockquoteDecoration!, + child: Padding( + padding: styleSheet.blockquotePadding!, + child: child, + ), + ); + } else if (tag == 'pre') { + child = Container( + clipBehavior: Clip.hardEdge, + decoration: styleSheet.codeblockDecoration, + child: child, + ); + } else if (tag == 'hr') { + child = Container(decoration: styleSheet.horizontalRuleDecoration); + } + + _addBlockChild(child); + } else { + final _InlineElement current = _inlines.removeLast(); + final _InlineElement parent = _inlines.last; + EdgeInsets padding = EdgeInsets.zero; + + if (paddingBuilders.containsKey(tag)) { + padding = paddingBuilders[tag]!.getPadding(); + } + + if (builders.containsKey(tag)) { + final Widget? child = builders[tag]!.visitElementAfterWithContext( + delegate.context, + element, + styleSheet.styles[tag], + parent.style, + ); + if (child != null) { + if (current.children.isEmpty) { + current.children.add(child); + } else { + current.children[0] = child; + } + } + } else if (tag == 'img') { + // create an image widget for this image + current.children.add(_buildPadding( + padding, + _buildImage( + element.attributes['src']!, + element.attributes['title'], + element.attributes['alt'], + ), + )); + } else if (tag == 'br') { + current.children.add(_buildRichText(const TextSpan(text: '\n'))); + } else if (tag == 'th' || tag == 'td') { + final bool isHeaderCell = tag == 'th'; + TextAlign? align; + final String? alignAttribute = element.attributes['align']; + if (alignAttribute == null) { + align = tag == 'th' ? styleSheet.tableHeadAlign : TextAlign.left; + } else { + switch (alignAttribute) { + case 'left': + align = TextAlign.left; + case 'center': + align = TextAlign.center; + case 'right': + align = TextAlign.right; + } + } + final Widget child = _buildTableCell( + _mergeInlineChildren(current.children, align), + textAlign: align, + isHeader: isHeaderCell, + ); + _tables.single.rows.last.children.add(child); + } else if (tag == 'a') { + if (!builders.containsKey('a')) { + _linkHandlers.removeLast(); + } + } else if (tag == 'sup') { + final Widget c = current.children.last; + TextSpan? textSpan; + if (c is Text && c.textSpan is TextSpan) { + textSpan = c.textSpan! as TextSpan; + } else if (c is SelectableText && c.textSpan is TextSpan) { + textSpan = c.textSpan; + } + if (textSpan != null) { + final Widget richText = _buildRichText( + TextSpan( + recognizer: textSpan.recognizer, + text: element.textContent, + style: textSpan.style?.copyWith( + fontFeatures: [ + const FontFeature.enable('sups'), + if (styleSheet.superscriptFontFeatureTag != null) + FontFeature.enable(styleSheet.superscriptFontFeatureTag!), + ], + ), + ), + ); + current.children.removeLast(); + current.children.add(richText); + } + } + + if (current.children.isNotEmpty) { + parent.children.addAll(current.children); + } + } + if (_currentBlockTag == tag) { + _currentBlockTag = null; + } + _lastVisitedTag = tag; + } + + Widget _buildTable() { + final Table table = Table( + defaultColumnWidth: styleSheet.tableColumnWidth!, + defaultVerticalAlignment: styleSheet.tableVerticalAlignment, + border: styleSheet.tableBorder, + children: _tables.removeLast().rows, + ); + + // Clip the table to the border radius if one is specified + final BorderRadiusGeometry? borderRadius = styleSheet.tableBorder?.borderRadius; + if (borderRadius != null) { + return ClipRRect( + borderRadius: borderRadius, + child: table, + ); + } + + return table; + } + + Widget _buildImage(String src, String? title, String? alt) { + final List parts = src.split('#'); + if (parts.isEmpty) { + return const SizedBox(); + } + + final String path = parts.first; + double? width; + double? height; + if (parts.length == 2) { + final List dimensions = parts.last.split('x'); + if (dimensions.length == 2) { + width = double.tryParse(dimensions[0]); + height = double.tryParse(dimensions[1]); + } + } + + final Uri? uri = Uri.tryParse(path); + + if (uri == null) { + return const SizedBox(); + } + + Widget child; + if (imageBuilder != null) { + child = imageBuilder!(uri, title, alt); + } else { + child = kDefaultImageBuilder(uri, imageDirectory, width, height); + } + + if (_linkHandlers.isNotEmpty) { + final TapGestureRecognizer recognizer = _linkHandlers.last as TapGestureRecognizer; + return GestureDetector(onTap: recognizer.onTap, child: child); + } else { + return child; + } + } + + Widget _buildCheckbox(bool checked) { + if (checkboxBuilder != null) { + return checkboxBuilder!(checked); + } + return Padding( + padding: styleSheet.listBulletPadding!, + child: Icon( + checked ? Icons.check_box : Icons.check_box_outline_blank, + size: styleSheet.checkbox!.fontSize, + color: styleSheet.checkbox!.color, + ), + ); + } + + Widget _buildBullet(String listTag) { + final int index = _blocks.last.nextListIndex; + final bool isUnordered = listTag == 'ul'; + + if (bulletBuilder != null) { + return Padding( + padding: styleSheet.listBulletPadding!, + child: bulletBuilder!( + MarkdownBulletParameters( + index: index, + style: isUnordered ? BulletStyle.unorderedList : BulletStyle.orderedList, + nestLevel: _listIndents.length - 1, + ), + ), + ); + } + + if (isUnordered) { + return Padding( + padding: styleSheet.listBulletPadding!, + child: Text( + '•', + textAlign: TextAlign.center, + style: styleSheet.listBullet, + ), + ); + } + + return Padding( + padding: styleSheet.listBulletPadding!, + child: Text( + '${index + 1}.', + textAlign: TextAlign.right, + style: styleSheet.listBullet, + ), + ); + } + + Widget _buildTableCell(List children, {TextAlign? textAlign, bool isHeader = false}) { + final EdgeInsets cellPadding = isHeader && styleSheet.tableHeadCellsPadding != null + ? styleSheet.tableHeadCellsPadding! + : styleSheet.tableCellsPadding!; + + final TextStyle cellStyle = + isHeader && styleSheet.tableHead != null ? styleSheet.tableHead! : styleSheet.tableBody!; + + return TableCell( + child: Padding( + padding: cellPadding, + child: DefaultTextStyle( + style: cellStyle, + textAlign: textAlign, + child: Wrap( + alignment: switch (textAlign) { + TextAlign.left => WrapAlignment.start, + TextAlign.center => WrapAlignment.center, + TextAlign.right => WrapAlignment.end, + _ => WrapAlignment.start, + }, + children: children as List, + ), + ), + ), + ); + } + + Widget _buildPadding(EdgeInsets padding, Widget child) { + if (padding == EdgeInsets.zero) { + return child; + } + + return Padding(padding: padding, child: child); + } + + void _addParentInlineIfNeeded(String? tag) { + if (_inlines.isEmpty) { + _inlines.add(_InlineElement( + tag, + style: tag != null ? styleSheet.styles[tag] : null, + )); + } + } + + void _addBlockChild(Widget child) { + final _BlockElement parent = _blocks.last; + if (parent.children.isNotEmpty) { + parent.children.add(SizedBox(height: styleSheet.blockSpacing)); + } + parent.children.add(child); + parent.nextListIndex += 1; + } + + void _addAnonymousBlockIfNeeded() { + if (_inlines.isEmpty) { + return; + } + + WrapAlignment blockAlignment = WrapAlignment.start; + TextAlign textAlign = TextAlign.start; + EdgeInsets textPadding = EdgeInsets.zero; + if (_isBlockTag(_currentBlockTag)) { + blockAlignment = _wrapAlignmentForBlockTag(_currentBlockTag); + textAlign = _textAlignForBlockTag(_currentBlockTag); + textPadding = _textPaddingForBlockTag(_currentBlockTag); + + if (paddingBuilders.containsKey(_currentBlockTag)) { + textPadding = paddingBuilders[_currentBlockTag]!.getPadding(); + } + } + + final _InlineElement inline = _inlines.single; + if (inline.children.isNotEmpty) { + final List mergedInlines = _mergeInlineChildren( + inline.children, + textAlign, + ); + final Wrap wrap = Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + alignment: blockAlignment, + children: mergedInlines, + ); + + if (textPadding == EdgeInsets.zero) { + _addBlockChild(wrap); + } else { + final Padding padding = Padding(padding: textPadding, child: wrap); + _addBlockChild(padding); + } + } + + _inlines.clear(); + } + + /// Extracts all spans from an inline element and merges them into a single list + Iterable _getInlineSpansFromSpan(InlineSpan span) { + // If the span is not a TextSpan or it has no children, return the span + if (span is! TextSpan || span.children == null) { + return [span]; + } + + // Merge the style of the parent with the style of the children + final Iterable spans = span.children!.map((InlineSpan childSpan) { + if (childSpan is TextSpan) { + return TextSpan( + text: childSpan.text, + recognizer: childSpan.recognizer, + semanticsLabel: childSpan.semanticsLabel, + style: childSpan.style?.merge(span.style), + ); + } else { + return childSpan; + } + }); + + return spans; + } + + // Accesses the TextSpan property correctly depending on the widget type. + // Returns null if not a valid (text) widget. + InlineSpan? _getInlineSpanFromText(Widget widget) => switch (widget) { + SelectableText() => widget.textSpan, + Text() => widget.textSpan, + RichText() => widget.text, + _ => null + }; + + /// Merges adjacent [TextSpan] children. + /// Also forces a specific [TextAlign] regardless of merging. + /// This is essential for table column alignment, since desired column alignment + /// is discovered after the text widgets have been created. This function is the + /// last chance to enforce the desired column alignment in the texts. + List _mergeInlineChildren( + List children, + TextAlign? textAlign, + ) { + // List of text widgets (merged) and non-text widgets (non-merged) + final List mergedWidgets = []; + + bool lastIsText = false; + for (final Widget child in children) { + final InlineSpan? currentSpan = _getInlineSpanFromText(child); + final bool currentIsText = currentSpan != null; + + if (!currentIsText) { + // There is no merging to do, so just add and continue + mergedWidgets.add(child); + lastIsText = false; + continue; + } + + // Extracted spans from the last and the current widget + List spans = []; + + if (lastIsText) { + // Removes last widget from the list for merging and extracts its spans + spans.addAll(_getInlineSpansFromSpan(_getInlineSpanFromText(mergedWidgets.removeLast())!)); + } + + spans.addAll(_getInlineSpansFromSpan(currentSpan)); + spans = _mergeSimilarTextSpans(spans); + + final Widget mergedWidget; + + if (spans.isEmpty) { + // no spans found, just insert the current widget + mergedWidget = child; + } else { + final InlineSpan first = spans.first; + final TextSpan textSpan = (spans.length == 1 && first is TextSpan) ? first : TextSpan(children: spans); + mergedWidget = _buildRichText(textSpan, textAlign: textAlign); + } + + mergedWidgets.add(mergedWidget); + lastIsText = true; + } + + return mergedWidgets; + } + + TextAlign _textAlignForBlockTag(String? blockTag) { + final WrapAlignment wrapAlignment = _wrapAlignmentForBlockTag(blockTag); + switch (wrapAlignment) { + case WrapAlignment.start: + return TextAlign.start; + case WrapAlignment.center: + return TextAlign.center; + case WrapAlignment.end: + return TextAlign.end; + case WrapAlignment.spaceAround: + return TextAlign.justify; + case WrapAlignment.spaceBetween: + return TextAlign.justify; + case WrapAlignment.spaceEvenly: + return TextAlign.justify; + } + } + + WrapAlignment _wrapAlignmentForBlockTag(String? blockTag) { + switch (blockTag) { + case 'p': + return styleSheet.textAlign; + case 'h1': + return styleSheet.h1Align; + case 'h2': + return styleSheet.h2Align; + case 'h3': + return styleSheet.h3Align; + case 'h4': + return styleSheet.h4Align; + case 'h5': + return styleSheet.h5Align; + case 'h6': + return styleSheet.h6Align; + case 'ul': + return styleSheet.unorderedListAlign; + case 'ol': + return styleSheet.orderedListAlign; + case 'blockquote': + return styleSheet.blockquoteAlign; + case 'pre': + return styleSheet.codeblockAlign; + case 'hr': + break; + case 'li': + break; + } + return WrapAlignment.start; + } + + EdgeInsets _textPaddingForBlockTag(String? blockTag) { + switch (blockTag) { + case 'p': + return styleSheet.pPadding!; + case 'h1': + return styleSheet.h1Padding!; + case 'h2': + return styleSheet.h2Padding!; + case 'h3': + return styleSheet.h3Padding!; + case 'h4': + return styleSheet.h4Padding!; + case 'h5': + return styleSheet.h5Padding!; + case 'h6': + return styleSheet.h6Padding!; + } + return EdgeInsets.zero; + } + + /// Combine text spans with equivalent properties into a single span. + List _mergeSimilarTextSpans(List textSpans) { + if (textSpans.length < 2) { + return textSpans; + } + + final List mergedSpans = []; + + for (int index = 1; index < textSpans.length; index++) { + final InlineSpan previous = mergedSpans.isEmpty ? textSpans.first : mergedSpans.removeLast(); + final InlineSpan nextChild = textSpans[index]; + + final bool previousIsTextSpan = previous is TextSpan; + final bool nextIsTextSpan = nextChild is TextSpan; + if (!previousIsTextSpan || !nextIsTextSpan) { + mergedSpans.addAll([previous, nextChild]); + continue; + } + + final bool matchStyle = nextChild.recognizer == previous.recognizer && + nextChild.semanticsLabel == previous.semanticsLabel && + nextChild.style == previous.style; + + if (matchStyle) { + mergedSpans.add(TextSpan( + text: previous.toPlainText() + nextChild.toPlainText(), + recognizer: previous.recognizer, + semanticsLabel: previous.semanticsLabel, + style: previous.style, + )); + } else { + mergedSpans.addAll([previous, nextChild]); + } + } + + // When the mergered spans compress into a single TextSpan return just that + // TextSpan, otherwise bundle the set of TextSpans under a single parent. + return mergedSpans; + } + + Widget _buildRichText(TextSpan text, {TextAlign? textAlign, String? key}) { + //Adding a unique key prevents the problem of using the same link handler for text spans with the same text + final Key k = key == null ? UniqueKey() : Key(key); + if (selectable) { + return SelectableText.rich( + text, + textScaler: styleSheet.textScaler, + textAlign: textAlign ?? TextAlign.start, + onSelectionChanged: onSelectionChanged != null + ? (TextSelection selection, SelectionChangedCause? cause) => + onSelectionChanged!(text.text, selection, cause) + : null, + onTap: onTapText, + key: k, + ); + } else { + return Text.rich( + text, + textScaler: styleSheet.textScaler, + textAlign: textAlign ?? TextAlign.start, + key: k, + ); + } + } +} + +class _ScrollControllerBuilder extends StatefulWidget { + const _ScrollControllerBuilder({ + required this.builder, + this.child, + }); + + final ValueWidgetBuilder builder; + + final Widget? child; + + @override + State<_ScrollControllerBuilder> createState() => _ScrollControllerBuilderState(); +} + +class _ScrollControllerBuilderState extends State<_ScrollControllerBuilder> { + final ScrollController _controller = ScrollController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, _controller, widget.child); + } +} diff --git a/flutter_markdown_plus/lib/src/style_sheet.dart b/flutter_markdown_plus/lib/src/style_sheet.dart new file mode 100644 index 0000000..9927731 --- /dev/null +++ b/flutter_markdown_plus/lib/src/style_sheet.dart @@ -0,0 +1,856 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// Defines which [TextStyle] objects to use for which Markdown elements. +class MarkdownStyleSheet { + /// Creates an explicit mapping of [TextStyle] objects to Markdown elements. + MarkdownStyleSheet({ + this.a, + this.p, + this.pPadding, + this.code, + this.h1, + this.h1Padding, + this.h2, + this.h2Padding, + this.h3, + this.h3Padding, + this.h4, + this.h4Padding, + this.h5, + this.h5Padding, + this.h6, + this.h6Padding, + this.em, + this.strong, + this.del, + this.blockquote, + this.img, + this.checkbox, + this.blockSpacing, + this.listIndent, + this.listBullet, + this.listBulletPadding, + this.tableHead, + this.tableBody, + this.tableHeadAlign, + this.tablePadding, + this.tableBorder, + this.tableColumnWidth, + this.tableScrollbarThumbVisibility, + this.tableCellsPadding, + this.tableCellsDecoration, + this.tableHeadCellsPadding, + this.tableHeadCellsDecoration, + this.tableVerticalAlignment = TableCellVerticalAlignment.middle, + this.blockquotePadding, + this.blockquoteDecoration, + this.codeblockPadding, + this.codeblockDecoration, + this.horizontalRuleDecoration, + this.textAlign = WrapAlignment.start, + this.h1Align = WrapAlignment.start, + this.h2Align = WrapAlignment.start, + this.h3Align = WrapAlignment.start, + this.h4Align = WrapAlignment.start, + this.h5Align = WrapAlignment.start, + this.h6Align = WrapAlignment.start, + this.unorderedListAlign = WrapAlignment.start, + this.orderedListAlign = WrapAlignment.start, + this.blockquoteAlign = WrapAlignment.start, + this.codeblockAlign = WrapAlignment.start, + this.superscriptFontFeatureTag, + @Deprecated('Use textScaler instead.') this.textScaleFactor, + TextScaler? textScaler, + }) : assert( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ), + textScaler = textScaler ?? + // Internally, only textScaler is used, so convert the scale factor + // to a linear scaler. + (textScaleFactor == null ? null : TextScaler.linear(textScaleFactor)), + _styles = { + 'a': a, + 'p': p, + 'li': p, + 'code': code, + 'pre': p, + 'h1': h1, + 'h2': h2, + 'h3': h3, + 'h4': h4, + 'h5': h5, + 'h6': h6, + 'em': em, + 'strong': strong, + 'del': del, + 'blockquote': blockquote, + 'img': img, + 'table': p, + 'th': tableHead, + 'tr': tableBody, + 'td': tableBody, + }; + + /// Creates a [MarkdownStyleSheet] from the [TextStyle]s in the provided [ThemeData]. + factory MarkdownStyleSheet.fromTheme(ThemeData theme) { + assert(theme.textTheme.bodyMedium?.fontSize != null); + return MarkdownStyleSheet( + a: const TextStyle(color: Colors.blue), + p: theme.textTheme.bodyMedium, + pPadding: EdgeInsets.zero, + code: theme.textTheme.bodyMedium!.copyWith( + backgroundColor: theme.cardTheme.color, + fontFamily: 'monospace', + fontSize: theme.textTheme.bodyMedium!.fontSize! * 0.85, + ), + h1: theme.textTheme.headlineSmall, + h1Padding: EdgeInsets.zero, + h2: theme.textTheme.titleLarge, + h2Padding: EdgeInsets.zero, + h3: theme.textTheme.titleMedium, + h3Padding: EdgeInsets.zero, + h4: theme.textTheme.bodyLarge, + h4Padding: EdgeInsets.zero, + h5: theme.textTheme.bodyLarge, + h5Padding: EdgeInsets.zero, + h6: theme.textTheme.bodyLarge, + h6Padding: EdgeInsets.zero, + em: const TextStyle(fontStyle: FontStyle.italic), + strong: const TextStyle(fontWeight: FontWeight.bold), + del: const TextStyle(decoration: TextDecoration.lineThrough), + blockquote: theme.textTheme.bodyMedium, + img: theme.textTheme.bodyMedium, + checkbox: theme.textTheme.bodyMedium!.copyWith( + color: theme.primaryColor, + ), + blockSpacing: 8.0, + listIndent: 24.0, + listBullet: theme.textTheme.bodyMedium, + listBulletPadding: const EdgeInsets.only(right: 4), + tableHead: const TextStyle(fontWeight: FontWeight.w600), + tableBody: theme.textTheme.bodyMedium, + tableHeadAlign: TextAlign.center, + tablePadding: const EdgeInsets.only(bottom: 4.0), + tableBorder: TableBorder.all( + color: theme.dividerColor, + ), + tableColumnWidth: const FlexColumnWidth(), + tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + tableCellsDecoration: const BoxDecoration(), + blockquotePadding: const EdgeInsets.all(8.0), + blockquoteDecoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(2.0), + ), + codeblockPadding: const EdgeInsets.all(8.0), + codeblockDecoration: BoxDecoration( + color: theme.cardTheme.color ?? theme.cardColor, + borderRadius: BorderRadius.circular(2.0), + ), + horizontalRuleDecoration: BoxDecoration( + border: Border( + top: BorderSide( + width: 5.0, + color: theme.dividerColor, + ), + ), + ), + ); + } + + /// Creates a [MarkdownStyleSheet] from the [TextStyle]s in the provided [CupertinoThemeData]. + factory MarkdownStyleSheet.fromCupertinoTheme(CupertinoThemeData theme) { + assert(theme.textTheme.textStyle.fontSize != null); + return MarkdownStyleSheet( + a: theme.textTheme.textStyle.copyWith( + color: theme.brightness == Brightness.dark ? CupertinoColors.link.darkColor : CupertinoColors.link.color, + ), + p: theme.textTheme.textStyle, + pPadding: EdgeInsets.zero, + code: theme.textTheme.textStyle.copyWith( + fontFamily: 'monospace', + fontSize: theme.textTheme.textStyle.fontSize! * 0.85, + ), + h1: theme.textTheme.textStyle.copyWith( + fontWeight: FontWeight.w500, + fontSize: theme.textTheme.textStyle.fontSize! + 10, + ), + h1Padding: EdgeInsets.zero, + h2: theme.textTheme.textStyle.copyWith( + fontWeight: FontWeight.w500, + fontSize: theme.textTheme.textStyle.fontSize! + 8, + ), + h2Padding: EdgeInsets.zero, + h3: theme.textTheme.textStyle.copyWith( + fontWeight: FontWeight.w500, + fontSize: theme.textTheme.textStyle.fontSize! + 6, + ), + h3Padding: EdgeInsets.zero, + h4: theme.textTheme.textStyle.copyWith( + fontWeight: FontWeight.w500, + fontSize: theme.textTheme.textStyle.fontSize! + 4, + ), + h4Padding: EdgeInsets.zero, + h5: theme.textTheme.textStyle.copyWith( + fontWeight: FontWeight.w500, + fontSize: theme.textTheme.textStyle.fontSize! + 2, + ), + h5Padding: EdgeInsets.zero, + h6: theme.textTheme.textStyle.copyWith( + fontWeight: FontWeight.w500, + ), + h6Padding: EdgeInsets.zero, + em: theme.textTheme.textStyle.copyWith( + fontStyle: FontStyle.italic, + ), + strong: theme.textTheme.textStyle.copyWith( + fontWeight: FontWeight.bold, + ), + del: theme.textTheme.textStyle.copyWith( + decoration: TextDecoration.lineThrough, + ), + blockquote: theme.textTheme.textStyle, + img: theme.textTheme.textStyle, + checkbox: theme.textTheme.textStyle.copyWith( + color: theme.primaryColor, + ), + blockSpacing: 8, + listIndent: 24, + listBullet: theme.textTheme.textStyle, + listBulletPadding: const EdgeInsets.only(right: 4), + tableHead: theme.textTheme.textStyle.copyWith( + fontWeight: FontWeight.w600, + ), + tableBody: theme.textTheme.textStyle, + tableHeadAlign: TextAlign.center, + tablePadding: const EdgeInsets.only(bottom: 8), + tableBorder: TableBorder.all(color: CupertinoColors.separator, width: 0), + tableColumnWidth: const FlexColumnWidth(), + tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + tableCellsDecoration: BoxDecoration( + color: theme.brightness == Brightness.dark + ? CupertinoColors.systemGrey6.darkColor + : CupertinoColors.systemGrey6.color, + ), + blockquotePadding: const EdgeInsets.all(16), + blockquoteDecoration: BoxDecoration( + color: theme.brightness == Brightness.dark + ? CupertinoColors.systemGrey6.darkColor + : CupertinoColors.systemGrey6.color, + border: Border( + left: BorderSide( + color: theme.brightness == Brightness.dark + ? CupertinoColors.systemGrey4.darkColor + : CupertinoColors.systemGrey4.color, + width: 4, + ), + ), + ), + codeblockPadding: const EdgeInsets.all(8), + codeblockDecoration: BoxDecoration( + color: theme.brightness == Brightness.dark + ? CupertinoColors.systemGrey6.darkColor + : CupertinoColors.systemGrey6.color, + ), + horizontalRuleDecoration: BoxDecoration( + border: Border( + top: BorderSide( + color: theme.brightness == Brightness.dark + ? CupertinoColors.systemGrey4.darkColor + : CupertinoColors.systemGrey4.color, + ), + ), + ), + ); + } + + /// Creates a [MarkdownStyle] from the [TextStyle]s in the provided [ThemeData]. + /// + /// This constructor uses larger fonts for the headings than in + /// [MarkdownStyle.fromTheme]. + factory MarkdownStyleSheet.largeFromTheme(ThemeData theme) { + return MarkdownStyleSheet( + a: const TextStyle(color: Colors.blue), + p: theme.textTheme.bodyMedium, + pPadding: EdgeInsets.zero, + code: theme.textTheme.bodyMedium!.copyWith( + backgroundColor: theme.cardTheme.color, + fontFamily: 'monospace', + fontSize: theme.textTheme.bodyMedium!.fontSize! * 0.85, + ), + h1: theme.textTheme.displayMedium, + h1Padding: EdgeInsets.zero, + h2: theme.textTheme.displaySmall, + h2Padding: EdgeInsets.zero, + h3: theme.textTheme.headlineMedium, + h3Padding: EdgeInsets.zero, + h4: theme.textTheme.headlineSmall, + h4Padding: EdgeInsets.zero, + h5: theme.textTheme.titleLarge, + h5Padding: EdgeInsets.zero, + h6: theme.textTheme.titleMedium, + h6Padding: EdgeInsets.zero, + em: const TextStyle(fontStyle: FontStyle.italic), + strong: const TextStyle(fontWeight: FontWeight.bold), + del: const TextStyle(decoration: TextDecoration.lineThrough), + blockquote: theme.textTheme.bodyMedium, + img: theme.textTheme.bodyMedium, + checkbox: theme.textTheme.bodyMedium!.copyWith( + color: theme.primaryColor, + ), + blockSpacing: 8.0, + listIndent: 24.0, + listBullet: theme.textTheme.bodyMedium, + listBulletPadding: const EdgeInsets.only(right: 4), + tableHead: const TextStyle(fontWeight: FontWeight.w600), + tableBody: theme.textTheme.bodyMedium, + tableHeadAlign: TextAlign.center, + tablePadding: const EdgeInsets.only(bottom: 4.0), + tableBorder: TableBorder.all( + color: theme.dividerColor, + ), + tableColumnWidth: const FlexColumnWidth(), + tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + tableCellsDecoration: const BoxDecoration(), + blockquotePadding: const EdgeInsets.all(8.0), + blockquoteDecoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(2.0), + ), + codeblockPadding: const EdgeInsets.all(8.0), + codeblockDecoration: BoxDecoration( + color: theme.cardTheme.color ?? theme.cardColor, + borderRadius: BorderRadius.circular(2.0), + ), + horizontalRuleDecoration: BoxDecoration( + border: Border( + top: BorderSide( + width: 5.0, + color: theme.dividerColor, + ), + ), + ), + ); + } + + /// Creates a [MarkdownStyleSheet] based on the current style, with the + /// provided parameters overridden. + MarkdownStyleSheet copyWith({ + TextStyle? a, + TextStyle? p, + EdgeInsets? pPadding, + TextStyle? code, + TextStyle? h1, + EdgeInsets? h1Padding, + TextStyle? h2, + EdgeInsets? h2Padding, + TextStyle? h3, + EdgeInsets? h3Padding, + TextStyle? h4, + EdgeInsets? h4Padding, + TextStyle? h5, + EdgeInsets? h5Padding, + TextStyle? h6, + EdgeInsets? h6Padding, + TextStyle? em, + TextStyle? strong, + TextStyle? del, + TextStyle? blockquote, + TextStyle? img, + TextStyle? checkbox, + double? blockSpacing, + double? listIndent, + TextStyle? listBullet, + EdgeInsets? listBulletPadding, + TextStyle? tableHead, + TextStyle? tableBody, + TextAlign? tableHeadAlign, + EdgeInsets? tablePadding, + TableBorder? tableBorder, + TableColumnWidth? tableColumnWidth, + bool? tableScrollbarThumbVisibility, + EdgeInsets? tableCellsPadding, + Decoration? tableCellsDecoration, + EdgeInsets? tableHeadCellsPadding, + Decoration? tableHeadCellsDecoration, + TableCellVerticalAlignment? tableVerticalAlignment, + EdgeInsets? blockquotePadding, + Decoration? blockquoteDecoration, + EdgeInsets? codeblockPadding, + Decoration? codeblockDecoration, + Decoration? horizontalRuleDecoration, + WrapAlignment? textAlign, + WrapAlignment? h1Align, + WrapAlignment? h2Align, + WrapAlignment? h3Align, + WrapAlignment? h4Align, + WrapAlignment? h5Align, + WrapAlignment? h6Align, + WrapAlignment? unorderedListAlign, + WrapAlignment? orderedListAlign, + WrapAlignment? blockquoteAlign, + WrapAlignment? codeblockAlign, + String? superscriptFontFeatureTag, + @Deprecated('Use textScaler instead.') double? textScaleFactor, + TextScaler? textScaler, + }) { + assert( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ); + // If either of textScaler or textScaleFactor is non-null, pass null for the + // other instead of the previous value, since only one is allowed. + final TextScaler? newTextScaler = textScaler ?? (textScaleFactor == null ? this.textScaler : null); + final double? nextTextScaleFactor = textScaleFactor ?? (textScaler == null ? this.textScaleFactor : null); + return MarkdownStyleSheet( + a: a ?? this.a, + p: p ?? this.p, + pPadding: pPadding ?? this.pPadding, + code: code ?? this.code, + h1: h1 ?? this.h1, + h1Padding: h1Padding ?? this.h1Padding, + h2: h2 ?? this.h2, + h2Padding: h2Padding ?? this.h2Padding, + h3: h3 ?? this.h3, + h3Padding: h3Padding ?? this.h3Padding, + h4: h4 ?? this.h4, + h4Padding: h4Padding ?? this.h4Padding, + h5: h5 ?? this.h5, + h5Padding: h5Padding ?? this.h5Padding, + h6: h6 ?? this.h6, + h6Padding: h6Padding ?? this.h6Padding, + em: em ?? this.em, + strong: strong ?? this.strong, + del: del ?? this.del, + blockquote: blockquote ?? this.blockquote, + img: img ?? this.img, + checkbox: checkbox ?? this.checkbox, + blockSpacing: blockSpacing ?? this.blockSpacing, + listIndent: listIndent ?? this.listIndent, + listBullet: listBullet ?? this.listBullet, + listBulletPadding: listBulletPadding ?? this.listBulletPadding, + tableHead: tableHead ?? this.tableHead, + tableBody: tableBody ?? this.tableBody, + tableHeadAlign: tableHeadAlign ?? this.tableHeadAlign, + tablePadding: tablePadding ?? this.tablePadding, + tableBorder: tableBorder ?? this.tableBorder, + tableColumnWidth: tableColumnWidth ?? this.tableColumnWidth, + tableScrollbarThumbVisibility: tableScrollbarThumbVisibility, + tableCellsPadding: tableCellsPadding ?? this.tableCellsPadding, + tableCellsDecoration: tableCellsDecoration ?? this.tableCellsDecoration, + tableHeadCellsPadding: tableHeadCellsPadding ?? this.tableHeadCellsPadding, + tableHeadCellsDecoration: tableHeadCellsDecoration ?? this.tableHeadCellsDecoration, + tableVerticalAlignment: tableVerticalAlignment ?? this.tableVerticalAlignment, + blockquotePadding: blockquotePadding ?? this.blockquotePadding, + blockquoteDecoration: blockquoteDecoration ?? this.blockquoteDecoration, + codeblockPadding: codeblockPadding ?? this.codeblockPadding, + codeblockDecoration: codeblockDecoration ?? this.codeblockDecoration, + horizontalRuleDecoration: horizontalRuleDecoration ?? this.horizontalRuleDecoration, + textAlign: textAlign ?? this.textAlign, + h1Align: h1Align ?? this.h1Align, + h2Align: h2Align ?? this.h2Align, + h3Align: h3Align ?? this.h3Align, + h4Align: h4Align ?? this.h4Align, + h5Align: h5Align ?? this.h5Align, + h6Align: h6Align ?? this.h6Align, + unorderedListAlign: unorderedListAlign ?? this.unorderedListAlign, + orderedListAlign: orderedListAlign ?? this.orderedListAlign, + blockquoteAlign: blockquoteAlign ?? this.blockquoteAlign, + codeblockAlign: codeblockAlign ?? this.codeblockAlign, + superscriptFontFeatureTag: superscriptFontFeatureTag ?? this.superscriptFontFeatureTag, + textScaler: newTextScaler, + textScaleFactor: nextTextScaleFactor, + ); + } + + /// Returns a new text style that is a combination of this style and the given + /// [other] style. + MarkdownStyleSheet merge(MarkdownStyleSheet? other) { + if (other == null) { + return this; + } + return copyWith( + a: a!.merge(other.a), + p: p!.merge(other.p), + pPadding: other.pPadding, + code: code!.merge(other.code), + h1: h1!.merge(other.h1), + h1Padding: other.h1Padding, + h2: h2!.merge(other.h2), + h2Padding: other.h2Padding, + h3: h3!.merge(other.h3), + h3Padding: other.h3Padding, + h4: h4!.merge(other.h4), + h4Padding: other.h4Padding, + h5: h5!.merge(other.h5), + h5Padding: other.h5Padding, + h6: h6!.merge(other.h6), + h6Padding: other.h6Padding, + em: em!.merge(other.em), + strong: strong!.merge(other.strong), + del: del!.merge(other.del), + blockquote: blockquote!.merge(other.blockquote), + img: img!.merge(other.img), + checkbox: checkbox!.merge(other.checkbox), + blockSpacing: other.blockSpacing, + listIndent: other.listIndent, + listBullet: listBullet!.merge(other.listBullet), + listBulletPadding: other.listBulletPadding, + tableHead: tableHead!.merge(other.tableHead), + tableBody: tableBody!.merge(other.tableBody), + tableHeadAlign: other.tableHeadAlign, + tablePadding: other.tablePadding, + tableBorder: other.tableBorder, + tableColumnWidth: other.tableColumnWidth, + tableScrollbarThumbVisibility: other.tableScrollbarThumbVisibility, + tableCellsPadding: other.tableCellsPadding, + tableCellsDecoration: other.tableCellsDecoration, + tableHeadCellsPadding: other.tableHeadCellsPadding, + tableHeadCellsDecoration: other.tableHeadCellsDecoration, + tableVerticalAlignment: other.tableVerticalAlignment, + blockquotePadding: other.blockquotePadding, + blockquoteDecoration: other.blockquoteDecoration, + codeblockPadding: other.codeblockPadding, + codeblockDecoration: other.codeblockDecoration, + horizontalRuleDecoration: other.horizontalRuleDecoration, + textAlign: other.textAlign, + h1Align: other.h1Align, + h2Align: other.h2Align, + h3Align: other.h3Align, + h4Align: other.h4Align, + h5Align: other.h5Align, + h6Align: other.h6Align, + unorderedListAlign: other.unorderedListAlign, + orderedListAlign: other.orderedListAlign, + blockquoteAlign: other.blockquoteAlign, + codeblockAlign: other.codeblockAlign, + textScaleFactor: other.textScaleFactor, + superscriptFontFeatureTag: other.superscriptFontFeatureTag, + // Only one of textScaler and textScaleFactor can be passed. If + // other.textScaleFactor is non-null, then the sheet was created with a + // textScaleFactor and the textScaler was derived from that, so should be + // ignored so that the textScaleFactor continues to be set. + textScaler: other.textScaleFactor == null ? other.textScaler : null, + ); + } + + /// The [TextStyle] to use for `a` elements. + final TextStyle? a; + + /// The [TextStyle] to use for `p` elements. + final TextStyle? p; + + /// The padding to use for `p` elements. + final EdgeInsets? pPadding; + + /// The [TextStyle] to use for `code` elements. + final TextStyle? code; + + /// The [TextStyle] to use for `h1` elements. + final TextStyle? h1; + + /// The padding to use for `h1` elements. + final EdgeInsets? h1Padding; + + /// The [TextStyle] to use for `h2` elements. + final TextStyle? h2; + + /// The padding to use for `h2` elements. + final EdgeInsets? h2Padding; + + /// The [TextStyle] to use for `h3` elements. + final TextStyle? h3; + + /// The padding to use for `h3` elements. + final EdgeInsets? h3Padding; + + /// The [TextStyle] to use for `h4` elements. + final TextStyle? h4; + + /// The padding to use for `h4` elements. + final EdgeInsets? h4Padding; + + /// The [TextStyle] to use for `h5` elements. + final TextStyle? h5; + + /// The padding to use for `h5` elements. + final EdgeInsets? h5Padding; + + /// The [TextStyle] to use for `h6` elements. + final TextStyle? h6; + + /// The padding to use for `h6` elements. + final EdgeInsets? h6Padding; + + /// The [TextStyle] to use for `em` elements. + final TextStyle? em; + + /// The [TextStyle] to use for `strong` elements. + final TextStyle? strong; + + /// The [TextStyle] to use for `del` elements. + final TextStyle? del; + + /// The [TextStyle] to use for `blockquote` elements. + final TextStyle? blockquote; + + /// The [TextStyle] to use for `img` elements. + final TextStyle? img; + + /// The [TextStyle] to use for `input` elements. + final TextStyle? checkbox; + + /// The amount of vertical space to use between block-level elements. + final double? blockSpacing; + + /// The amount of horizontal space to indent list items. + final double? listIndent; + + /// The [TextStyle] to use for bullets. + final TextStyle? listBullet; + + /// The padding to use for bullets. + final EdgeInsets? listBulletPadding; + + /// The [TextStyle] to use for `th` elements. + final TextStyle? tableHead; + + /// The [TextStyle] to use for `td` elements. + final TextStyle? tableBody; + + /// The [TextAlign] to use for `th` elements. + final TextAlign? tableHeadAlign; + + /// The padding to use for `table` elements. + final EdgeInsets? tablePadding; + + /// The [TableBorder] to use for `table` elements. + final TableBorder? tableBorder; + + /// The [TableColumnWidth] to use for `th` and `td` elements. + final TableColumnWidth? tableColumnWidth; + + /// The scrollbar thumbVisibility when the table is scrollable. + final bool? tableScrollbarThumbVisibility; + + /// The padding to use for `th` and `td` elements. + final EdgeInsets? tableCellsPadding; + + /// The decoration to use for `th` and `td` elements. + final Decoration? tableCellsDecoration; + + /// The padding to use for `th` elements. + /// + /// If null, defaults to [tableCellsPadding]. + final EdgeInsets? tableHeadCellsPadding; + + /// The decoration to use for `th` elements. + /// + /// If null, defaults to [tableCellsDecoration]. + final Decoration? tableHeadCellsDecoration; + + /// The [TableCellVerticalAlignment] to use for `th` and `td` elements. + final TableCellVerticalAlignment tableVerticalAlignment; + + /// The padding to use for `blockquote` elements. + final EdgeInsets? blockquotePadding; + + /// The decoration to use behind `blockquote` elements. + final Decoration? blockquoteDecoration; + + /// The padding to use for `pre` elements. + final EdgeInsets? codeblockPadding; + + /// The decoration to use behind for `pre` elements. + final Decoration? codeblockDecoration; + + /// The decoration to use for `hr` elements. + final Decoration? horizontalRuleDecoration; + + /// The [WrapAlignment] to use for normal text. Defaults to start. + final WrapAlignment textAlign; + + /// The [WrapAlignment] to use for h1 text. Defaults to start. + final WrapAlignment h1Align; + + /// The [WrapAlignment] to use for h2 text. Defaults to start. + final WrapAlignment h2Align; + + /// The [WrapAlignment] to use for h3 text. Defaults to start. + final WrapAlignment h3Align; + + /// The [WrapAlignment] to use for h4 text. Defaults to start. + final WrapAlignment h4Align; + + /// The [WrapAlignment] to use for h5 text. Defaults to start. + final WrapAlignment h5Align; + + /// The [WrapAlignment] to use for h6 text. Defaults to start. + final WrapAlignment h6Align; + + /// The [WrapAlignment] to use for an unordered list. Defaults to start. + final WrapAlignment unorderedListAlign; + + /// The [WrapAlignment] to use for an ordered list. Defaults to start. + final WrapAlignment orderedListAlign; + + /// The [WrapAlignment] to use for a blockquote. Defaults to start. + final WrapAlignment blockquoteAlign; + + /// The [WrapAlignment] to use for a code block. Defaults to start. + final WrapAlignment codeblockAlign; + + /// The text scaler to use in textual elements. + final TextScaler? textScaler; + + /// The text scale factor to use in textual elements. + /// + /// This will be non-null only if the sheet was created with the deprecated + /// [textScaleFactor] instead of [textScaler]. + @Deprecated('Use textScaler instead.') + final double? textScaleFactor; + + /// Custom font feature tag for font which does not support `sups' + /// feature to create superscript in footnotes. + final String? superscriptFontFeatureTag; + + /// A [Map] from element name to the corresponding [TextStyle] object. + Map get styles => _styles; + Map _styles; + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != MarkdownStyleSheet) { + return false; + } + return other is MarkdownStyleSheet && + other.a == a && + other.p == p && + other.pPadding == pPadding && + other.code == code && + other.h1 == h1 && + other.h1Padding == h1Padding && + other.h2 == h2 && + other.h2Padding == h2Padding && + other.h3 == h3 && + other.h3Padding == h3Padding && + other.h4 == h4 && + other.h4Padding == h4Padding && + other.h5 == h5 && + other.h5Padding == h5Padding && + other.h6 == h6 && + other.h6Padding == h6Padding && + other.em == em && + other.strong == strong && + other.del == del && + other.blockquote == blockquote && + other.img == img && + other.checkbox == checkbox && + other.blockSpacing == blockSpacing && + other.listIndent == listIndent && + other.listBullet == listBullet && + other.listBulletPadding == listBulletPadding && + other.tableHead == tableHead && + other.tableBody == tableBody && + other.tableHeadAlign == tableHeadAlign && + other.tablePadding == tablePadding && + other.tableBorder == tableBorder && + other.tableColumnWidth == tableColumnWidth && + other.tableCellsPadding == tableCellsPadding && + other.tableCellsDecoration == tableCellsDecoration && + other.tableHeadCellsPadding == tableHeadCellsPadding && + other.tableHeadCellsDecoration == tableHeadCellsDecoration && + other.tableVerticalAlignment == tableVerticalAlignment && + other.blockquotePadding == blockquotePadding && + other.blockquoteDecoration == blockquoteDecoration && + other.codeblockPadding == codeblockPadding && + other.codeblockDecoration == codeblockDecoration && + other.horizontalRuleDecoration == horizontalRuleDecoration && + other.textAlign == textAlign && + other.h1Align == h1Align && + other.h2Align == h2Align && + other.h3Align == h3Align && + other.h4Align == h4Align && + other.h5Align == h5Align && + other.h6Align == h6Align && + other.unorderedListAlign == unorderedListAlign && + other.orderedListAlign == orderedListAlign && + other.blockquoteAlign == blockquoteAlign && + other.codeblockAlign == codeblockAlign && + other.superscriptFontFeatureTag == superscriptFontFeatureTag && + other.textScaler == textScaler; + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode { + return Object.hashAll([ + a, + p, + pPadding, + code, + h1, + h1Padding, + h2, + h2Padding, + h3, + h3Padding, + h4, + h4Padding, + h5, + h5Padding, + h6, + h6Padding, + em, + strong, + del, + blockquote, + img, + checkbox, + blockSpacing, + listIndent, + listBullet, + listBulletPadding, + tableHead, + tableBody, + tableHeadAlign, + tablePadding, + tableBorder, + tableColumnWidth, + tableCellsPadding, + tableCellsDecoration, + tableHeadCellsPadding, + tableHeadCellsDecoration, + tableVerticalAlignment, + blockquotePadding, + blockquoteDecoration, + codeblockPadding, + codeblockDecoration, + horizontalRuleDecoration, + textAlign, + h1Align, + h2Align, + h3Align, + h4Align, + h5Align, + h6Align, + unorderedListAlign, + orderedListAlign, + blockquoteAlign, + codeblockAlign, + textScaler, + textScaleFactor, + superscriptFontFeatureTag, + ]); + } +} diff --git a/flutter_markdown_plus/lib/src/widget.dart b/flutter_markdown_plus/lib/src/widget.dart new file mode 100644 index 0000000..5e8af27 --- /dev/null +++ b/flutter_markdown_plus/lib/src/widget.dart @@ -0,0 +1,589 @@ +// Copyright 2013 The Flutter Authors. 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:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:markdown/markdown.dart' as md; + +import '../flutter_markdown_plus.dart'; +import '_functions_io.dart' if (dart.library.js_interop) '_functions_web.dart'; + +/// Signature for callbacks used by [MarkdownWidget] when +/// [MarkdownWidget.selectable] is set to true and the user changes selection. +/// +/// The callback will return the entire block of text available for selection, +/// along with the current [selection] and the [cause] of the selection change. +/// This is a wrapper of [SelectionChangedCallback] with additional context +/// [text] for the caller to process. +/// +/// Used by [MarkdownWidget.onSelectionChanged] +typedef MarkdownOnSelectionChangedCallback = void Function( + String? text, TextSelection selection, SelectionChangedCause? cause); + +/// Signature for callbacks used by [MarkdownWidget] when the user taps a link. +/// The callback will return the link text, destination, and title from the +/// Markdown link tag in the document. +/// +/// Used by [MarkdownWidget.onTapLink]. +typedef MarkdownTapLinkCallback = void Function(String text, String? href, String title); + +/// Signature for custom image widget. +/// +/// Used by [MarkdownWidget.imageBuilder] +typedef MarkdownImageBuilder = Widget Function(Uri uri, String? title, String? alt); + +/// Signature for custom checkbox widget. +/// +/// Used by [MarkdownWidget.checkboxBuilder] +typedef MarkdownCheckboxBuilder = Widget Function(bool value); + +/// Signature for custom bullet widget. +/// +/// Used by [MarkdownWidget.bulletBuilder] +typedef MarkdownBulletBuilder = Widget Function( + MarkdownBulletParameters parameters, +); + +/// An parameters of [MarkdownBulletBuilder]. +/// +/// Used by [MarkdownWidget.bulletBuilder] +class MarkdownBulletParameters { + /// Creates a new instance of [MarkdownBulletParameters]. + const MarkdownBulletParameters({ + required this.index, + required this.style, + required this.nestLevel, + }); + + /// The index of the bullet on that nesting level. + final int index; + + /// The style of the bullet. + final BulletStyle style; + + /// The nest level of the bullet. + final int nestLevel; +} + +/// Enumeration sent to the user when calling [MarkdownBulletBuilder] +/// +/// Use this to differentiate the bullet styling when building your own. +enum BulletStyle { + /// An ordered list. + orderedList, + + /// An unordered list. + unorderedList, +} + +/// Creates a format [TextSpan] given a string. +/// +/// Used by [MarkdownWidget] to highlight the contents of `pre` elements. +abstract class SyntaxHighlighter { + // ignore: one_member_abstracts + /// Returns the formatted [TextSpan] for the given string. + TextSpan format(String source); +} + +/// An interface for an element builder. +abstract class MarkdownElementBuilder { + /// For block syntax has to return true. + /// + /// By default returns false. + bool isBlockElement() => false; + + /// Called when an Element has been reached, before its children have been + /// visited. + void visitElementBefore(md.Element element) {} + + /// Called when a text node has been reached. + /// + /// If [MarkdownWidget.styleSheet] has a style of this tag, will passing + /// to [preferredStyle]. + /// + /// If you needn't build a widget, return null. + Widget? visitText(md.Text text, TextStyle? preferredStyle) => null; + + /// Called when an Element has been reached, after its children have been + /// visited. + /// + /// If [MarkdownWidget.styleSheet] has a style with this tag, it will be + /// passed as [preferredStyle]. + /// + /// If parent element has [TextStyle] set, it will be passed as + /// [parentStyle]. + /// + /// If a widget build isn't needed, return null. + Widget? visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + return visitElementAfter(element, preferredStyle); + } + + /// Called when an Element has been reached, after its children have been + /// visited. + /// + /// If [MarkdownWidget.styleSheet] has a style of this tag, will passing + /// to [preferredStyle]. + /// + /// If you needn't build a widget, return null. + @Deprecated('Use visitElementAfterWithContext() instead.') + Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) => null; +} + +/// Enum to specify which theme being used when creating [MarkdownStyleSheet] +/// +/// [material] - create MarkdownStyleSheet based on MaterialTheme +/// [cupertino] - create MarkdownStyleSheet based on CupertinoTheme +/// [platform] - create MarkdownStyleSheet based on the Platform where the +/// is running on. Material on Android and Cupertino on iOS +enum MarkdownStyleSheetBaseTheme { + /// Creates a MarkdownStyleSheet based on MaterialTheme. + material, + + /// Creates a MarkdownStyleSheet based on CupertinoTheme. + cupertino, + + /// Creates a MarkdownStyleSheet whose theme is based on the current platform. + platform, +} + +/// Enumeration of alignment strategies for the cross axis of list items. +enum MarkdownListItemCrossAxisAlignment { + /// Uses [CrossAxisAlignment.baseline] for the row the bullet and the list + /// item are placed in. + /// + /// This alignment will ensure that the bullet always lines up with + /// the list text on the baseline. + /// + /// However, note that this alignment does not support intrinsic height + /// measurements because [RenderFlex] does not support it for + /// [CrossAxisAlignment.baseline]. + /// See https://github.com/flutter/flutter_markdown_plus/issues/311 for cases, + /// where this might be a problem for you. + /// + /// See also: + /// * [start], which allows for intrinsic height measurements. + baseline, + + /// Uses [CrossAxisAlignment.start] for the row the bullet and the list item + /// are placed in. + /// + /// This alignment will ensure that intrinsic height measurements work. + /// + /// However, note that this alignment might not line up the bullet with the + /// list text in the way you would expect in certain scenarios. + /// See https://github.com/flutter/flutter_markdown_plus/issues/169 for example + /// cases that do not produce expected results. + /// + /// See also: + /// * [baseline], which will position the bullet and list item on the + /// baseline. + start, +} + +/// A base class for widgets that parse and display Markdown. +/// +/// Supports all standard Markdown from the original +/// [Markdown specification](https://github.github.com/gfm/). +/// +/// See also: +/// +/// * [Markdown], which is a scrolling container of Markdown. +/// * [MarkdownBody], which is a non-scrolling container of Markdown. +/// * +abstract class MarkdownWidget extends StatefulWidget { + /// Creates a widget that parses and displays Markdown. + /// + /// The [data] argument must not be null. + const MarkdownWidget({ + super.key, + required this.data, + this.selectable = false, + this.styleSheet, + this.styleSheetTheme = MarkdownStyleSheetBaseTheme.material, + this.syntaxHighlighter, + this.onSelectionChanged, + this.onTapLink, + this.onTapText, + this.imageDirectory, + this.blockSyntaxes, + this.inlineSyntaxes, + this.extensionSet, + this.imageBuilder, + this.checkboxBuilder, + this.bulletBuilder, + this.builders = const {}, + this.paddingBuilders = const {}, + this.fitContent = false, + this.listItemCrossAxisAlignment = MarkdownListItemCrossAxisAlignment.baseline, + this.softLineBreak = false, + }); + + /// The Markdown to display. + final String data; + + /// If true, the text is selectable. + /// + /// Defaults to false. + final bool selectable; + + /// The styles to use when displaying the Markdown. + /// + /// If null, the styles are inferred from the current [Theme]. + final MarkdownStyleSheet? styleSheet; + + /// Setting to specify base theme for MarkdownStyleSheet + /// + /// Default to [MarkdownStyleSheetBaseTheme.material] + final MarkdownStyleSheetBaseTheme? styleSheetTheme; + + /// The syntax highlighter used to color text in `pre` elements. + /// + /// If null, the [MarkdownStyleSheet.code] style is used for `pre` elements. + final SyntaxHighlighter? syntaxHighlighter; + + /// Called when the user taps a link. + final MarkdownTapLinkCallback? onTapLink; + + /// Called when the user changes selection when [selectable] is set to true. + final MarkdownOnSelectionChangedCallback? onSelectionChanged; + + /// Default tap handler used when [selectable] is set to true + final VoidCallback? onTapText; + + /// The base directory holding images referenced by Img tags with local or network file paths. + final String? imageDirectory; + + /// Collection of custom block syntax types to be used parsing the Markdown data. + final List? blockSyntaxes; + + /// Collection of custom inline syntax types to be used parsing the Markdown data. + final List? inlineSyntaxes; + + /// Markdown syntax extension set + /// + /// Defaults to [md.ExtensionSet.gitHubFlavored] + final md.ExtensionSet? extensionSet; + + /// Call when build an image widget. + final MarkdownImageBuilder? imageBuilder; + + /// Call when build a checkbox widget. + final MarkdownCheckboxBuilder? checkboxBuilder; + + /// Called when building a bullet + final MarkdownBulletBuilder? bulletBuilder; + + /// Render certain tags, usually used with [extensionSet] + /// + /// For example, we will add support for `sub` tag: + /// + /// ```dart + /// builders: { + /// 'sub': SubscriptBuilder(), + /// } + /// ``` + /// + /// The `SubscriptBuilder` is a subclass of [MarkdownElementBuilder]. + final Map builders; + + /// Add padding for different tags (use only for block elements and img) + /// + /// For example, we will add padding for `img` tag: + /// + /// ```dart + /// paddingBuilders: { + /// 'img': ImgPaddingBuilder(), + /// } + /// ``` + /// + /// The `ImgPaddingBuilder` is a subclass of [MarkdownPaddingBuilder]. + final Map paddingBuilders; + + /// Whether to allow the widget to fit the child content. + final bool fitContent; + + /// Controls the cross axis alignment for the bullet and list item content + /// in lists. + /// + /// Defaults to [MarkdownListItemCrossAxisAlignment.baseline], which + /// does not allow for intrinsic height measurements. + final MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment; + + /// The soft line break is used to identify the spaces at the end of aline of + /// text and the leading spaces in the immediately following the line of text. + /// + /// Default these spaces are removed in accordance with the Markdown + /// specification on soft line breaks when lines of text are joined. + final bool softLineBreak; + + /// Subclasses should override this function to display the given children, + /// which are the parsed representation of [data]. + @protected + Widget build(BuildContext context, List? children); + + @override + State createState() => _MarkdownWidgetState(); +} + +class _MarkdownWidgetState extends State implements MarkdownBuilderDelegate { + List? _children; + final List _recognizers = []; + + @override + void didChangeDependencies() { + _parseMarkdown(); + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(MarkdownWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.data != oldWidget.data || widget.styleSheet != oldWidget.styleSheet) { + _parseMarkdown(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _parseMarkdown() { + final MarkdownStyleSheet fallbackStyleSheet = kFallbackStyle(context, widget.styleSheetTheme); + final MarkdownStyleSheet styleSheet = fallbackStyleSheet.merge(widget.styleSheet); + + _disposeRecognizers(); + + final md.Document document = md.Document( + blockSyntaxes: widget.blockSyntaxes, + inlineSyntaxes: widget.inlineSyntaxes, + extensionSet: widget.extensionSet ?? md.ExtensionSet.gitHubFlavored, + encodeHtml: false, + ); + + // Parse the source Markdown data into nodes of an Abstract Syntax Tree. + final List lines = const LineSplitter().convert(widget.data); + final List astNodes = document.parseLines(lines); + + // Configure a Markdown widget builder to traverse the AST nodes and + // create a widget tree based on the elements. + final MarkdownBuilder builder = MarkdownBuilder( + delegate: this, + selectable: widget.selectable, + styleSheet: styleSheet, + imageDirectory: widget.imageDirectory, + imageBuilder: widget.imageBuilder, + checkboxBuilder: widget.checkboxBuilder, + bulletBuilder: widget.bulletBuilder, + builders: widget.builders, + paddingBuilders: widget.paddingBuilders, + fitContent: widget.fitContent, + listItemCrossAxisAlignment: widget.listItemCrossAxisAlignment, + onSelectionChanged: widget.onSelectionChanged, + onTapText: widget.onTapText, + softLineBreak: widget.softLineBreak, + ); + + _children = builder.build(astNodes); + } + + void _disposeRecognizers() { + if (_recognizers.isEmpty) { + return; + } + final List localRecognizers = List.from(_recognizers); + _recognizers.clear(); + for (final GestureRecognizer recognizer in localRecognizers) { + recognizer.dispose(); + } + } + + @override + GestureRecognizer createLink(String text, String? href, String title) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () { + if (widget.onTapLink != null) { + widget.onTapLink!(text, href, title); + } + }; + _recognizers.add(recognizer); + return recognizer; + } + + @override + TextSpan formatText(MarkdownStyleSheet styleSheet, String code) { + code = code.replaceAll(RegExp(r'\n$'), ''); + if (widget.syntaxHighlighter != null) { + return widget.syntaxHighlighter!.format(code); + } + return TextSpan(style: styleSheet.code, text: code); + } + + @override + Widget build(BuildContext context) => widget.build(context, _children); +} + +/// A non-scrolling widget that parses and displays Markdown. +/// +/// Supports all GitHub Flavored Markdown from the +/// [specification](https://github.github.com/gfm/). +/// +/// See also: +/// +/// * [Markdown], which is a scrolling container of Markdown. +/// * +class MarkdownBody extends MarkdownWidget { + /// Creates a non-scrolling widget that parses and displays Markdown. + const MarkdownBody({ + super.key, + required super.data, + super.selectable, + super.styleSheet, + super.styleSheetTheme = null, + super.syntaxHighlighter, + super.onSelectionChanged, + super.onTapLink, + super.onTapText, + super.imageDirectory, + super.blockSyntaxes, + super.inlineSyntaxes, + super.extensionSet, + super.imageBuilder, + super.checkboxBuilder, + super.bulletBuilder, + super.builders, + super.paddingBuilders, + super.listItemCrossAxisAlignment, + this.shrinkWrap = true, + super.fitContent = true, + super.softLineBreak, + }); + + /// If [shrinkWrap] is `true`, [MarkdownBody] will take the minimum height + /// that wraps its content. Otherwise, [MarkdownBody] will expand to the + /// maximum allowed height. + final bool shrinkWrap; + + @override + Widget build(BuildContext context, List? children) { + if (children!.length == 1 && shrinkWrap) { + return children.single; + } + return Column( + mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max, + crossAxisAlignment: fitContent ? CrossAxisAlignment.start : CrossAxisAlignment.stretch, + children: children, + ); + } +} + +/// A scrolling widget that parses and displays Markdown. +/// +/// Supports all GitHub Flavored Markdown from the +/// [specification](https://github.github.com/gfm/). +/// +/// See also: +/// +/// * [MarkdownBody], which is a non-scrolling container of Markdown. +/// * +class Markdown extends MarkdownWidget { + /// Creates a scrolling widget that parses and displays Markdown. + const Markdown({ + super.key, + required super.data, + super.selectable, + super.styleSheet, + super.styleSheetTheme = null, + super.syntaxHighlighter, + super.onSelectionChanged, + super.onTapLink, + super.onTapText, + super.imageDirectory, + super.blockSyntaxes, + super.inlineSyntaxes, + super.extensionSet, + super.imageBuilder, + super.checkboxBuilder, + super.bulletBuilder, + super.builders, + super.paddingBuilders, + super.listItemCrossAxisAlignment, + this.padding = const EdgeInsets.all(16.0), + this.controller, + this.physics, + this.shrinkWrap = false, + super.softLineBreak, + }); + + /// The amount of space by which to inset the children. + final EdgeInsets padding; + + /// An object that can be used to control the position to which this scroll view is scrolled. + /// + /// See also: [ScrollView.controller] + final ScrollController? controller; + + /// How the scroll view should respond to user input. + /// + /// See also: [ScrollView.physics] + final ScrollPhysics? physics; + + /// Whether the extent of the scroll view in the scroll direction should be + /// determined by the contents being viewed. + /// + /// See also: [ScrollView.shrinkWrap] + final bool shrinkWrap; + + @override + Widget build(BuildContext context, List? children) { + return ListView( + padding: padding, + controller: controller, + physics: physics, + shrinkWrap: shrinkWrap, + children: children!, + ); + } +} + +/// Parse [task list items](https://github.github.com/gfm/#task-list-items-extension-). +/// +/// This class is no longer used as Markdown now supports checkbox syntax natively. +@Deprecated('Use [OrderedListWithCheckBoxSyntax] or [UnorderedListWithCheckBoxSyntax]') +class TaskListSyntax extends md.InlineSyntax { + /// Creates a new instance. + @Deprecated('Use [OrderedListWithCheckBoxSyntax] or [UnorderedListWithCheckBoxSyntax]') + TaskListSyntax() : super(_pattern); + + static const String _pattern = r'^ *\[([ xX])\] +'; + + @override + bool onMatch(md.InlineParser parser, Match match) { + final md.Element el = md.Element.withTag('input'); + el.attributes['type'] = 'checkbox'; + el.attributes['disabled'] = 'true'; + el.attributes['checked'] = '${match[1]!.trim().isNotEmpty}'; + parser.addNode(el); + return true; + } +} + +/// An interface for an padding builder for element. +abstract class MarkdownPaddingBuilder { + /// Called when an Element has been reached, before its children have been + /// visited. + void visitElementBefore(md.Element element) {} + + /// Called when a widget node has been rendering and need tag padding. + EdgeInsets getPadding() => EdgeInsets.zero; +} diff --git a/flutter_markdown_plus/pubspec.yaml b/flutter_markdown_plus/pubspec.yaml new file mode 100644 index 0000000..351a9d7 --- /dev/null +++ b/flutter_markdown_plus/pubspec.yaml @@ -0,0 +1,32 @@ +name: flutter_markdown_plus +description: A Markdown renderer for Flutter. Create rich text output, + including text styles, tables, links, and more, from plain text data + formatted with simple Markdown tags. +repository: https://github.com/foresightmobile/flutter_markdown_plus +issue_tracker: https://github.com/foresightmobile/flutter_markdown_plus/issues +version: 1.0.7 + +environment: + sdk: ^3.4.0 + flutter: ">=3.27.1" + +dependencies: + flutter: + sdk: flutter + markdown: ^7.3.0 + meta: ^1.16.0 + path: ^1.9.1 + +dev_dependencies: + flutter_test: + sdk: flutter + leak_tracker_flutter_testing: any + mockito: ^5.5.0 + standard_message_codec: ^0.0.1+4 + +topics: + - markdown + - widgets + +formatter: + page_width: 120 diff --git a/flutter_markdown_plus/test/all.dart b/flutter_markdown_plus/test/all.dart new file mode 100644 index 0000000..28338db --- /dev/null +++ b/flutter_markdown_plus/test/all.dart @@ -0,0 +1,45 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'blockquote_test.dart' as blockquote_test; +import 'custom_syntax_test.dart' as custome_syntax_test; +import 'emphasis_test.dart' as emphasis_test; +import 'footnote_test.dart' as footnote_test; +import 'header_test.dart' as header_test; +import 'horizontal_rule_test.dart' as horizontal_rule_test; +import 'html_test.dart' as html_test; +import 'image_test.dart' as image_test; +import 'line_break_test.dart' as line_break_test; +import 'link_test.dart' as link_test; +import 'list_test.dart' as list_test; +import 'scrollable_test.dart' as scrollable_test; +import 'selection_area_compatibility_test.dart' as selection_area_test; +import 'style_sheet_test.dart' as style_sheet_test; +import 'table_test.dart' as table_test; +import 'text_alignment_test.dart' as text_alignment_test; +import 'text_scaler_test.dart' as text_scaler; +import 'text_test.dart' as text_test; +import 'uri_test.dart' as uri_test; + +void main() { + blockquote_test.defineTests(); + custome_syntax_test.defineTests(); + emphasis_test.defineTests(); + footnote_test.defineTests(); + header_test.defineTests(); + horizontal_rule_test.defineTests(); + html_test.defineTests(); + image_test.defineTests(); + line_break_test.defineTests(); + link_test.defineTests(); + list_test.defineTests(); + scrollable_test.defineTests(); + selection_area_test.defineTests(); + style_sheet_test.defineTests(); + table_test.defineTests(); + text_test.defineTests(); + text_alignment_test.defineTests(); + text_scaler.defineTests(); + uri_test.defineTests(); +} diff --git a/flutter_markdown_plus/test/assets/images/golden/image_test/custom_builder_asset_logo.png b/flutter_markdown_plus/test/assets/images/golden/image_test/custom_builder_asset_logo.png new file mode 100644 index 0000000..671bba3 Binary files /dev/null and b/flutter_markdown_plus/test/assets/images/golden/image_test/custom_builder_asset_logo.png differ diff --git a/flutter_markdown_plus/test/assets/images/golden/image_test/resource_asset_logo.png b/flutter_markdown_plus/test/assets/images/golden/image_test/resource_asset_logo.png new file mode 100644 index 0000000..671bba3 Binary files /dev/null and b/flutter_markdown_plus/test/assets/images/golden/image_test/resource_asset_logo.png differ diff --git a/flutter_markdown_plus/test/assets/images/logo.png b/flutter_markdown_plus/test/assets/images/logo.png new file mode 100644 index 0000000..00357cb Binary files /dev/null and b/flutter_markdown_plus/test/assets/images/logo.png differ diff --git a/flutter_markdown_plus/test/blockquote_test.dart b/flutter_markdown_plus/test/blockquote_test.dart new file mode 100644 index 0000000..487bedc --- /dev/null +++ b/flutter_markdown_plus/test/blockquote_test.dart @@ -0,0 +1,106 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Blockquote', () { + testWidgets( + 'simple one word blockquote', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: '> quote'), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, ['quote']); + }, + ); + + testWidgets( + 'soft wrapping in blockquote', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: '> soft\n> wrap'), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, ['soft wrap']); + }, + ); + + testWidgets( + 'should work with styling', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith( + textTheme: textTheme, + ); + final MarkdownStyleSheet styleSheet = MarkdownStyleSheet.fromTheme( + theme, + ); + + const String data = + '> this is a link: [Markdown guide](https://www.markdownguide.org) and this is **bold** and *italic*'; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + styleSheet: styleSheet, + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final DecoratedBox blockQuoteContainer = tester.widget( + find.byType(DecoratedBox), + ); + final Text quoteText = tester.widget(find.byType(Text)); + final List styledTextParts = (quoteText.textSpan! as TextSpan).children!.cast(); + + expectTextStrings( + widgets, + ['this is a link: Markdown guide and this is bold and italic'], + ); + expect( + (blockQuoteContainer.decoration as BoxDecoration).color, + (styleSheet.blockquoteDecoration as BoxDecoration?)!.color, + ); + expect( + (blockQuoteContainer.decoration as BoxDecoration).borderRadius, + (styleSheet.blockquoteDecoration as BoxDecoration?)!.borderRadius, + ); + + /// this is a link + expect(styledTextParts[0].text, 'this is a link: '); + expect( + styledTextParts[0].style!.color, + theme.textTheme.bodyMedium!.color, + ); + + /// Markdown guide + expect(styledTextParts[1].text, 'Markdown guide'); + expect(styledTextParts[1].style!.color, styleSheet.blockquote!.color); + + /// and this is + expect( + styledTextParts[2].style!.color, + theme.textTheme.bodyMedium!.color, + ); + + /// bold + expect(styledTextParts[2].text, ' and this is bold and italic'); + expect(styledTextParts[2].style!.fontWeight, FontWeight.w400); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/custom_syntax_test.dart b/flutter_markdown_plus/test/custom_syntax_test.dart new file mode 100644 index 0000000..3e7974b --- /dev/null +++ b/flutter_markdown_plus/test/custom_syntax_test.dart @@ -0,0 +1,445 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:markdown/markdown.dart' as md; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Custom Syntax', () { + testWidgets( + 'Subscript', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + Markdown( + data: 'H_2O', + extensionSet: md.ExtensionSet.none, + inlineSyntaxes: [SubscriptSyntax()], + builders: { + 'sub': SubscriptBuilder(), + }, + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, ['H₂O']); + }, + ); + + testWidgets( + 'Custom block element', + (WidgetTester tester) async { + const String blockContent = 'note block'; + await tester.pumpWidget( + boilerplate( + Markdown( + data: '[!NOTE] $blockContent', + extensionSet: md.ExtensionSet.none, + blockSyntaxes: [NoteSyntax()], + builders: { + 'note': NoteBuilder(), + }, + ), + ), + ); + final ColoredBox container = tester.widgetList(find.byType(ColoredBox)).first as ColoredBox; + expect(container.color, Colors.red); + expect(container.child, isInstanceOf()); + expect((container.child! as Text).data, blockContent); + }, + ); + + testWidgets( + 'Block with custom tag', + (WidgetTester tester) async { + const String textBefore = 'Before '; + const String textAfter = ' After'; + const String blockContent = 'Custom content rendered in a ColoredBox'; + + await tester.pumpWidget( + boilerplate( + Markdown( + data: '$textBefore\n{{custom}}\n$blockContent\n{{/custom}}\n$textAfter', + extensionSet: md.ExtensionSet.none, + blockSyntaxes: [CustomTagBlockSyntax()], + builders: { + 'custom': CustomTagBlockBuilder(), + }, + ), + ), + ); + + final ColoredBox container = tester.widgetList(find.byType(ColoredBox)).first as ColoredBox; + expect(container.color, Colors.red); + expect(container.child, isInstanceOf()); + expect((container.child! as Text).data, blockContent); + }, + ); + + testWidgets( + 'link for wikistyle', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + Markdown( + data: 'This is a [[wiki link]]', + extensionSet: md.ExtensionSet.none, + inlineSyntaxes: [WikilinkSyntax()], + builders: { + 'wikilink': WikilinkBuilder(), + }, + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = (textWidget.textSpan! as TextSpan).children![1] as TextSpan; + + expect(span.children, null); + expect(span.recognizer.runtimeType, equals(TapGestureRecognizer)); + }, + ); + + testWidgets( + 'WidgetSpan in Text.rich is handled correctly', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + Markdown( + data: 'container is a widget that allows to customize its child', + extensionSet: md.ExtensionSet.none, + inlineSyntaxes: [ContainerSyntax()], + builders: { + 'container': ContainerBuilder(), + }, + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + final WidgetSpan widgetSpan = textSpan.children![0] as WidgetSpan; + expect(widgetSpan.child, isInstanceOf()); + }, + ); + + testWidgets( + 'visitElementAfterWithContext is handled correctly', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + Markdown( + data: r'# This is a header with some \color1{color} in it', + extensionSet: md.ExtensionSet.none, + inlineSyntaxes: [InlineTextColorSyntax()], + builders: { + 'inlineTextColor': InlineTextColorElementBuilder(), + }, + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan rootSpan = textWidget.textSpan! as TextSpan; + final TextSpan firstSpan = rootSpan.children![0] as TextSpan; + final TextSpan secondSpan = rootSpan.children![1] as TextSpan; + final TextSpan thirdSpan = rootSpan.children![2] as TextSpan; + + expect(secondSpan.style!.color, Colors.red); + expect(secondSpan.style!.fontSize, firstSpan.style!.fontSize); + expect(secondSpan.style!.fontSize, thirdSpan.style!.fontSize); + }, + ); + }); + + testWidgets( + 'TextSpan and WidgetSpan as children in Text.rich are handled correctly', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + Markdown( + data: 'this test replaces a string with a container', + extensionSet: md.ExtensionSet.none, + inlineSyntaxes: [ContainerSyntax()], + builders: { + 'container': ContainerBuilder2(), + }, + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + final TextSpan start = textSpan.children![0] as TextSpan; + expect(start.text, 'this test replaces a string with a '); + final TextSpan foo = textSpan.children![1] as TextSpan; + expect(foo.text, 'foo'); + final WidgetSpan widgetSpan = textSpan.children![2] as WidgetSpan; + expect(widgetSpan.child, isInstanceOf()); + }, + ); + + testWidgets( + 'Custom rendering of tags without children', + (WidgetTester tester) async { + const String data = '![alt](/assets/images/logo.png)'; + await tester.pumpWidget( + boilerplate( + Markdown( + data: data, + builders: { + 'img': ImgBuilder(), + }, + ), + ), + ); + + final Finder imageFinder = find.byType(Image); + expect(imageFinder, findsNothing); + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + final Text textWidget = tester.widget(find.byType(Text)); + expect(textWidget.data, 'foo'); + }, + ); +} + +class SubscriptSyntax extends md.InlineSyntax { + SubscriptSyntax() : super(_pattern); + + static const String _pattern = r'_([0-9]+)'; + + @override + bool onMatch(md.InlineParser parser, Match match) { + parser.addNode(md.Element.text('sub', match[1]!)); + return true; + } +} + +class SubscriptBuilder extends MarkdownElementBuilder { + static const List _subscripts = ['₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉']; + + @override + Widget visitElementAfter(md.Element element, _) { + // We don't currently have a way to control the vertical alignment of text spans. + // See https://github.com/flutter/flutter/issues/10906#issuecomment-385723664 + final String textContent = element.textContent; + String text = ''; + for (int i = 0; i < textContent.length; i++) { + text += _subscripts[int.parse(textContent[i])]; + } + return Text.rich(TextSpan(text: text)); + } +} + +class WikilinkSyntax extends md.InlineSyntax { + WikilinkSyntax() : super(_pattern); + + static const String _pattern = r'\[\[(.*?)\]\]'; + + @override + bool onMatch(md.InlineParser parser, Match match) { + final String link = match[1]!; + final md.Element el = md.Element('wikilink', [md.Element.text('span', link)]) + ..attributes['href'] = link.replaceAll(' ', '_'); + + parser.addNode(el); + return true; + } +} + +class WikilinkBuilder extends MarkdownElementBuilder { + @override + Widget visitElementAfter(md.Element element, _) { + final TapGestureRecognizer recognizer = TapGestureRecognizer()..onTap = () {}; + addTearDown(recognizer.dispose); + return Text.rich( + TextSpan(text: element.textContent, recognizer: recognizer), + ); + } +} + +class ContainerSyntax extends md.InlineSyntax { + ContainerSyntax() : super(_pattern); + + static const String _pattern = 'container'; + + @override + bool onMatch(md.InlineParser parser, Match match) { + parser.addNode( + md.Element.text('container', ''), + ); + return true; + } +} + +class ContainerBuilder extends MarkdownElementBuilder { + @override + Widget? visitElementAfter(md.Element element, _) { + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Container(), + ), + ], + ), + ); + } +} + +class ContainerBuilder2 extends MarkdownElementBuilder { + @override + Widget? visitElementAfter(md.Element element, _) { + return Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'foo'), + WidgetSpan( + child: Container(), + ), + ], + ), + ); + } +} + +// Note: The implementation of inline span is incomplete, it does not handle +// bold, italic, ... text with a colored block. +// This would not work: `\color1{Text with *bold* text}` +class InlineTextColorSyntax extends md.InlineSyntax { + InlineTextColorSyntax() : super(r'\\color([1-9]){(.*?)}'); + + @override + bool onMatch(md.InlineParser parser, Match match) { + final String colorId = match.group(1)!; + final String textContent = match.group(2)!; + final md.Element node = md.Element.text( + 'inlineTextColor', + textContent, + )..attributes['color'] = colorId; + + parser.addNode(node); + + parser.addNode( + md.Text(''), + ); + return true; + } +} + +class InlineTextColorElementBuilder extends MarkdownElementBuilder { + @override + Widget visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + final String innerText = element.textContent; + final String color = element.attributes['color'] ?? ''; + + final Map contentColors = { + '1': Colors.red, + '2': Colors.green, + '3': Colors.blue, + }; + final Color? contentColor = contentColors[color]; + + return Text.rich( + TextSpan( + text: innerText, + style: parentStyle?.copyWith(color: contentColor), + ), + ); + } +} + +class ImgBuilder extends MarkdownElementBuilder { + @override + Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { + return Text('foo', style: preferredStyle); + } +} + +class NoteBuilder extends MarkdownElementBuilder { + @override + Widget? visitText(md.Text text, TextStyle? preferredStyle) { + return ColoredBox(color: Colors.red, child: Text(text.text, style: preferredStyle)); + } + + @override + bool isBlockElement() { + return true; + } +} + +class NoteSyntax extends md.BlockSyntax { + @override + md.Node? parse(md.BlockParser parser) { + final md.Line line = parser.current; + parser.advance(); + return md.Element('note', [md.Text(line.content.substring(8))]); + } + + @override + RegExp get pattern => RegExp(r'^\[!NOTE] '); +} + +class CustomTagBlockBuilder extends MarkdownElementBuilder { + @override + bool isBlockElement() => true; + + @override + Widget visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + if (element.tag == 'custom') { + final String content = element.attributes['content']!; + return ColoredBox(color: Colors.red, child: Text(content, style: preferredStyle)); + } + return const SizedBox.shrink(); + } +} + +class CustomTagBlockSyntax extends md.BlockSyntax { + @override + bool canParse(md.BlockParser parser) { + return parser.current.content.startsWith('{{custom}}'); + } + + @override + RegExp get pattern => RegExp(r'\{\{custom\}\}([\s\S]*?)\{\{/custom\}\}'); + + @override + md.Node parse(md.BlockParser parser) { + parser.advance(); + + final StringBuffer buffer = StringBuffer(); + while (!parser.current.content.startsWith('{{/custom}}') && !parser.isDone) { + buffer.writeln(parser.current.content); + parser.advance(); + } + + if (!parser.isDone) { + parser.advance(); + } + + final String content = buffer.toString().trim(); + final md.Element element = md.Element.empty('custom'); + element.attributes['content'] = content; + return element; + } +} diff --git a/flutter_markdown_plus/test/emphasis_test.dart b/flutter_markdown_plus/test/emphasis_test.dart new file mode 100644 index 0000000..46e78e2 --- /dev/null +++ b/flutter_markdown_plus/test/emphasis_test.dart @@ -0,0 +1,4287 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +// The emphasis and strong emphasis section of the GitHub Flavored Markdown +// specification (https://github.github.com/gfm/#emphasis-and-strong-emphasis) +// is extensive covering over 130 example cases. The tests in this file cover +// all of the GFM tests; example 360 through 490. + +void main() => defineTests(); + +void defineTests() { + group( + 'Emphasis', + () { + group( + 'Rule 1', + () { + // Rule 1 tests check the single '*' can open emphasis. + testWidgets( + // Example 360 from GFM. + 'italic text using asterisk tags', + (WidgetTester tester) async { + const String data = '*foo bar*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 361 from GFM. + 'invalid left-flanking delimiter run because * is followed by whitespace', + (WidgetTester tester) async { + const String data = 'a * foo bar*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 362 from GFM. + 'invalid left-flanking delimiter run because * preceded by alphanumeric followed by punctuation', + (WidgetTester tester) async { + const String data = 'a*"foo bar"*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + // NOTE: Example 363 is not included. The test is "Unicode nonbreaking + // spaces count as whitespace, too: '* a *' The Markdown parse sees + // this as a unordered list item." https://github.github.com/gfm/#example-363 + + testWidgets( + // Example 364 from GFM. + 'intraword emphasis with * is permitted alpha characters', + (WidgetTester tester) async { + const String data = 'foo*bar*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + expect(textWidget, isNotNull); + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foobar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with no emphasis. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 365 from GFM. + 'intraword emphasis with * is permitted numeric characters', + (WidgetTester tester) async { + const String data = '5*6*78'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + expect(textWidget, isNotNull); + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '5678'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span is normal text with no emphasis. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Third text span is normal text with no emphasis. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + }, + ); + + group('Rule 2', () { + testWidgets( + // Example 366 from GFM. + 'italic text using underscore tags', + (WidgetTester tester) async { + const String data = '_foo bar_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 367 from GFM. + 'invalid left-flanking delimiter run because _ is followed by whitespace', + (WidgetTester tester) async { + const String data = '_ foo bar_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 368 from GFM. + 'invalid left-flanking delimiter run because _ preceded by alphanumeric followed by punctuation', + (WidgetTester tester) async { + const String data = 'a_"foo bar"_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 369 from GFM. + 'emphasis with _ is not allowed inside words alpha characters', + (WidgetTester tester) async { + const String data = 'foo_bar_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 370 from GFM. + 'emphasis with _ is not allowed inside words numeric characters', + (WidgetTester tester) async { + const String data = '5_6_78'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 371 from GFM. + 'emphasis with _ is not allowed inside words unicode characters', + (WidgetTester tester) async { + const String data = 'пристаням_стремятся_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 372 from GFM. + 'invalid first delimiter right-flanking followed by second delimiter left-flanking', + (WidgetTester tester) async { + const String data = 'aa_"bb"_cc'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 373 from GFM. + 'valid open delimiter left- and right-flanking preceded by punctuation', + (WidgetTester tester) async { + const String data = 'foo-_(bar)_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo-(bar)'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with no emphasis. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 3', () { + testWidgets( + // Example 374 from GFM. + 'invalid emphasis - closing delimiter does not match opening delimiter', + (WidgetTester tester) async { + const String data = '_foo*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 375 from GFM. + 'invalid emphasis - closing * is preceded by whitespace', + (WidgetTester tester) async { + const String data = '*foo bar *'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 376 from GFM. + 'invalid emphasis - closing * is preceded by newline', + (WidgetTester tester) async { + const String data = '*foo bar\n*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '*foo bar *'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 377 from GFM. + 'invalid emphasis - second * is preceded by punctuation followed by alphanumeric', + (WidgetTester tester) async { + const String data = '*(*foo)'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 378 from GFM. + 'nested * emphasis', + (WidgetTester tester) async { + const String data = '*(*foo*)*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '(foo)'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 379 from GFM. + 'intraword emphasis with * is allowed', + (WidgetTester tester) async { + const String data = '*foo*bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foobar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span is normal text with no emphasis. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 4', () { + testWidgets( + // Example 380 from GFM. + 'invalid emphasis because closing _ is preceded by whitespace', + (WidgetTester tester) async { + const String data = '_foo bar _'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 381 from GFM. + 'invalid emphasis because second _ is preceded by punctuation and followed by an alphanumeric', + (WidgetTester tester) async { + const String data = '_(_foo)'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 382 from GFM. + 'nested _ emphasis', + (WidgetTester tester) async { + const String data = '_(_foo_)_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '(foo)'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 383 from GFM. + 'intraword emphasis with _ is disallowed - alpha characters', + (WidgetTester tester) async { + const String data = '_foo_bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 384 from GFM. + 'intraword emphasis with _ is disallowed - unicode characters', + (WidgetTester tester) async { + const String data = '_пристаням_стремятся'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 385 from GFM. + 'intraword emphasis with _ is disallowed - nested emphasis tags', + (WidgetTester tester) async { + const String data = '_foo_bar_baz_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo_bar_baz'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 386 from GFM. + 'valid emphasis closing delimiter is both left- and right-flanking followed by punctuation', + (WidgetTester tester) async { + const String data = '_(bar)_.'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '(bar).'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span is normal text with no emphasis. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 5', () { + testWidgets( + // Example 387 from GFM. + 'strong emphasis using ** emphasis tags', + (WidgetTester tester) async { + const String data = '**foo bar**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 388 from GFM. + 'invalid strong emphasis - opening delimiter followed by whitespace', + (WidgetTester tester) async { + const String data = '** foo bar**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 389 from GFM. + 'invalid strong emphasis - opening ** is preceded by an alphanumeric and followed by punctuation', + (WidgetTester tester) async { + const String data = 'a**"foo"**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 390 from GFM. + 'intraword strong emphasis with ** is permitted', + (WidgetTester tester) async { + const String data = 'foo**bar**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foobar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with no emphasis. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + }); + + group('Rule 6', () { + testWidgets( + // Example 391 from GFM. + 'strong emphasis using __ emphasis tags', + (WidgetTester tester) async { + const String data = '__foo bar__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 392 from GFM. + 'invalid strong emphasis - opening delimiter followed by whitespace', + (WidgetTester tester) async { + const String data = '__ foo bar__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 393 from GFM. + 'invalid strong emphasis - opening delimiter followed by newline', + (WidgetTester tester) async { + const String data = '__\nfoo bar__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '__ foo bar__'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 394 from GFM. + 'invalid strong emphasis - opening __ is preceded by an alphanumeric and followed by punctuation', + (WidgetTester tester) async { + const String data = 'a__"foo"__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 395 from GFM. + 'intraword strong emphasis is forbidden with __ - alpha characters', + (WidgetTester tester) async { + const String data = 'foo__bar__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 396 from GFM. + 'intraword strong emphasis is forbidden with __ - numeric characters', + (WidgetTester tester) async { + const String data = '5__6__78'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 397 from GFM. + 'intraword strong emphasis is forbidden with __ - unicode characters', + (WidgetTester tester) async { + const String data = 'пристаням__стремятся__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 398 from GFM. + 'intraword strong emphasis is forbidden with __ - nested strong emphasis', + (WidgetTester tester) async { + const String data = '__foo, __bar__, baz__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo, bar, baz'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 399 from GFM. + 'valid strong emphasis because opening delimiter is both left- and right-flanking preceded by punctuation', + (WidgetTester tester) async { + const String data = 'foo-__(bar)__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo-(bar)'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with no emphasis. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + }); + + group('Rule 7', () { + testWidgets( + // Example 400 from GFM. + 'invalid strong emphasis - closing delimiter is preceded by whitespace', + (WidgetTester tester) async { + const String data = '**foo bar **'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 401 from GFM. + 'invalid strong emphasis - second ** is preceded by punctuation and followed by an alphanumeric', + (WidgetTester tester) async { + const String data = '**(**foo)'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 402 from GFM. + 'emphasis with nested strong emphasis', + (WidgetTester tester) async { + const String data = '*(**foo**)*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '(foo)'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has italic style with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 403 from GFM. + 'strong emphasis with multiple nested emphasis', + (WidgetTester tester) async { + const String data = '**Gomphocarpus (*Gomphocarpus physocarpus*, syn. *Asclepias physocarpa*)**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'Gomphocarpus (Gomphocarpus physocarpus, syn. Asclepias physocarpa)'); + + // There should be five spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 5, isTrue); + + // First text span has bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span has both italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has bold weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Fourth text span has both italic style with bold weight. + final InlineSpan fourthSpan = textSpan.children![3]; + expectTextSpanStyle( + fourthSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Fifth text span has bold weight. + final InlineSpan fifthSpan = textSpan.children![4]; + expectTextSpanStyle( + fifthSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 404 from GFM. + 'strong emphasis with nested emphasis', + (WidgetTester tester) async { + const String data = '**foo "*bar*" foo**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo "bar" foo'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span has bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span has both italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has bold weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 405 from GFM. + 'intraword strong emphasis', + (WidgetTester tester) async { + const String data = '**foo**bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foobar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with strong emphasis. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span is normal text with no emphasis. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 8', () { + testWidgets( + // Example 406 from GFM. + 'invalid strong emphasis - closing delimiter is preceded by whitespace', + (WidgetTester tester) async { + const String data = '__foo bar __'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 407 from GFM. + 'invalid strong emphasis - second __ is preceded by punctuation followed by alphanumeric', + (WidgetTester tester) async { + const String data = '__(__foo)'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 408 from GFM. + 'strong emphasis nested in emphasis', + (WidgetTester tester) async { + const String data = '_(__foo__)_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '(foo)'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has italic style with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 409 from GFM. + 'intraword strong emphasis is forbidden with __ - alpha characters', + (WidgetTester tester) async { + const String data = '__foo__bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 410 from GFM. + 'intraword strong emphasis is forbidden with __ - unicode characters', + (WidgetTester tester) async { + const String data = '__пристаням__стремятся'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 411 from GFM. + 'intraword nested strong emphasis is forbidden with __', + (WidgetTester tester) async { + const String data = '__foo__bar__baz__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo__bar__baz'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 412 from GFM. + 'strong emphasis because closing delimiter is both left- and right-flanking is followed by punctuation', + (WidgetTester tester) async { + const String data = '__(bar)__.'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '(bar).'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with strong emphasis. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 9', () { + testWidgets( + // Example 413 from GFM. + 'nonempty sequence emphasis span - text followed by link', + (WidgetTester tester) async { + const String data = '*foo [bar](/url)*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is plain text and has italic style with normal weight. + final TextSpan firstSpan = textSpan.children![0] as TextSpan; + expect(firstSpan.recognizer, isNull); + expectTextSpanStyle( + firstSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final TextSpan secondSpan = textSpan.children![1] as TextSpan; + expect(secondSpan.recognizer, isNotNull); + expect(secondSpan.recognizer is GestureRecognizer, isTrue); + expectTextSpanStyle( + secondSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 414 from GFM. + 'nonempty sequence emphasis span - two lines of text', + (WidgetTester tester) async { + const String data = '*foo\nbar*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 415 from GFM. + 'strong emphasis nested inside emphasis - _ delimiter', + (WidgetTester tester) async { + const String data = '_foo __bar__ baz_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar baz'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has italic style with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 416 from GFM. + 'emphasis nested inside emphasis', + (WidgetTester tester) async { + const String data = '_foo _bar_ baz_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar baz'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 417 from GFM. + 'intraword emphasis nested inside emphasis - _ delimiter', + (WidgetTester tester) async { + const String data = '__foo_ bar_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 418 from GFM. + 'intraword emphasis nested inside emphasis - * delimiter', + (WidgetTester tester) async { + const String data = '*foo *bar**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 419 from GFM. + 'strong emphasis nested inside emphasis - * delimiter', + (WidgetTester tester) async { + const String data = '*foo **bar** baz*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar baz'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has italic style with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 418 from GFM. + 'intraword strong emphasis nested inside emphasis - * delimiter', + (WidgetTester tester) async { + const String data = '*foo**bar**baz*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foobarbaz'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has italic style with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 421 from GFM. + 'consecutive emphasis sections are not allowed', + (WidgetTester tester) async { + const String data = '*foo**bar*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo**bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 422 from GFM. + 'strong emphasis nested inside emphasis - space after first word', + (WidgetTester tester) async { + const String data = '***foo** bar*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 423 from GFM. + 'strong emphasis nested inside emphasis - space before second word', + (WidgetTester tester) async { + const String data = '*foo **bar***'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 424 from GFM. + 'intraword strong emphasis nested inside emphasis', + (WidgetTester tester) async { + const String data = '*foo**bar***'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foobar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 425 from GFM. + 'intraword emphasis and strong emphasis', + (WidgetTester tester) async { + const String data = 'foo***bar***baz'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foobarbaz'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span is plain text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span is plain text with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 426 from GFM. + 'intraword emphasis and strong emphasis - multiples of 3', + (WidgetTester tester) async { + const String data = 'foo******bar*********baz'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foobar***baz'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span is plain text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span is plain text with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Third text span is plain text with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 427 from GFM. + 'infinite levels of nesting are possible within emphasis', + (WidgetTester tester) async { + const String data = '*foo **bar *baz*\nbim** bop*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar baz bim bop'); + + // There should be five spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length, 3); + + // First text span has italic style and normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has both italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has bold weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 428 from GFM. + 'infinite levels of nesting are possible within emphasis - text and a link', + (WidgetTester tester) async { + const String data = '*foo [*bar*](/url)*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style and normal weight. + final TextSpan firstSpan = textSpan.children![0] as TextSpan; + expect(firstSpan.recognizer, isNull); + expectTextSpanStyle( + firstSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final TextSpan secondSpan = textSpan.children![1] as TextSpan; + expect(secondSpan.recognizer, isNotNull); + expect(secondSpan.recognizer is GestureRecognizer, isTrue); + expectTextSpanStyle( + secondSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 429 from GFM. + 'there can be no empty emphasis * delimiter', + (WidgetTester tester) async { + const String data = '** is not an empty emphasis'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 430 from GFM. + 'there can be no empty strong emphasis * delimiter', + (WidgetTester tester) async { + const String data = '**** is not an empty strong emphasis'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 10', () { + testWidgets( + // Example 431 from GFM. + 'nonempty sequence of inline elements with strong emphasis - text and a link', + (WidgetTester tester) async { + const String data = '**foo [bar](/url)**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with bold weight. + final TextSpan firstSpan = textSpan.children![0] as TextSpan; + expect(firstSpan.recognizer, isNull); + expectTextSpanStyle( + firstSpan, + null, + FontWeight.bold, + ); + + // Second span is a link with bold weight. + final TextSpan secondSpan = textSpan.children![1] as TextSpan; + expect(secondSpan.recognizer, isNotNull); + expect(secondSpan.recognizer is GestureRecognizer, isTrue); + expectTextSpanStyle( + secondSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 432 from GFM. + 'nonempty sequence of inline elements with strong emphasis - two lines of texts', + (WidgetTester tester) async { + const String data = '**foo\nbar**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 433 from GFM. + 'emphasis and strong emphasis nested inside strong emphasis - nested emphasis', + (WidgetTester tester) async { + const String data = '__foo _bar_ baz__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar baz'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span is plain text with bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span is plain text with bold weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 434 from GFM. + 'emphasis and strong emphasis nested inside strong emphasis - nested strong emphasis', + (WidgetTester tester) async { + const String data = '__foo __bar__ baz__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar baz'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 435 from GFM. + 'emphasis and strong emphasis nested inside strong emphasis - nested strong emphasis', + (WidgetTester tester) async { + const String data = '____foo__ bar__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 436 from GFM. + 'emphasis and strong emphasis nested inside strong emphasis - nested strong emphasis', + (WidgetTester tester) async { + const String data = '**foo **bar****'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 437 from GFM. + 'emphasis and strong emphasis nested inside strong emphasis - nested emphasis', + (WidgetTester tester) async { + const String data = '**foo *bar* baz**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar baz'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span is plain text with bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span is plain text with bold weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 438 from GFM. + 'emphasis and strong emphasis nested inside strong emphasis - intraword nested emphasis', + (WidgetTester tester) async { + const String data = '**foo*bar*baz**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foobarbaz'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span is plain text with bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span is plain text with bold weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 439 from GFM. + 'emphasis and strong emphasis nested inside strong emphasis - nested emphasis on first word', + (WidgetTester tester) async { + const String data = '***foo* bar**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Second span is plain text with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 440 from GFM. + 'emphasis and strong emphasis nested inside strong emphasis - nested emphasis on second word', + (WidgetTester tester) async { + const String data = '**foo *bar***'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is plain text with bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 441 from GFM. + 'infinite levels of nesting are possible within strong emphasis', + (WidgetTester tester) async { + const String data = '**foo *bar **baz**\nbim* bop**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar baz bim bop'); + + // There should be five spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length, 3); + + // First text span is plain text with bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span has both italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has both italic style with bold weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 442 from GFM. + 'infinite levels of nesting are possible within strong emphasis - text and a link', + (WidgetTester tester) async { + const String data = '**foo [*bar*](/url)**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is plain text and bold weight. + final TextSpan firstSpan = textSpan.children![0] as TextSpan; + expect(firstSpan.recognizer, isNull); + expectTextSpanStyle( + firstSpan, + null, + FontWeight.bold, + ); + + // Second span has both italic style with normal weight. + final TextSpan secondSpan = textSpan.children![1] as TextSpan; + expect(secondSpan.recognizer, isNotNull); + expect(secondSpan.recognizer is GestureRecognizer, isTrue); + expectTextSpanStyle( + secondSpan, + FontStyle.italic, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 443 from GFM. + 'there can be no empty emphasis _ delimiter', + (WidgetTester tester) async { + const String data = '__ is not an empty emphasis'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 444 from GFM. + 'there can be no empty strong emphasis _ delimiter', + (WidgetTester tester) async { + const String data = '____ is not an empty strong emphasis'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 11', () { + testWidgets( + // Example 445 from GFM. + 'an * cannot occur at the beginning or end of * delimited emphasis', + (WidgetTester tester) async { + const String data = 'foo ***'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 446 from GFM. + 'an escaped * can occur inside * delimited emphasis', + (WidgetTester tester) async { + const String data = r'foo *\**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo *'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 446 from GFM. + 'an _ can occur inside * delimited emphasis', + (WidgetTester tester) async { + const String data = 'foo *_*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo _'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 448 from GFM. + 'an * cannot occur at the beginning or end of ** delimited strong emphasis', + (WidgetTester tester) async { + const String data = 'foo *****'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 449 from GFM. + 'an escaped * can occur inside ** delimited strong emphasis', + (WidgetTester tester) async { + const String data = r'foo **\***'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo *'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span is normal text with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 450 from GFM. + 'an _ can occur inside ** delimited strong emphasis', + (WidgetTester tester) async { + const String data = 'foo **_**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo _'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span is normal text with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 451 from GFM. + 'unmatched emphasis delimiters excess * at beginning', + (WidgetTester tester) async { + const String data = '**foo*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '*foo'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 452 from GFM. + 'unmatched emphasis delimiters excess * at end', + (WidgetTester tester) async { + const String data = '*foo**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo*'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span is normal text with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 453 from GFM. + 'unmatched strong emphasis delimiters excess * at beginning', + (WidgetTester tester) async { + const String data = '***foo**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '*foo'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span is normal text with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 454 from GFM. + 'unmatched strong emphasis delimiters excess * at beginning', + (WidgetTester tester) async { + const String data = '****foo*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '***foo'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 455 from GFM. + 'unmatched strong emphasis delimiters excess * at end', + (WidgetTester tester) async { + const String data = '**foo***'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo*'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span is plain text with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 456 from GFM. + 'unmatched strong emphasis delimiters excess * at end', + (WidgetTester tester) async { + const String data = '*foo****'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo***'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span is plain text with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 12', () { + testWidgets( + // Example 457 from GFM. + 'an _ cannot occur at the beginning or end of _ delimited emphasis', + (WidgetTester tester) async { + const String data = 'foo ___'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 458 from GFM. + 'an escaped _ can occur inside _ delimited emphasis', + (WidgetTester tester) async { + const String data = r'foo _\__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo _'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 459 from GFM. + 'an * can occur inside _ delimited emphasis', + (WidgetTester tester) async { + const String data = 'foo _*_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo *'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 460 from GFM. + 'an _ cannot occur at the beginning or end of __ delimited strong emphasis', + (WidgetTester tester) async { + const String data = 'foo _____'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, data); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 461 from GFM. + 'an escaped _ can occur inside __ delimited strong emphasis', + (WidgetTester tester) async { + const String data = r'foo __\___'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo _'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span is normal text with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 462 from GFM. + 'an * can occur inside __ delimited strong emphasis', + (WidgetTester tester) async { + const String data = 'foo __*__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo *'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span is normal text with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 463 from GFM. + 'unmatched emphasis delimiters excess _ at beginning', + (WidgetTester tester) async { + const String data = '__foo_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '_foo'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 464 from GFM. + 'unmatched emphasis delimiters excess _ at end', + (WidgetTester tester) async { + const String data = '_foo__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo_'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span is normal text with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 465 from GFM. + 'unmatched strong emphasis delimiters excess _ at beginning', + (WidgetTester tester) async { + const String data = '___foo__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '_foo'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span is normal text with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 466 from GFM. + 'unmatched strong emphasis delimiters excess _ at beginning', + (WidgetTester tester) async { + const String data = '____foo_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '___foo'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 467 from GFM. + 'unmatched strong emphasis delimiters excess _ at end', + (WidgetTester tester) async { + const String data = '__foo___'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo_'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is normal text with bold weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.bold, + ); + + // Second span is plain text with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 468 from GFM. + 'unmatched strong emphasis delimiters excess _ at end', + (WidgetTester tester) async { + const String data = '_foo____'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + // Expect text to be unchanged from original data string. + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo___'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span is plain text with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 13', () { + testWidgets( + // Example 469 from GFM. + 'nested delimiters must be different - nested * is strong emphasis', + (WidgetTester tester) async { + const String data = '**foo**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 470 from GFM. + 'nested delimiters must be different - nest _ in * emphasis', + (WidgetTester tester) async { + const String data = '*_foo_*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 471 from GFM. + 'nested delimiters must be different - nested _ is strong emphasis', + (WidgetTester tester) async { + const String data = '__foo__'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 472 from GFM. + 'nested delimiters must be different - nest * in _ emphasis', + (WidgetTester tester) async { + const String data = '_*foo*_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 473 from GFM. + 'nested delimiters must be different - nested * strong emphasis', + (WidgetTester tester) async { + const String data = '****foo****'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 474 from GFM. + 'nested delimiters must be different - nested _ strong emphasis', + (WidgetTester tester) async { + const String data = '____foo____'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 475 from GFM. + 'nested delimiters must be different - long sequence of * delimiters', + (WidgetTester tester) async { + const String data = '******foo******'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + }); + + // Rule 14 doesn't make any difference to flutter_markdown_plus but tests for + // rule 14 are included here for completeness. + group('Rule 14', () { + testWidgets( + // Example 476 from GFM. + 'font style and weight order * delimiter', + (WidgetTester tester) async { + const String data = '***foo***'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 476 from GFM. + 'font style and weight order _ delimiter', + (WidgetTester tester) async { + const String data = '_____foo_____'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo'); + + expectTextSpanStyle( + textWidget.textSpan! as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + }, + ); + }); + + group('Rule 15', () { + testWidgets( + // Example 478 from GFM. + 'overlapping * and _ emphasis delimiters', + (WidgetTester tester) async { + const String data = '*foo _bar* baz_'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo _bar baz_'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span is plain text with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.normal, + ); + }, + ); + + testWidgets( + // Example 479 from GFM. + 'overlapping * and __ emphasis delimiters', + (WidgetTester tester) async { + const String data = '*foo __bar *baz bim__ bam*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo bar *baz bim bam'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + + // Second span has italic style with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.bold, + ); + + // Third text span has italic style with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle( + thirdSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 16', () { + testWidgets( + // Example 480 from GFM. + 'overlapping ** strong emphasis delimiters', + (WidgetTester tester) async { + const String data = '**foo **bar baz**'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '**foo bar baz'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is plain text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span is plain text with bold weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + null, + FontWeight.bold, + ); + }, + ); + + testWidgets( + // Example 479 from GFM. + 'overlapping * emphasis delimiters', + (WidgetTester tester) async { + const String data = '*foo *bar baz*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, '*foo bar baz'); + + // There should be two spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 2, isTrue); + + // First text span is plain text with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle( + firstSpan as TextSpan, + null, + FontWeight.normal, + ); + + // Second span has italic style with normal weight. + final InlineSpan secondSpan = textSpan.children![1]; + expectTextSpanStyle( + secondSpan as TextSpan, + FontStyle.italic, + FontWeight.normal, + ); + }, + ); + }); + + group('Rule 17', () { + // The markdown package does not follow rule 17. Sam Rawlins made the + // following comment on issue #280 on March 7, 2020: + // + // In terms of the spec, we are not following Rule 17 of "Emphasis and + // strong emphasis." Inline code spans, links, images, and HTML tags + // group more tightly than emphasis. Currently the Dart package respects + // the broader rule that any time we can close a tag, we do, attempting + // in the order of most recent openings first. I don't think this is + // terribly hard to correct. + // https://github.com/dart-lang/markdown/issues/280 + // + // Test for rule 17 are not included since markdown package is not + // following the rule. + }, skip: 'No Rule 17 tests implemented'); + }, + ); +} diff --git a/flutter_markdown_plus/test/flutter_test_config.dart b/flutter_markdown_plus/test/flutter_test_config.dart new file mode 100644 index 0000000..9907e57 --- /dev/null +++ b/flutter_markdown_plus/test/flutter_test_config.dart @@ -0,0 +1,13 @@ +// Copyright 2013 The Flutter Authors. 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:async'; + +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + LeakTesting.enable(); + LeakTracking.warnForUnsupportedPlatforms = false; + await testMain(); +} diff --git a/flutter_markdown_plus/test/footnote_test.dart b/flutter_markdown_plus/test/footnote_test.dart new file mode 100644 index 0000000..cb47ea7 --- /dev/null +++ b/flutter_markdown_plus/test/footnote_test.dart @@ -0,0 +1,281 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group( + 'structure', + () { + testWidgets( + 'footnote is detected and handle correctly', + (WidgetTester tester) async { + const String data = 'Foo[^a]\n[^a]: Bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody( + data: data, + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, [ + 'Foo1', + '1.', + 'Bar ↩', + ]); + }, + ); + + testWidgets( + 'footnote is detected and handle correctly for selectable markdown', + (WidgetTester tester) async { + const String data = 'Foo[^a]\n[^a]: Bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody( + data: data, + selectable: true, + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, [ + 'Foo1', + '1.', + 'Bar ↩', + ]); + }, + ); + + testWidgets( + 'ignore footnotes without description', + (WidgetTester tester) async { + const String data = 'Foo[^1] Bar[^2]\n[^1]: Bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody( + data: data, + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, [ + 'Foo1 Bar[^2]', + '1.', + 'Bar ↩', + ]); + }, + ); + testWidgets( + 'ignore superscripts and footnotes order', + (WidgetTester tester) async { + const String data = '[^2]: Bar \n [^1]: Foo \n Foo[^f] Bar[^b]'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody( + data: data, + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, [ + 'Foo1 Bar2', + '1.', + 'Foo ↩', + '2.', + 'Bar ↩', + ]); + }, + ); + + testWidgets( + 'handle two digits superscript', + (WidgetTester tester) async { + const String data = ''' +1[^1] 2[^2] 3[^3] 4[^4] 5[^5] 6[^6] 7[^7] 8[^8] 9[^9] 10[^10] +[^1]:1 +[^2]:2 +[^3]:3 +[^4]:4 +[^5]:5 +[^6]:6 +[^7]:7 +[^8]:8 +[^9]:9 +[^10]:10 +'''; + await tester.pumpWidget( + boilerplate( + const MarkdownBody( + data: data, + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '11 22 33 44 55 66 77 88 99 1010', + '1.', + '1 ↩', + '2.', + '2 ↩', + '3.', + '3 ↩', + '4.', + '4 ↩', + '5.', + '5 ↩', + '6.', + '6 ↩', + '7.', + '7 ↩', + '8.', + '8 ↩', + '9.', + '9 ↩', + '10.', + '10 ↩', + ]); + }, + ); + }, + ); + + group( + 'superscript textstyle replacing', + () { + testWidgets( + 'superscript has correct default fontfeature', + (WidgetTester tester) async { + const String data = 'Foo[^a]\n[^a]: Bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody( + data: data, + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Text text = widgets.firstWhere((Widget widget) => widget is Text) as Text; + + final TextSpan span = text.textSpan! as TextSpan; + final List? children = span.children; + + expect(children, isNotNull); + expect(children!.length, 2); + expect(children[1].style, isNotNull); + expect(children[1].style!.fontFeatures?.length, 1); + expect(children[1].style!.fontFeatures?.first.feature, 'sups'); + }, + ); + + testWidgets( + 'superscript has correct custom fontfeature', + (WidgetTester tester) async { + const String data = 'Foo[^a]\n[^a]: Bar'; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + styleSheet: MarkdownStyleSheet(superscriptFontFeatureTag: 'numr'), + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Text text = widgets.firstWhere((Widget widget) => widget is Text) as Text; + + final TextSpan span = text.textSpan! as TextSpan; + final List? children = span.children; + + expect(children, isNotNull); + expect(children!.length, 2); + expect(children[1].style, isNotNull); + expect(children[1].style!.fontFeatures?.length, 2); + expect(children[1].style!.fontFeatures?[1].feature, 'numr'); + }, + ); + + testWidgets( + 'superscript index has the same font style like text', + (WidgetTester tester) async { + const String data = '# Foo[^a]\n[^a]: Bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody( + data: data, + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Text text = widgets.firstWhere((Widget widget) => widget is Text) as Text; + + final TextSpan span = text.textSpan! as TextSpan; + final List? children = span.children; + + expect(children![0].style, isNotNull); + expect(children[1].style!.fontSize, children[0].style!.fontSize); + expect(children[1].style!.fontFamily, children[0].style!.fontFamily); + expect(children[1].style!.fontStyle, children[0].style!.fontStyle); + expect(children[1].style!.fontSize, children[0].style!.fontSize); + }, + ); + + testWidgets( + 'link is correctly copied to new superscript index', + (WidgetTester tester) async { + final List linkTapResults = []; + const String data = 'Foo[^a]\n[^a]: Bar'; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => + linkTapResults.add(MarkdownLink(text, href, title)), + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Text text = widgets.firstWhere((Widget widget) => widget is Text) as Text; + + final TextSpan span = text.textSpan! as TextSpan; + + final List gestureRecognizerTypes = []; + span.visitChildren((InlineSpan inlineSpan) { + if (inlineSpan is TextSpan) { + final TapGestureRecognizer? recognizer = inlineSpan.recognizer as TapGestureRecognizer?; + gestureRecognizerTypes.add(recognizer?.runtimeType ?? Null); + if (recognizer != null) { + recognizer.onTap!(); + } + } + return true; + }); + + expect(span.children!.length, 2); + expect( + gestureRecognizerTypes, + orderedEquals([Null, TapGestureRecognizer]), + ); + expectLinkTap(linkTapResults[0], const MarkdownLink('1', '#fn-a')); + }, + ); + }, + ); +} diff --git a/flutter_markdown_plus/test/header_test.dart b/flutter_markdown_plus/test/header_test.dart new file mode 100644 index 0000000..470068c --- /dev/null +++ b/flutter_markdown_plus/test/header_test.dart @@ -0,0 +1,35 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Header', () { + testWidgets( + 'level one', + (WidgetTester tester) async { + const String data = '# Header'; + await tester.pumpWidget(boilerplate(const MarkdownBody(data: data))); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + Wrap, + Text, + RichText, + ]); + expectTextStrings(widgets, ['Header']); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/horizontal_rule_test.dart b/flutter_markdown_plus/test/horizontal_rule_test.dart new file mode 100644 index 0000000..03a465a --- /dev/null +++ b/flutter_markdown_plus/test/horizontal_rule_test.dart @@ -0,0 +1,88 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Horizontal Rule', () { + testWidgets( + '3 consecutive hyphens', + (WidgetTester tester) async { + const String data = '---'; + await tester.pumpWidget(boilerplate(const MarkdownBody(data: data))); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [MarkdownBody, Container, DecoratedBox, Padding, LimitedBox, ConstrainedBox]); + }, + ); + + testWidgets( + '5 consecutive hyphens', + (WidgetTester tester) async { + const String data = '-----'; + await tester.pumpWidget(boilerplate(const MarkdownBody(data: data))); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [MarkdownBody, Container, DecoratedBox, Padding, LimitedBox, ConstrainedBox]); + }, + ); + + testWidgets( + '3 asterisks separated with spaces', + (WidgetTester tester) async { + const String data = '* * *'; + await tester.pumpWidget(boilerplate(const MarkdownBody(data: data))); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [MarkdownBody, Container, DecoratedBox, Padding, LimitedBox, ConstrainedBox]); + }, + ); + + testWidgets( + '3 asterisks separated with spaces alongside text Markdown', + (WidgetTester tester) async { + const String data = '# h1\n ## h2\n* * *'; + await tester.pumpWidget(boilerplate(const MarkdownBody(data: data))); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + Column, + Wrap, + Text, + RichText, + SizedBox, + Column, + Wrap, + Text, + RichText, + SizedBox, + Container, + DecoratedBox, + Padding, + LimitedBox, + ConstrainedBox + ]); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/html_test.dart b/flutter_markdown_plus/test/html_test.dart new file mode 100644 index 0000000..283a46b --- /dev/null +++ b/flutter_markdown_plus/test/html_test.dart @@ -0,0 +1,55 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('HTML', () { + testWidgets( + 'ignore tags', + (WidgetTester tester) async { + final List data = [ + 'Line 1\n

HTML content

\nLine 2', + 'Line 1\n<\nLine 2' + ]; + + for (final String line in data) { + await tester.pumpWidget(boilerplate(MarkdownBody(data: line))); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, ['Line 1', 'Line 2']); + } + }, + ); + + testWidgets( + "doesn't convert & to & when parsing", + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + const Markdown(data: '&'), + ), + ); + expectTextStrings(tester.allWidgets, ['&']); + }, + ); + + testWidgets( + "doesn't convert < to < when parsing", + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + const Markdown(data: '<'), + ), + ); + expectTextStrings(tester.allWidgets, ['<']); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/image_test.dart b/flutter_markdown_plus/test/image_test.dart new file mode 100644 index 0000000..46fc7d4 --- /dev/null +++ b/flutter_markdown_plus/test/image_test.dart @@ -0,0 +1,448 @@ +// Copyright 2013 The Flutter Authors. 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:io' as io; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'image_test_mocks.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Image', () { + setUp(() { + // Only needs to be done once since the HttpClient generated + // by this override is cached as a static singleton. + io.HttpOverrides.global = TestHttpOverrides(); + }); + + testWidgets( + 'should not interrupt styling', + (WidgetTester tester) async { + const String data = '_textbefore ![alt](https://img) textafter_'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Iterable texts = tester.widgetList(find.byType(Text)); + final Text firstTextWidget = texts.first; + final TextSpan firstTextSpan = firstTextWidget.textSpan! as TextSpan; + final Image image = tester.widget(find.byType(Image)); + final NetworkImage networkImage = image.image as NetworkImage; + final Text secondTextWidget = texts.last; + final TextSpan secondTextSpan = secondTextWidget.textSpan! as TextSpan; + + expect(firstTextSpan.text, 'textbefore '); + expect(firstTextSpan.style!.fontStyle, FontStyle.italic); + expect(networkImage.url, 'https://img'); + expect(secondTextSpan.text, ' textafter'); + expect(secondTextSpan.style!.fontStyle, FontStyle.italic); + }, + ); + + testWidgets( + 'should work with a link', + (WidgetTester tester) async { + const String data = '![alt](https://img#50x50)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Image image = tester.widget(find.byType(Image)); + final NetworkImage networkImage = image.image as NetworkImage; + expect(networkImage.url, 'https://img'); + expect(image.width, 50); + expect(image.height, 50); + }, + ); + + testWidgets( + 'should work with relative remote image', + (WidgetTester tester) async { + const String data = '![alt](/img.png)'; + await tester.pumpWidget( + boilerplate( + const Markdown( + data: data, + imageDirectory: 'https://localhost', + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image; + + expect(image.image is NetworkImage, isTrue); + expect((image.image as NetworkImage).url, 'https://localhost/img.png'); + }, + ); + + testWidgets( + 'local files should be files on non-web', + (WidgetTester tester) async { + const String data = '![alt](http.png)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image; + + expect(image.image is FileImage, isTrue); + }, + skip: kIsWeb || isLinux, + ); + + testWidgets( + 'local files should be network on web', + (WidgetTester tester) async { + const String data = '![alt](http.png)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image; + + expect(image.image is NetworkImage, isTrue); + }, + skip: !kIsWeb || !isLinux, + ); + + testWidgets( + 'should work with resources', + (WidgetTester tester) async { + TestWidgetsFlutterBinding.ensureInitialized(); + const String data = '![alt](resource:assets/logo.png)'; + await tester.pumpWidget( + boilerplate( + MaterialApp( + home: DefaultAssetBundle( + bundle: TestAssetBundle(), + child: Center( + child: Container( + color: Colors.white, + width: 500, + child: const Markdown( + data: data, + ), + ), + ), + ), + ), + ), + ); + + final Image image = tester.allWidgets.firstWhere((Widget widget) => widget is Image) as Image; + + expect(image.image is AssetImage, isTrue); + expect((image.image as AssetImage).assetName, 'assets/logo.png'); + + // Force the asset image to be rasterized so it can be compared. + await tester.runAsync(() async { + final Element element = tester.element(find.byType(Markdown)); + await precacheImage(image.image, element); + }); + + await tester.pumpAndSettle(); + + await expectLater( + find.byType(Container), matchesGoldenFile('assets/images/golden/image_test/resource_asset_logo.png')); + }, + skip: kIsWeb || isLinux, // Goldens are platform-specific. + ); + + testWidgets( + 'should work with local image files', + (WidgetTester tester) async { + const String data = '![alt](img.png#50x50)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Image image = tester.widget(find.byType(Image)); + final FileImage fileImage = image.image as FileImage; + expect(fileImage.file.path, 'img.png'); + expect(image.width, 50); + expect(image.height, 50); + }, + skip: kIsWeb || isLinux, + ); + + testWidgets( + 'should show properly next to text', + (WidgetTester tester) async { + const String data = 'Hello ![alt](img#50x50)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Text text = tester.widget(find.byType(Text)); + final TextSpan textSpan = text.textSpan! as TextSpan; + expect(textSpan.text, 'Hello '); + expect(textSpan.style, isNotNull); + }, + ); + + testWidgets( + 'should work when nested in a link', + (WidgetTester tester) async { + final List tapTexts = []; + final List tapResults = []; + const String data = '[![alt](https://img#50x50)](href)'; + await tester.pumpWidget( + boilerplate( + Markdown( + data: data, + onTapLink: (String text, String? value, String title) { + tapTexts.add(text); + tapResults.add(value); + }, + ), + ), + ); + + final GestureDetector detector = tester.widget(find.byType(GestureDetector)); + detector.onTap!(); + + expect(tapTexts.length, 1); + expect(tapTexts, everyElement('alt')); + expect(tapResults.length, 1); + expect(tapResults, everyElement('href')); + }, + ); + + testWidgets( + 'should work when nested in a link with text', + (WidgetTester tester) async { + final List tapTexts = []; + final List tapResults = []; + const String data = '[Text before ![alt](https://img#50x50) text after](href)'; + await tester.pumpWidget( + boilerplate( + Markdown( + data: data, + onTapLink: (String text, String? value, String title) { + tapTexts.add(text); + tapResults.add(value); + }, + ), + ), + ); + + final GestureDetector detector = tester.widget(find.byType(GestureDetector)); + detector.onTap!(); + + final Iterable texts = tester.widgetList(find.byType(Text)); + final Text firstTextWidget = texts.first; + final TextSpan firstSpan = firstTextWidget.textSpan! as TextSpan; + (firstSpan.recognizer as TapGestureRecognizer?)!.onTap!(); + + final Text lastTextWidget = texts.last; + final TextSpan lastSpan = lastTextWidget.textSpan! as TextSpan; + (lastSpan.recognizer as TapGestureRecognizer?)!.onTap!(); + + expect(firstSpan.children, null); + expect(firstSpan.text, 'Text before '); + expect(firstSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); + + expect(lastSpan.children, null); + expect(lastSpan.text, ' text after'); + expect(lastSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); + + expect(tapTexts.length, 3); + expect(tapTexts, everyElement('Text before alt text after')); + expect(tapResults.length, 3); + expect(tapResults, everyElement('href')); + }, + ); + + testWidgets( + 'should work alongside different links', + (WidgetTester tester) async { + final List tapTexts = []; + final List tapResults = []; + const String data = '[Link before](firstHref)[![alt](https://img#50x50)](imageHref)[link after](secondHref)'; + + await tester.pumpWidget( + boilerplate( + Markdown( + data: data, + onTapLink: (String text, String? value, String title) { + tapTexts.add(text); + tapResults.add(value); + }, + ), + ), + ); + + final Iterable texts = tester.widgetList(find.byType(Text)); + final Text firstTextWidget = texts.first; + final TextSpan firstSpan = firstTextWidget.textSpan! as TextSpan; + (firstSpan.recognizer as TapGestureRecognizer?)!.onTap!(); + + final GestureDetector detector = tester.widget(find.byType(GestureDetector)); + detector.onTap!(); + + final Text lastTextWidget = texts.last; + final TextSpan lastSpan = lastTextWidget.textSpan! as TextSpan; + (lastSpan.recognizer as TapGestureRecognizer?)!.onTap!(); + + expect(firstSpan.children, null); + expect(firstSpan.text, 'Link before'); + expect(firstSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); + + expect(lastSpan.children, null); + expect(lastSpan.text, 'link after'); + expect(lastSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); + + expect(tapTexts.length, 3); + expect(tapTexts, ['Link before', 'alt', 'link after']); + expect(tapResults.length, 3); + expect(tapResults, ['firstHref', 'imageHref', 'secondHref']); + }, + ); + + testWidgets( + 'should gracefully handle image URLs with empty scheme', + (WidgetTester tester) async { + const String data = '![alt](://img#x50)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + expect(find.byType(Image), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'should gracefully handle image URLs with invalid scheme', + (WidgetTester tester) async { + const String data = '![alt](ttps://img#x50)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + // On the web, any URI with an unrecognized scheme is treated as a network image. + // Thus the error builder of the Image widget is called. + // On non-web, any URI with an unrecognized scheme is treated as a file image. + // However, constructing a file from an invalid URI will throw an exception. + // Thus the Image widget is never created, nor is its error builder called. + if (kIsWeb || isLinux) { + expect(find.byType(Image), findsOneWidget); + } else { + expect(find.byType(Image), findsNothing); + } + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'should gracefully handle width parsing failures', + (WidgetTester tester) async { + const String data = '![alt](https://img#x50)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Image image = tester.widget(find.byType(Image)); + final NetworkImage networkImage = image.image as NetworkImage; + expect(networkImage.url, 'https://img'); + expect(image.width, null); + expect(image.height, 50); + }, + ); + + testWidgets( + 'should gracefully handle height parsing failures', + (WidgetTester tester) async { + const String data = ' ![alt](https://img#50x)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Image image = tester.widget(find.byType(Image)); + final NetworkImage networkImage = image.image as NetworkImage; + expect(networkImage.url, 'https://img'); + expect(image.width, 50); + expect(image.height, null); + }, + ); + + testWidgets( + 'custom image builder', + (WidgetTester tester) async { + const String data = '![alt](https://img.png)'; + Widget builder(Uri uri, String? title, String? alt) => Image.asset('assets/logo.png'); + + await tester.pumpWidget( + boilerplate( + MaterialApp( + home: DefaultAssetBundle( + bundle: TestAssetBundle(), + child: Center( + child: Container( + color: Colors.white, + width: 500, + child: Markdown( + data: data, + imageBuilder: builder, + ), + ), + ), + ), + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image; + + expect(image.image.runtimeType, AssetImage); + expect((image.image as AssetImage).assetName, 'assets/logo.png'); + + // Force the asset image to be rasterized so it can be compared. + await tester.runAsync(() async { + final Element element = tester.element(find.byType(Markdown)); + await precacheImage(image.image, element); + }); + + await tester.pumpAndSettle(); + + await expectLater( + find.byType(Container), matchesGoldenFile('assets/images/golden/image_test/custom_builder_asset_logo.png')); + imageCache.clear(); + }, + skip: kIsWeb || isLinux, // Goldens are platform-specific. + ); + }); +} diff --git a/flutter_markdown_plus/test/image_test_mocks.dart b/flutter_markdown_plus/test/image_test_mocks.dart new file mode 100644 index 0000000..1ab6d1a --- /dev/null +++ b/flutter_markdown_plus/test/image_test_mocks.dart @@ -0,0 +1,375 @@ +// Copyright 2013 The Flutter Authors. 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:async'; +import 'dart:io'; + +import 'package:mockito/mockito.dart'; + +class TestHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return createMockImageHttpClient(context); + } +} + +MockHttpClient createMockImageHttpClient(SecurityContext? _) { + final MockHttpClient client = MockHttpClient(); + final MockHttpClientRequest request = MockHttpClientRequest(); + final MockHttpClientResponse response = MockHttpClientResponse(); + final MockHttpHeaders headers = MockHttpHeaders(); + + final List transparentImage = getTestImageData(); + + when(client.getUrl(any)).thenAnswer((_) => Future.value(request)); + + when(request.headers).thenReturn(headers); + + when(request.close()).thenAnswer((_) => Future.value(response)); + + when(client.autoUncompress = any).thenAnswer((_) => null); + + when(response.contentLength).thenReturn(transparentImage.length); + + when(response.statusCode).thenReturn(HttpStatus.ok); + + when(response.compressionState).thenReturn(HttpClientResponseCompressionState.notCompressed); + + // Define an image stream that streams the mock test image for all + // image tests that request an image. + StreamSubscription> imageStream(Invocation invocation) { + final void Function(List)? onData = invocation.positionalArguments[0] as void Function(List)?; + final void Function()? onDone = invocation.namedArguments[#onDone] as void Function()?; + final void Function(Object, [StackTrace?])? onError = + invocation.namedArguments[#onError] as void Function(Object, [StackTrace?])?; + final bool? cancelOnError = invocation.namedArguments[#cancelOnError] as bool?; + + return Stream>.fromIterable(>[transparentImage]).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + when(response.listen(any, + onError: anyNamed('onError'), onDone: anyNamed('onDone'), cancelOnError: anyNamed('cancelOnError'))) + .thenAnswer(imageStream); + + return client; +} + +// A list of integers that can be consumed as image data in a stream. +final List _transparentImage = [ + // Image bytes. + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, + 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, +]; + +List getTestImageData() { + return _transparentImage; +} + +/// Define the "fake" data types to be used in mock data type definitions. These +/// fake data types are important in the definition of the return values of the +/// properties and methods of the mock data types for null safety. +// ignore: avoid_implementing_value_types +class _FakeDuration extends Fake implements Duration {} + +class _FakeHttpClientRequest extends Fake implements HttpClientRequest {} + +class _FakeUri extends Fake implements Uri {} + +class _FakeHttpHeaders extends Fake implements HttpHeaders {} + +class _FakeHttpClientResponse extends Fake implements HttpClientResponse {} + +class _FakeSocket extends Fake implements Socket {} + +class _FakeStreamSubscription extends Fake implements StreamSubscription {} + +/// A class which mocks [HttpClient]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHttpClient extends Mock implements HttpClient { + MockHttpClient() { + throwOnMissingStub(this); + } + + @override + Duration get idleTimeout => + super.noSuchMethod(Invocation.getter(#idleTimeout), returnValue: _FakeDuration()) as Duration; + + @override + set idleTimeout(Duration? idleTimeout) => super.noSuchMethod(Invocation.setter(#idleTimeout, idleTimeout)); + + @override + bool get autoUncompress => super.noSuchMethod(Invocation.getter(#autoUncompress), returnValue: false) as bool; + + @override + set autoUncompress(bool? autoUncompress) => super.noSuchMethod(Invocation.setter(#autoUncompress, autoUncompress)); + + @override + Future open(String? method, String? host, int? port, String? path) => + super.noSuchMethod(Invocation.method(#open, [method, host, port, path]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future openUrl(String? method, Uri? url) => + super.noSuchMethod(Invocation.method(#openUrl, [method, url]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future get(String? host, int? port, String? path) => + super.noSuchMethod(Invocation.method(#get, [host, port, path]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future getUrl(Uri? url) => super.noSuchMethod(Invocation.method(#getUrl, [url]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future post(String? host, int? port, String? path) => + super.noSuchMethod(Invocation.method(#post, [host, port, path]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future postUrl(Uri? url) => super.noSuchMethod(Invocation.method(#postUrl, [url]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future put(String? host, int? port, String? path) => + super.noSuchMethod(Invocation.method(#put, [host, port, path]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future putUrl(Uri? url) => super.noSuchMethod(Invocation.method(#putUrl, [url]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future delete(String? host, int? port, String? path) => + super.noSuchMethod(Invocation.method(#delete, [host, port, path]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future deleteUrl(Uri? url) => super.noSuchMethod(Invocation.method(#deleteUrl, [url]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future patch(String? host, int? port, String? path) => + super.noSuchMethod(Invocation.method(#patch, [host, port, path]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future patchUrl(Uri? url) => super.noSuchMethod(Invocation.method(#patchUrl, [url]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future head(String? host, int? port, String? path) => + super.noSuchMethod(Invocation.method(#head, [host, port, path]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + Future headUrl(Uri? url) => super.noSuchMethod(Invocation.method(#headUrl, [url]), + returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future; + + @override + void addCredentials(Uri? url, String? realm, HttpClientCredentials? credentials) => + super.noSuchMethod(Invocation.method(#addCredentials, [url, realm, credentials])); + + @override + void addProxyCredentials(String? host, int? port, String? realm, HttpClientCredentials? credentials) => + super.noSuchMethod(Invocation.method(#addProxyCredentials, [host, port, realm, credentials])); + + @override + void close({bool? force = false}) => + super.noSuchMethod(Invocation.method(#close, [], {#force: force})); +} + +/// A class which mocks [HttpClientRequest]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHttpClientRequest extends Mock implements HttpClientRequest { + MockHttpClientRequest() { + throwOnMissingStub(this); + } + + @override + bool get persistentConnection => + super.noSuchMethod(Invocation.getter(#persistentConnection), returnValue: false) as bool; + + @override + set persistentConnection(bool? persistentConnection) => + super.noSuchMethod(Invocation.setter(#persistentConnection, persistentConnection)); + + @override + bool get followRedirects => super.noSuchMethod(Invocation.getter(#followRedirects), returnValue: false) as bool; + + @override + set followRedirects(bool? followRedirects) => + super.noSuchMethod(Invocation.setter(#followRedirects, followRedirects)); + + @override + int get maxRedirects => super.noSuchMethod(Invocation.getter(#maxRedirects), returnValue: 0) as int; + + @override + set maxRedirects(int? maxRedirects) => super.noSuchMethod(Invocation.setter(#maxRedirects, maxRedirects)); + + @override + int get contentLength => super.noSuchMethod(Invocation.getter(#contentLength), returnValue: 0) as int; + + @override + set contentLength(int? contentLength) => super.noSuchMethod(Invocation.setter(#contentLength, contentLength)); + + @override + bool get bufferOutput => super.noSuchMethod(Invocation.getter(#bufferOutput), returnValue: false) as bool; + + @override + set bufferOutput(bool? bufferOutput) => super.noSuchMethod(Invocation.setter(#bufferOutput, bufferOutput)); + + @override + String get method => super.noSuchMethod(Invocation.getter(#method), returnValue: '') as String; + + @override + Uri get uri => super.noSuchMethod(Invocation.getter(#uri), returnValue: _FakeUri()) as Uri; + + @override + HttpHeaders get headers => + super.noSuchMethod(Invocation.getter(#headers), returnValue: _FakeHttpHeaders()) as HttpHeaders; + + @override + List get cookies => super.noSuchMethod(Invocation.getter(#cookies), returnValue: []) as List; + + @override + Future get done => super.noSuchMethod(Invocation.getter(#done), + returnValue: Future<_FakeHttpClientResponse>.value(_FakeHttpClientResponse())) as Future; + + @override + Future close() => super.noSuchMethod(Invocation.method(#close, []), + returnValue: Future<_FakeHttpClientResponse>.value(_FakeHttpClientResponse())) as Future; +} + +/// A class which mocks [HttpClientResponse]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHttpClientResponse extends Mock implements HttpClientResponse { + MockHttpClientResponse() { + throwOnMissingStub(this); + } + + // Include an override method for the inherited listen method. This method + // intercepts HttpClientResponse listen calls to return a mock image. + @override + StreamSubscription> listen(void Function(List event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) => + super.noSuchMethod( + Invocation.method( + #listen, + [onData], + {#onError: onError, #onDone: onDone, #cancelOnError: cancelOnError}, + ), + returnValue: _FakeStreamSubscription>()) as StreamSubscription>; + + @override + int get statusCode => super.noSuchMethod(Invocation.getter(#statusCode), returnValue: 0) as int; + + @override + String get reasonPhrase => super.noSuchMethod(Invocation.getter(#reasonPhrase), returnValue: '') as String; + + @override + int get contentLength => super.noSuchMethod(Invocation.getter(#contentLength), returnValue: 0) as int; + + @override + HttpClientResponseCompressionState get compressionState => super.noSuchMethod(Invocation.getter(#compressionState), + returnValue: HttpClientResponseCompressionState.notCompressed) as HttpClientResponseCompressionState; + + @override + bool get persistentConnection => + super.noSuchMethod(Invocation.getter(#persistentConnection), returnValue: false) as bool; + + @override + bool get isRedirect => super.noSuchMethod(Invocation.getter(#isRedirect), returnValue: false) as bool; + + @override + List get redirects => + super.noSuchMethod(Invocation.getter(#redirects), returnValue: []) as List; + + @override + HttpHeaders get headers => + super.noSuchMethod(Invocation.getter(#headers), returnValue: _FakeHttpHeaders()) as HttpHeaders; + + @override + List get cookies => super.noSuchMethod(Invocation.getter(#cookies), returnValue: []) as List; + + @override + Future redirect([String? method, Uri? url, bool? followLoops]) => + super.noSuchMethod(Invocation.method(#redirect, [method, url, followLoops]), + returnValue: Future<_FakeHttpClientResponse>.value(_FakeHttpClientResponse())) as Future; + + @override + Future detachSocket() => super.noSuchMethod(Invocation.method(#detachSocket, []), + returnValue: Future<_FakeSocket>.value(_FakeSocket())) as Future; +} + +/// A class which mocks [HttpHeaders]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHttpHeaders extends Mock implements HttpHeaders { + MockHttpHeaders() { + throwOnMissingStub(this); + } + + @override + int get contentLength => super.noSuchMethod(Invocation.getter(#contentLength), returnValue: 0) as int; + + @override + set contentLength(int? contentLength) => super.noSuchMethod(Invocation.setter(#contentLength, contentLength)); + + @override + bool get persistentConnection => + super.noSuchMethod(Invocation.getter(#persistentConnection), returnValue: false) as bool; + + @override + set persistentConnection(bool? persistentConnection) => + super.noSuchMethod(Invocation.setter(#persistentConnection, persistentConnection)); + + @override + bool get chunkedTransferEncoding => + super.noSuchMethod(Invocation.getter(#chunkedTransferEncoding), returnValue: false) as bool; + + @override + set chunkedTransferEncoding(bool? chunkedTransferEncoding) => + super.noSuchMethod(Invocation.setter(#chunkedTransferEncoding, chunkedTransferEncoding)); + + @override + List? operator [](String? name) => + super.noSuchMethod(Invocation.method(#[], [name])) as List?; + + @override + String? value(String? name) => super.noSuchMethod(Invocation.method(#value, [name])) as String?; + + @override + void add(String? name, Object? value, {bool? preserveHeaderCase = false}) => super.noSuchMethod( + Invocation.method(#add, [name, value], {#preserveHeaderCase: preserveHeaderCase})); + + @override + void set(String? name, Object? value, {bool? preserveHeaderCase = false}) => super.noSuchMethod( + Invocation.method(#set, [name, value], {#preserveHeaderCase: preserveHeaderCase})); + + @override + void remove(String? name, Object? value) => super.noSuchMethod(Invocation.method(#remove, [name, value])); + + @override + void removeAll(String? name) => super.noSuchMethod(Invocation.method(#removeAll, [name])); + + @override + void forEach(void Function(String, List)? action) => + super.noSuchMethod(Invocation.method(#forEach, [action])); + + @override + void noFolding(String? name) => super.noSuchMethod(Invocation.method(#noFolding, [name])); +} diff --git a/flutter_markdown_plus/test/inline_widget_test.dart b/flutter_markdown_plus/test/inline_widget_test.dart new file mode 100644 index 0000000..58661c1 --- /dev/null +++ b/flutter_markdown_plus/test/inline_widget_test.dart @@ -0,0 +1,78 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:markdown/markdown.dart' as md; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('InlineWidget', () { + testWidgets( + 'Test inline widget', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: 'Hello, foo bar', + builders: { + 'sub': SubscriptBuilder(), + }, + extensionSet: md.ExtensionSet( + [], + [SubscriptSyntax()], + ), + ), + ), + ); + + final Text textWidget = tester.firstWidget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + + final TextSpan part1 = span.children![0] as TextSpan; + expect(part1.toPlainText(), 'Hello, '); + + final WidgetSpan part2 = span.children![1] as WidgetSpan; + expect(part2.alignment, PlaceholderAlignment.middle); + expect(part2.child, isA()); + expect((part2.child as Text).data, 'foo'); + + final TextSpan part3 = span.children![2] as TextSpan; + expect(part3.toPlainText(), ' bar'); + }, + ); + }); +} + +class SubscriptBuilder extends MarkdownElementBuilder { + @override + Widget visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + return Text.rich(WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Text(element.textContent), + )); + } +} + +class SubscriptSyntax extends md.InlineSyntax { + SubscriptSyntax() : super(_pattern); + + static const String _pattern = r'(foo)'; + + @override + bool onMatch(md.InlineParser parser, Match match) { + parser.addNode(md.Element.text('sub', match[1]!)); + return true; + } +} diff --git a/flutter_markdown_plus/test/line_break_test.dart b/flutter_markdown_plus/test/line_break_test.dart new file mode 100644 index 0000000..a247a83 --- /dev/null +++ b/flutter_markdown_plus/test/line_break_test.dart @@ -0,0 +1,357 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Hard Line Breaks', () { + testWidgets( + // Example 654 from GFM. + 'two spaces at end of line', + (WidgetTester tester) async { + const String data = 'foo \nbar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, 'foo\nbar'); + }, + ); + + testWidgets( + // Example 655 from GFM. + 'backslash at end of line', + (WidgetTester tester) async { + const String data = 'foo\\\nbar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, 'foo\nbar'); + }, + ); + + testWidgets( + // Example 656 from GFM. + 'more than two spaces at end of line', + (WidgetTester tester) async { + const String data = 'foo \nbar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, 'foo\nbar'); + }, + ); + + testWidgets( + // Example 657 from GFM. + 'leading spaces at beginning of next line are ignored', + (WidgetTester tester) async { + const String data = 'foo \n bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, 'foo\nbar'); + }, + ); + + testWidgets( + // Example 658 from GFM. + 'leading spaces at beginning of next line are ignored', + (WidgetTester tester) async { + const String data = 'foo\\\n bar'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, 'foo\nbar'); + }, + ); + + testWidgets( + // Example 659 from GFM. + 'two spaces line break inside emphasis', + (WidgetTester tester) async { + const String data = '*foo \nbar*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo\nbar'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle(firstSpan as TextSpan, FontStyle.italic, FontWeight.normal); + + // Second span is just the newline character with no font style or weight. + + // Third text span has italic style with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle(thirdSpan as TextSpan, FontStyle.italic, FontWeight.normal); + }, + ); + + testWidgets( + // Example 660 from GFM. + 'backslash line break inside emphasis', + (WidgetTester tester) async { + const String data = '*foo\\\nbar*'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'foo\nbar'); + + // There should be three spans of text. + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.children!.length == 3, isTrue); + + // First text span has italic style with normal weight. + final InlineSpan firstSpan = textSpan.children![0]; + expectTextSpanStyle(firstSpan as TextSpan, FontStyle.italic, FontWeight.normal); + + // Second span is just the newline character with no font style or weight. + + // Third text span has italic style with normal weight. + final InlineSpan thirdSpan = textSpan.children![2]; + expectTextSpanStyle(thirdSpan as TextSpan, FontStyle.italic, FontWeight.normal); + }, + ); + + testWidgets( + // Example 661 from GFM. + 'two space line break does not occur in code span', + (WidgetTester tester) async { + const String data = '`code \nspan`'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, 'code span'); + + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.style, isNotNull); + expect(textSpan.style!.fontFamily == 'monospace', isTrue); + }, + ); + + testWidgets( + // Example 662 from GFM. + 'backslash line break does not occur in code span', + (WidgetTester tester) async { + const String data = '`code\\\nspan`'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + + final Text textWidget = textFinder.evaluate().first.widget as Text; + final String text = textWidget.textSpan!.toPlainText(); + expect(text, r'code\ span'); + + final TextSpan textSpan = textWidget.textSpan! as TextSpan; + expect(textSpan, isNotNull); + expect(textSpan.style, isNotNull); + expect(textSpan.style!.fontFamily == 'monospace', isTrue); + }, + ); + + testWidgets( + // Example 665 from GFM. + 'backslash at end of paragraph is ignored', + (WidgetTester tester) async { + const String data = r'foo\'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, r'foo\'); + }, + ); + + testWidgets( + // Example 666 from GFM. + 'two spaces at end of paragraph is ignored', + (WidgetTester tester) async { + const String data = 'foo '; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, 'foo'); + }, + ); + + testWidgets( + // Example 667 from GFM. + 'backslash at end of header is ignored', + (WidgetTester tester) async { + const String data = r'### foo\'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, r'foo\'); + }, + ); + + testWidgets( + // Example 668 from GFM. + 'two spaces at end of header is ignored', + (WidgetTester tester) async { + const String data = '### foo '; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, 'foo'); + }, + ); + }); + + group('Soft Line Breaks', () { + testWidgets( + // Example 669 from GFM. + 'lines of text in paragraph', + (WidgetTester tester) async { + const String data = 'foo\nbaz'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, 'foo baz'); + }, + ); + + testWidgets( + // Example 670 from GFM. + 'spaces at beginning and end of lines of text in paragraph are removed', + (WidgetTester tester) async { + const String data = 'foo \n baz'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Finder richTextFinder = find.byType(RichText); + expect(richTextFinder, findsOneWidget); + + final RichText richText = richTextFinder.evaluate().first.widget as RichText; + final String text = richText.text.toPlainText(); + expect(text, 'foo baz'); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/link_test.dart b/flutter_markdown_plus/test/link_test.dart new file mode 100644 index 0000000..37654d6 --- /dev/null +++ b/flutter_markdown_plus/test/link_test.dart @@ -0,0 +1,2498 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Link', () { + testWidgets( + 'should work with nested elements', + (WidgetTester tester) async { + final List linkTapResults = []; + const String data = '[Link `with nested code` Text](href)'; + await tester.pumpWidget( + boilerplate( + Markdown( + data: data, + onTapLink: (String text, String? href, String title) => + linkTapResults.add(MarkdownLink(text, href, title)), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + + final List gestureRecognizerTypes = []; + span.visitChildren((InlineSpan inlineSpan) { + if (inlineSpan is TextSpan) { + final TapGestureRecognizer? recognizer = inlineSpan.recognizer as TapGestureRecognizer?; + gestureRecognizerTypes.add(recognizer?.runtimeType ?? Null); + if (recognizer != null) { + recognizer.onTap!(); + } + } + return true; + }); + + expect(span.children!.length, 3); + expect(gestureRecognizerTypes.length, 3); + expect(gestureRecognizerTypes, everyElement(TapGestureRecognizer)); + expect(linkTapResults.length, 3); + + // Each of the child text span runs should return the same link info. + for (final MarkdownLink tapResult in linkTapResults) { + expectLinkTap(tapResult, const MarkdownLink('Link with nested code Text', 'href')); + } + }, + ); + + testWidgets( + 'should work next to other links', + (WidgetTester tester) async { + final List linkTapResults = []; + const String data = '[First Link](firstHref) and [Second Link](secondHref)'; + await tester.pumpWidget( + boilerplate( + Markdown( + data: data, + onTapLink: (String text, String? href, String title) => + linkTapResults.add(MarkdownLink(text, href, title)), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + + final List gestureRecognizerTypes = []; + span.visitChildren((InlineSpan inlineSpan) { + if (inlineSpan is TextSpan) { + final TapGestureRecognizer? recognizer = inlineSpan.recognizer as TapGestureRecognizer?; + gestureRecognizerTypes.add(recognizer?.runtimeType ?? Null); + if (recognizer != null) { + recognizer.onTap!(); + } + } + return true; + }); + + expect(span.children!.length, 3); + expect( + gestureRecognizerTypes, + orderedEquals([TapGestureRecognizer, Null, TapGestureRecognizer]), + ); + expectLinkTap(linkTapResults[0], const MarkdownLink('First Link', 'firstHref')); + expectLinkTap(linkTapResults[1], const MarkdownLink('Second Link', 'secondHref')); + }, + ); + + testWidgets( + 'multiple inline links with same name but different urls - unique keys are assigned automatically', + (WidgetTester tester) async { + //Arange + final Widget toBePumped = boilerplate( + Column( + children: [ + MarkdownBody( + data: '[link](link1.com)', + onTapLink: (String text, String? href, String title) {}, + ), + MarkdownBody( + data: '[link](link2.com)', + onTapLink: (String text, String? href, String title) {}, + ), + ], + ), + ); + + //Act + await tester.pumpWidget(toBePumped); + + //Assert + final Finder widgetFinder = find.byType(Text); + final List elements = widgetFinder.evaluate().toList(); + final List widgets = elements.map((Element e) => e.widget).toList(); + + final List keys = widgets + .where((Widget w) => w.key != null && w.key.toString().isNotEmpty) + .map((Widget w) => w.key.toString()) + .toList(); + expect(keys.length, 2); //Not empty + expect(keys.toSet().length, 2); // Unique + }, + ); + testWidgets( + 'multiple inline links with same content should not throw an exception', + (WidgetTester tester) async { + //Arrange + final Widget toBePumped = boilerplate( + Column( + children: [ + Expanded( + child: MarkdownBody( + data: + '''links: [![first](image.png)](https://link.com) [![second](image.png)](https://link.com) [![third](image.png)](https://link.com)''', + onTapLink: (String text, String? href, String title) {}, + ), + ), + ], + ), + ); + + //Act + await tester.pumpWidget(toBePumped); + + //Assert + final Finder widgetFinder = find.byType(Text, skipOffstage: false); + final List elements = widgetFinder.evaluate().toList(); + final List widgets = elements.map((Element e) => e.widget).toList(); + + final List keys = widgets + .where((Widget w) => w.key != null && w.key.toString().isNotEmpty) + .map((Widget w) => w.key.toString()) + .toList(); + expect(keys.length, 3); //all three links. + expect(keys.toSet().length, 3); // all three unique. + }, + ); + + testWidgets( + // Example 493 from GFM. + 'simple inline link', + (WidgetTester tester) async { + const String data = '[link](/uri "title")'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/uri', 'title')); + }, + ); + + testWidgets( + 'empty inline link', + (WidgetTester tester) async { + const String data = '[](/uri "title")'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expect(find.byType(RichText), findsNothing); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 494 from GFM. + 'simple inline link - title omitted', + (WidgetTester tester) async { + const String data = '[link](/uri)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/uri')); + }, + ); + + testWidgets( + // Example 495 from GFM. + 'simple inline link - both destination and title omitted', + (WidgetTester tester) async { + const String data = '[link]()'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '')); + }, + ); + + testWidgets( + // Example 496 from GFM. + 'simple inline link - both < > enclosed destination and title omitted', + (WidgetTester tester) async { + const String data = '[link](<>)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '')); + }, + ); + + testWidgets( + // Example 497 from GFM. + 'link destination with space and not < > enclosed', + (WidgetTester tester) async { + const String data = '[link](/my url)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[link](/my url)'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 498 from GFM. + 'link destination with space and < > enclosed', + (WidgetTester tester) async { + const String data = '[link]()'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/my%20url')); + }, + ); + + testWidgets( + // Example 499 from GFM. + 'link destination cannot contain line breaks - not < > enclosed', + (WidgetTester tester) async { + const String data = '[link](foo\nbar)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[link](foo bar)'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 500 from GFM. + 'link destination cannot contain line breaks - < > enclosed', + (WidgetTester tester) async { + const String data = '[link]()'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[link]()'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 501 from GFM. + 'link destination containing ")" and < > enclosed', + (WidgetTester tester) async { + const String data = '[link]()'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/my)url')); + }, + ); + + testWidgets( + // Example 502 from GFM. + 'pointy brackets that enclose links must be unescaped', + (WidgetTester tester) async { + const String data = r'[link]()'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[link]()'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 503 from GFM. + 'opening pointy brackets are not properly matched', + (WidgetTester tester) async { + const String data = '[link](\n[link](bar)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[link]( [link](bar)'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 504 from GFM. + 'parentheses inside link destination may be escaped', + (WidgetTester tester) async { + const String data = r'[link](\(foo\))'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '(foo)')); + }, + ); + + testWidgets( + // Example 505 from GFM. + 'multiple balanced parentheses are allowed without escaping', + (WidgetTester tester) async { + const String data = '[link](foo(and(bar)))'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', 'foo(and(bar))')); + }, + ); + + testWidgets( + // Example 506 from GFM. + 'escaped unbalanced parentheses', + (WidgetTester tester) async { + // Use raw string so backslash isn't treated as an escape character. + const String data = r'[link](foo\(and\(bar\))'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', 'foo(and(bar)')); + }, + ); + + testWidgets( + // Example 507 from GFM. + 'pointy brackets enclosed unbalanced parentheses', + (WidgetTester tester) async { + const String data = '[link]()'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', 'foo(and(bar)')); + }, + ); + + testWidgets( + // Example 508 from GFM. + 'parentheses and other symbols can be escaped', + (WidgetTester tester) async { + // Use raw string so backslash isn't treated as an escape character. + const String data = r'[link](foo\)\:)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', 'foo):')); + }, + ); + + testWidgets( + // Example 509 case 1 from GFM. + 'link destinations with just fragment identifier', + (WidgetTester tester) async { + const String data = '[link](#fragment)'; + + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '#fragment')); + }, + ); + + testWidgets( + // Example 509 case 2 from GFM. + 'link destinations with URL and fragment identifier', + (WidgetTester tester) async { + const String data = '[link](http://example.com#fragment)'; + + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', 'http://example.com#fragment')); + }, + ); + + testWidgets( + // Example 509 case 3 from GFM. + 'link destinations with URL, fragment identifier, and query', + (WidgetTester tester) async { + const String data = '[link](http://example.com?foo=3#fragment)'; + + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', 'http://example.com?foo=3#fragment')); + }, + ); + + testWidgets( + // Example 510 from GFM. + 'link destinations with backslash before non-escapable character', + (WidgetTester tester) async { + const String data = '[link](foo\bar)'; + + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', 'foo%08ar')); + }, + ); + + testWidgets( + // Example 511 from GFM. + 'URL escaping should be left alone inside link destination', + (WidgetTester tester) async { + const String data = '[link](foo%20bä)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', 'foo%20b%C3%A4')); + }, + ); + + testWidgets( + // Example 512 from GFM. + 'omitting link destination uses title for destination', + (WidgetTester tester) async { + const String data = '[link]("title")'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '%22title%22')); + }, + ); + + testWidgets( + // Example 513a from GFM. + 'link title in double quotes', + (WidgetTester tester) async { + const String data = '[link](/url "title")'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/url', 'title')); + }, + ); + + testWidgets( + // Example 513b from GFM. + 'link title in single quotes', + (WidgetTester tester) async { + const String data = "[link](/url 'title')"; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/url', 'title')); + }, + ); + + testWidgets( + // Example 513c from GFM. + 'link title in parentheses', + (WidgetTester tester) async { + const String data = '[link](/url (title))'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/url', 'title')); + }, + ); + + testWidgets( + // Example 514 from GFM. + 'backslash escapes, entity, and numeric character references are allowed in title', + (WidgetTester tester) async { + const String data = r'[link](/url "title \""")'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/url', 'title ""')); + }, + ); + + testWidgets( + // Example 515 from GFM. + 'link title must be separated with whitespace and not Unicode whitespace', + (WidgetTester tester) async { + const String data = '[link](/url\u{C2A0}"title")'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/url%EC%8A%A0%22title%22')); + }, + ); + + testWidgets( + // Example 516 from GFM. + 'nested balanced quotes are not allowed without escaping', + (WidgetTester tester) async { + const String data = '[link](/url "title "and" title")'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[link](/url "title "and" title")'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 517 from GFM. + 'nested balanced quotes using different quote type', + (WidgetTester tester) async { + const String data = '[link](/url \'title "and" title\')'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap( + linkTapResults, + const MarkdownLink('link', '/url', 'title "and" title'), + ); + }, + ); + + testWidgets( + // Example 518 from GFM. + 'whitespace is allowed around the destination and title', + (WidgetTester tester) async { + const String data = '[link]( /url "title")'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link'); + expectLinkTap(linkTapResults, const MarkdownLink('link', '/url', 'title')); + }, + ); + + testWidgets( + // Example 519 from GFM. + 'whitespace is not allowed between link text and following parentheses', + (WidgetTester tester) async { + const String data = '[link] (/url)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[link] (/url)'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 520 from GFM. + 'link text may contain balanced brackets', + (WidgetTester tester) async { + const String data = '[link [foo [bar]]](/uri)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link [foo [bar]]'); + expectLinkTap(linkTapResults, const MarkdownLink('link [foo [bar]]', '/uri')); + }, + ); + + testWidgets( + // Example 521 from GFM. + 'link text may not contain unbalanced brackets', + (WidgetTester tester) async { + const String data = '[link] bar](/uri)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[link] bar](/uri)'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 522 from GFM. + 'link text may not contain unbalanced brackets - unintended link text', + (WidgetTester tester) async { + const String data = '[link [bar](/uri)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[link '); + + expectLinkTextSpan(span.children![1] as TextSpan, 'bar'); + expectLinkTap(linkTapResults, const MarkdownLink('bar', '/uri')); + }, + ); + + testWidgets( + // Example 523 from GFM. + 'link text with escaped open square bracket', + (WidgetTester tester) async { + const String data = r'[link \[bar](/uri)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link [bar'); + expectLinkTap(linkTapResults, const MarkdownLink('link [bar', '/uri')); + }, + ); + + testWidgets( + // Example 524 from GFM. + 'link text with inline emphasis and code', + (WidgetTester tester) async { + const String data = '[link *foo **bar** `#`*](/uri)'; + final List linkTapResults = []; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => + linkTapResults.add(MarkdownLink(text, href, title)), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 5); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + expectTextSpanStyle(span.children![1] as TextSpan, FontStyle.italic, FontWeight.normal); + expectTextSpanStyle(span.children![2] as TextSpan, FontStyle.italic, FontWeight.bold); + expectTextSpanStyle(span.children![3] as TextSpan, FontStyle.italic, FontWeight.normal); + expect((span.children![4] as TextSpan).style!.fontFamily, 'monospace'); + + final List gestureRecognizerTypes = []; + span.visitChildren((InlineSpan inlineSpan) { + if (inlineSpan is TextSpan) { + final TapGestureRecognizer? recognizer = inlineSpan.recognizer as TapGestureRecognizer?; + gestureRecognizerTypes.add(recognizer.runtimeType); + recognizer!.onTap!(); + } + return true; + }); + + expect(gestureRecognizerTypes.length, 5); + expect(gestureRecognizerTypes, everyElement(TapGestureRecognizer)); + expect(linkTapResults.length, 5); + + // Each of the child text span runs should return the same link info. + for (final MarkdownLink tapResult in linkTapResults) { + expectLinkTap(tapResult, const MarkdownLink('link foo bar #', '/uri')); + } + }, + ); + + testWidgets( + // Example 525 from GFM. + 'inline image link text', + (WidgetTester tester) async { + const String data = '[![moon](moon.jpg)](/uri)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Finder gestureFinder = find.byType(GestureDetector); + expect(gestureFinder, findsOneWidget); + final GestureDetector gestureWidget = gestureFinder.evaluate().first.widget as GestureDetector; + expect(gestureWidget.child, isA()); + expect(gestureWidget.onTap, isNotNull); + + gestureWidget.onTap!(); + expectLinkTap(linkTapResults, const MarkdownLink('moon', '/uri')); + }, + ); + + testWidgets( + // Example 526 from GFM. + 'links cannot be nested - outter link ignored', + (WidgetTester tester) async { + const String data = '[foo [bar](/uri)](/uri)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 3); + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[foo '); + + expectLinkTextSpan(span.children![1] as TextSpan, 'bar'); + expectLinkTap(linkTapResults, const MarkdownLink('bar', '/uri')); + + expect(span.children![2], isA()); + expect(span.children![2].toPlainText(), '](/uri)'); + }, + ); + + testWidgets( + // Example 527 from GFM. + 'links cannot be nested - outter link ignored with emphasis', + (WidgetTester tester) async { + const String data = '[foo *[bar [baz](/uri)](/uri)*](/uri)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 5); + expect(span.children, everyElement(isA())); + + expect(span.children![0].toPlainText(), '[foo '); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expect(span.children![1].toPlainText(), '[bar '); + expectTextSpanStyle(span.children![1] as TextSpan, FontStyle.italic, FontWeight.normal); + + expect(span.children![2].toPlainText(), 'baz'); + expectTextSpanStyle(span.children![2] as TextSpan, FontStyle.italic, FontWeight.normal); + + expect(span.children![3].toPlainText(), '](/uri)'); + expectTextSpanStyle(span.children![3] as TextSpan, FontStyle.italic, FontWeight.normal); + + expect(span.children![4].toPlainText(), '](/uri)'); + expectTextSpanStyle(span.children![4] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![2] as TextSpan, 'baz'); + expectLinkTap(linkTapResults, const MarkdownLink('baz', '/uri')); + }, + ); + + testWidgets( + // Example 528 from GFM. + 'links cannot be nested in image linksinline image link text', + (WidgetTester tester) async { + const String data = '![[[foo](uri1)](uri2)](uri3)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Finder gestureFinder = find.byType(GestureDetector); + expect(gestureFinder, findsNothing); + + final Finder imageFinder = find.byType(Image); + expect(imageFinder, findsOneWidget); + final Image image = imageFinder.evaluate().first.widget as Image; + if (kIsWeb) { + final NetworkImage fi = image.image as NetworkImage; + expect(fi.url.endsWith('uri3'), true); + } else { + final FileImage fi = image.image as FileImage; + expect(fi.file.path, equals('uri3')); + } + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 529 from GFM. + 'link text grouping has precedence over emphasis grouping example 1', + (WidgetTester tester) async { + const String data = r'*[foo*](/uri)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '*'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![1] as TextSpan, 'foo*'); + expectLinkTap(linkTapResults, const MarkdownLink('foo*', '/uri')); + }, + ); + + testWidgets( + // Example 530 from GFM. + 'link text grouping has precedence over emphasis grouping example 2', + (WidgetTester tester) async { + const String data = '[foo *bar](baz*)'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('foo *bar'); + expectLinkTap(linkTapResults, const MarkdownLink('foo *bar', 'baz*')); + }, + ); + + testWidgets( + // Example 531 from GFM. + "brackets that aren't part of links do not take precedence", + (WidgetTester tester) async { + const String data = '*foo [bar* baz]'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children, everyElement(isA())); + + expect(span.children![0].toPlainText(), 'foo [bar'); + expectTextSpanStyle(span.children![0] as TextSpan, FontStyle.italic, FontWeight.normal); + + expect(span.children![1].toPlainText(), ' baz]'); + expectTextSpanStyle(span.children![1] as TextSpan, null, FontWeight.normal); + }, + ); + + testWidgets( + // Example 532 from GFM. + 'HTML tag takes precedence over link grouping', + (WidgetTester tester) async { + const String data = '[foo '; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[foo '); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 533 from GFM. + 'code span takes precedence over link grouping', + (WidgetTester tester) async { + const String data = '[foo`](/uri)`'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Finder gestureFinder = find.byType(GestureDetector); + expect(gestureFinder, findsNothing); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children, everyElement(isA())); + + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[foo'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + expect((span.children![1] as TextSpan).style!.fontFamily, 'monospace'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 534 from GFM. + 'autolinks take precedence over link grouping', + (WidgetTester tester) async { + const String data = '[foo'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children, everyElement(isA())); + + expect(span.children![0].toPlainText(), '[foo'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![1] as TextSpan, 'http://example.com/?search=](uri)'); + expectLinkTap(linkTapResults, + const MarkdownLink('http://example.com/?search=](uri)', 'http://example.com/?search=%5D(uri)')); + }, + ); + }); + group('Reference Link', () { + testWidgets( + // Example 535 from GFM. + 'simple reference link', + (WidgetTester tester) async { + const String data = '[foo][bar]\n\n[bar]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url', 'title')); + }, + ); + + testWidgets( + // Example 536 from GFM. + 'reference link with balanced brackets in link text', + (WidgetTester tester) async { + const String data = '[link [foo [bar]]][ref]\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link [foo [bar]]'); + expectLinkTap(linkTapResults, const MarkdownLink('link [foo [bar]]', '/uri')); + }, + ); + + testWidgets( + // Example 537 from GFM. + 'reference link with unbalanced but escaped bracket in link text', + (WidgetTester tester) async { + const String data = '[link \\[bar][ref]\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('link [bar'); + expectLinkTap(linkTapResults, const MarkdownLink('link [bar', '/uri')); + }, + ); + + testWidgets( + // Example 538 from GFM. + 'reference link with inline emphasis and code span in link text', + (WidgetTester tester) async { + const String data = '[link *foo **bar** `#`*][ref]\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 5); + expect(span.children, everyElement(isA())); + + expect(span.children![0].toPlainText(), 'link '); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expect(span.children![1].toPlainText(), 'foo '); + expectTextSpanStyle(span.children![1] as TextSpan, FontStyle.italic, FontWeight.normal); + + expect(span.children![2].toPlainText(), 'bar'); + expectTextSpanStyle(span.children![2] as TextSpan, FontStyle.italic, FontWeight.bold); + + expect(span.children![3].toPlainText(), ' '); + expectTextSpanStyle(span.children![3] as TextSpan, FontStyle.italic, FontWeight.normal); + + expect(span.children![4].toPlainText(), '#'); + expectTextSpanStyle(span.children![4] as TextSpan, null, FontWeight.normal); + expect((span.children![4] as TextSpan).style!.fontFamily, 'monospace'); + + for (final InlineSpan element in span.children!) { + final TextSpan textSpan = element as TextSpan; + expect(textSpan.recognizer, isNotNull); + expect(textSpan.recognizer, isA()); + final TapGestureRecognizer? tapRecognizer = textSpan.recognizer as TapGestureRecognizer?; + expect(tapRecognizer?.onTap, isNotNull); + + tapRecognizer!.onTap!(); + expectLinkTap(linkTapResults, const MarkdownLink('link foo bar #', '/uri')); + + // Clear link tap results. + linkTapResults = null; + } + }, + ); + + testWidgets( + // Example 539 from GFM. + 'referenence link with inline image link text', + (WidgetTester tester) async { + const String data = '[![moon](moon.jpg)][ref]\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Finder gestureFinder = find.byType(GestureDetector); + expect(gestureFinder, findsOneWidget); + final GestureDetector gestureWidget = gestureFinder.evaluate().first.widget as GestureDetector; + expect(gestureWidget.child, isA()); + expect(gestureWidget.onTap, isNotNull); + + gestureWidget.onTap!(); + expectLinkTap(linkTapResults, const MarkdownLink('moon', '/uri')); + imageCache.clear(); + }, + ); + + testWidgets( + // Example 540 from GFM. + 'reference links cannot have nested links', + (WidgetTester tester) async { + const String data = '[foo [bar](/uri)][ref]\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 4); + + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[foo '); + + expectLinkTextSpan(span.children![1] as TextSpan, 'bar'); + expectLinkTap(linkTapResults, const MarkdownLink('bar', '/uri')); + + expect(span.children![2], isA()); + expect(span.children![2].toPlainText(), ']'); + + expectLinkTextSpan(span.children![3] as TextSpan, 'ref'); + expectLinkTap(linkTapResults, const MarkdownLink('ref', '/uri')); + }, + ); + + testWidgets( + // Example 541 from GFM. + 'reference links cannot have nested reference links', + (WidgetTester tester) async { + const String data = '[foo *bar [baz][ref]*][ref]\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 5); + + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[foo '); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expect(span.children![1], isA()); + expect(span.children![1].toPlainText(), 'bar '); + expectTextSpanStyle(span.children![1] as TextSpan, FontStyle.italic, FontWeight.normal); + + expectLinkTextSpan(span.children![2] as TextSpan, 'baz'); + expectTextSpanStyle(span.children![2] as TextSpan, FontStyle.italic, FontWeight.normal); + expectLinkTap(linkTapResults, const MarkdownLink('baz', '/uri')); + + expect(span.children![3], isA()); + expect(span.children![3].toPlainText(), ']'); + expectTextSpanStyle(span.children![3] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![4] as TextSpan, 'ref'); + expectTextSpanStyle(span.children![4] as TextSpan, null, FontWeight.normal); + expectLinkTap(linkTapResults, const MarkdownLink('ref', '/uri')); + }, + ); + + testWidgets( + // Example 542 from GFM. + 'reference link text grouping has precedence over emphasis grouping example 1', + (WidgetTester tester) async { + const String data = '*[foo*][ref]\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '*'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![1] as TextSpan, 'foo*'); + expectLinkTap(linkTapResults, const MarkdownLink('foo*', '/uri')); + }, + ); + + testWidgets( + // Example 543 from GFM. + 'reference link text grouping has precedence over emphasis grouping example 2', + (WidgetTester tester) async { + const String data = '[foo *bar][ref]*\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + + expectLinkTextSpan(span.children![0] as TextSpan, 'foo *bar'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + expectLinkTap(linkTapResults, const MarkdownLink('foo *bar', '/uri')); + + expect(span.children![1], isA()); + expect(span.children![1].toPlainText(), '*'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + }, + ); + + testWidgets( + // Example 544 from GFM. + 'HTML tag takes precedence over reference link grouping', + (WidgetTester tester) async { + const String data = '[foo \n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[foo '); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 545 from GFM. + 'code span takes precedence over reference link grouping', + (WidgetTester tester) async { + const String data = '[foo`][ref]`\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Finder gestureFinder = find.byType(GestureDetector); + expect(gestureFinder, findsNothing); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children, everyElement(isA())); + + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[foo'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expect(span.children![1].toPlainText(), '][ref]'); + expect((span.children![1] as TextSpan).style!.fontFamily, 'monospace'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 534 from GFM. + 'autolinks take precedence over reference link grouping', + (WidgetTester tester) async { + const String data = '[foo\n\n[ref]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children, everyElement(isA())); + + expect(span.children![0].toPlainText(), '[foo'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![1] as TextSpan, 'http://example.com/?search=][ref]'); + expectLinkTap(linkTapResults, + const MarkdownLink('http://example.com/?search=][ref]', 'http://example.com/?search=%5D%5Bref%5D')); + }, + ); + + testWidgets( + // Example 547 from GFM. + 'reference link matching is case-insensitive', + (WidgetTester tester) async { + const String data = '[foo][BaR]\n\n[bar]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url', 'title')); + }, + ); + + testWidgets( + // Example 548 from GFM. + 'reference link support Unicode case fold - GFM', + (WidgetTester tester) async { + const String data = '[ẞ]\n\n[SS]: /url'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('ẞ'); + expectLinkTap(linkTapResults, const MarkdownLink('ẞ', '/url', 'title')); + }, + // TODO(mjordan56): Remove skip once the issue #333 in the markdown package + // is fixed and released. https://github.com/dart-lang/markdown/issues/333 + skip: true, + ); + + testWidgets( + // Example 536 from CommonMark. NOTE: The CommonMark and GFM specifications + // use different examples for Unicode case folding. Both are being added + // to the test suite since each example produces different cases to test. + 'reference link support Unicode case fold - CommonMark', + (WidgetTester tester) async { + const String data = '[Толпой][Толпой] is a Russian word.\n\n[ТОЛПОЙ]: /url'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + + expectLinkTextSpan(span.children![0] as TextSpan, 'Толпой'); + expectLinkTap(linkTapResults, const MarkdownLink('Толпой', '/url')); + + expect(span.children![1], isA()); + expect(span.children![1].toPlainText(), ' is a Russian word.'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + }, + ); + + testWidgets( + // Example 549 from GFM. + 'reference link with internal whitespace', + (WidgetTester tester) async { + const String data = '[Foo\n bar]: /url\n\n[Baz][Foo bar]'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('Baz'); + expectLinkTap(linkTapResults, const MarkdownLink('Baz', '/url')); + }, + ); + + testWidgets( + // Example 550 from GFM. + 'reference link no whitespace between link text and link label', + (WidgetTester tester) async { + const String data = '[foo] [bar]\n\n[bar]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[foo] '); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![1] as TextSpan, 'bar'); + expectLinkTap(linkTapResults, const MarkdownLink('bar', '/url', 'title')); + }, + ); + + testWidgets( + // Example 551 from GFM. + 'reference link no line break between link text and link label', + (WidgetTester tester) async { + const String data = '[foo]\n[bar]\n\n[bar]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[foo] '); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![1] as TextSpan, 'bar'); + expectLinkTap(linkTapResults, const MarkdownLink('bar', '/url', 'title')); + }, + ); + + testWidgets( + // Example 552 from GFM. + 'multiple matching reference link definitions use first definition', + (WidgetTester tester) async { + const String data = '[foo]: /url1\n\n[foo]: /url2\n\n[bar][foo]'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('bar'); + expectLinkTap(linkTapResults, const MarkdownLink('bar', '/url1')); + }, + ); + + testWidgets( + // Example 553 from GFM. + 'reference link matching is performed on normalized strings', + (WidgetTester tester) async { + const String data = '[bar][foo\\!]\n\n[foo!]: /url'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[bar][foo!]'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 554 from GFM. + 'reference link labels cannot contain brackets - case 1', + (WidgetTester tester) async { + const String data = '[foo][ref[]\n\n[ref[]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final List textWidgets = tester.widgetList(find.byType(RichText)).toList().cast(); + expect(textWidgets.length, 2); + + expect(textWidgets[0].text, isA()); + expect(textWidgets[0].text.toPlainText(), '[foo][ref[]'); + expectTextSpanStyle(textWidgets[0].text as TextSpan, null, FontWeight.normal); + + expect(textWidgets[1].text, isA()); + expect(textWidgets[1].text.toPlainText(), '[ref[]: /uri'); + expectTextSpanStyle(textWidgets[1].text as TextSpan, null, FontWeight.normal); + + expect(linkTapResults, isNull); + }, + // TODO(mjordan56): Remove skip once the issue #335 in the markdown package + // is fixed and released. https://github.com/dart-lang/markdown/issues/335 + skip: true, + ); + + testWidgets( + // Example 555 from GFM. + 'reference link labels cannot contain brackets - case 2', + (WidgetTester tester) async { + const String data = '[foo][ref[bar]]\n\n[ref[bar]]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final List textWidgets = tester.widgetList(find.byType(Text)).toList().cast(); + expect(textWidgets.length, 2); + + expect(textWidgets[0].textSpan, isNotNull); + expect(textWidgets[0].textSpan, isA()); + expect(textWidgets[0].textSpan!.toPlainText(), '[foo][ref[bar]]'); + expectTextSpanStyle(textWidgets[0].textSpan! as TextSpan, null, FontWeight.normal); + + expect(textWidgets[1].textSpan, isNotNull); + expect(textWidgets[1].textSpan, isA()); + expect(textWidgets[1].textSpan!.toPlainText(), '[ref[bar]]: /uri'); + expectTextSpanStyle(textWidgets[1].textSpan! as TextSpan, null, FontWeight.normal); + + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 556 from GFM. + 'reference link labels cannot contain brackets - case 3', + (WidgetTester tester) async { + const String data = '[[[foo]]]\n\n[[[foo]]]: /url'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final List textWidgets = tester.widgetList(find.byType(Text)).toList().cast(); + expect(textWidgets.length, 2); + + expect(textWidgets[0].textSpan, isNotNull); + expect(textWidgets[0].textSpan, isA()); + expect(textWidgets[0].textSpan!.toPlainText(), '[[[foo]]]'); + expectTextSpanStyle(textWidgets[0].textSpan! as TextSpan, null, FontWeight.normal); + + expect(textWidgets[1].textSpan, isNotNull); + expect(textWidgets[1].textSpan, isA()); + expect(textWidgets[1].textSpan!.toPlainText(), '[[[foo]]]: /url'); + expectTextSpanStyle(textWidgets[1].textSpan! as TextSpan, null, FontWeight.normal); + + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 557 from GFM. + 'reference link labels can have escaped brackets', + (WidgetTester tester) async { + const String data = '[foo][ref\\[]\n\n[ref\\[]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/uri')); + }, + ); + + testWidgets( + // Example 558 from GFM. + 'reference link labels can have escaped characters', + (WidgetTester tester) async { + const String data = '[bar\\]: /uri\n\n[bar\\]'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink(r'bar\'); + expectLinkTap(linkTapResults, const MarkdownLink(r'bar\', '/uri')); + }, + // TODO(mjordan56): Remove skip once the issue #336 in the markdown package + // is fixed and released. https://github.com/dart-lang/markdown/issues/336 + skip: true, + ); + + testWidgets( + // Example 559 from GFM. + 'reference link labels must contain at least on non-whitespace character - case 1', + (WidgetTester tester) async { + const String data = '[]\n\n[]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final List textWidgets = tester.widgetList(find.byType(Text)).toList().cast(); + expect(textWidgets.length, 2); + + expect(textWidgets[0].textSpan, isNotNull); + expect(textWidgets[0].textSpan, isA()); + expect(textWidgets[0].textSpan!.toPlainText(), '[]'); + expectTextSpanStyle(textWidgets[0].textSpan! as TextSpan, null, FontWeight.normal); + + expect(textWidgets[1].textSpan, isNotNull); + expect(textWidgets[1].textSpan, isA()); + expect(textWidgets[1].textSpan!.toPlainText(), '[]: /uri'); + expectTextSpanStyle(textWidgets[1].textSpan! as TextSpan, null, FontWeight.normal); + + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 560 from GFM. + 'reference link labels must contain at least on non-whitespace character - case 2', + (WidgetTester tester) async { + const String data = '[\n ]\n\n[\n ]: /uri'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final List textWidgets = tester.widgetList(find.byType(Text)).toList().cast(); + expect(textWidgets.length, 2); + + expect(textWidgets[0].textSpan, isNotNull); + expect(textWidgets[0].textSpan, isA()); + expect(textWidgets[0].textSpan!.toPlainText(), '[ ]'); + expectTextSpanStyle(textWidgets[0].textSpan! as TextSpan, null, FontWeight.normal); + + expect(textWidgets[1].textSpan, isNotNull); + expect(textWidgets[1].textSpan, isA()); + expect(textWidgets[1].textSpan!.toPlainText(), '[ ]: /uri'); + expectTextSpanStyle(textWidgets[1].textSpan! as TextSpan, null, FontWeight.normal); + + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 561 from GFM. + 'collapsed reference link', + (WidgetTester tester) async { + const String data = '[foo][]\n\n[foo]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url', 'title')); + }, + ); + + testWidgets( + // Example 562 from GFM. + 'collapsed reference link with inline emphasis in link text', + (WidgetTester tester) async { + const String data = '[*foo* bar][]\n\n[*foo* bar]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children, everyElement(isA())); + + expect(span.children![0].toPlainText(), 'foo'); + expectTextSpanStyle(span.children![0] as TextSpan, FontStyle.italic, FontWeight.normal); + + expect(span.children![1].toPlainText(), ' bar'); + expectTextSpanStyle(span.children![1] as TextSpan, null, FontWeight.normal); + + for (final InlineSpan element in span.children!) { + final TextSpan textSpan = element as TextSpan; + expect(textSpan.recognizer, isNotNull); + expect(textSpan.recognizer, isA()); + final TapGestureRecognizer? tapRecognizer = textSpan.recognizer as TapGestureRecognizer?; + expect(tapRecognizer?.onTap, isNotNull); + + tapRecognizer!.onTap!(); + expectLinkTap(linkTapResults, const MarkdownLink('foo bar', '/url', 'title')); + + // Clear link tap results. + linkTapResults = null; + } + }, + ); + + testWidgets( + // Example 563 from GFM. + 'collapsed reference links are case-insensitive', + (WidgetTester tester) async { + const String data = '[Foo][]\n\n[foo]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('Foo'); + expectLinkTap(linkTapResults, const MarkdownLink('Foo', '/url', 'title')); + }, + ); + + testWidgets( + // Example 564 from GFM. + 'collapsed reference link no whitespace between link text and link label', + (WidgetTester tester) async { + const String data = '[foo] \n\n[]\n\n[foo]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final List textWidgets = tester.widgetList(find.byType(Text)).toList().cast(); + expect(textWidgets.length, 2); + + expect(textWidgets[0].textSpan, isNotNull); + expect(textWidgets[0].textSpan, isA()); + expect(textWidgets[0].textSpan!.toPlainText(), 'foo'); + + expect(textWidgets[0].textSpan, isNotNull); + expect(textWidgets[0].textSpan, isA()); + expectLinkTextSpan(textWidgets[0].textSpan! as TextSpan, 'foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url', 'title')); + + expect(textWidgets[1].textSpan, isNotNull); + expect(textWidgets[1].textSpan, isA()); + expect(textWidgets[1].textSpan!.toPlainText(), '[]'); + expectTextSpanStyle(textWidgets[1].textSpan! as TextSpan, null, FontWeight.normal); + }, + ); + + testWidgets( + // Example 565 from GFM. + 'shortcut reference link', + (WidgetTester tester) async { + const String data = '[foo]\n\n[foo]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url', 'title')); + }, + ); + + testWidgets( + // Example 566 from GFM. + 'shortcut reference link with inline emphasis in link text', + (WidgetTester tester) async { + const String data = '[*foo* bar]\n\n[*foo* bar]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children, everyElement(isA())); + + expect(span.children![0].toPlainText(), 'foo'); + expectTextSpanStyle(span.children![0] as TextSpan, FontStyle.italic, FontWeight.normal); + + expect(span.children![1].toPlainText(), ' bar'); + expectTextSpanStyle(span.children![1] as TextSpan, null, FontWeight.normal); + + for (final InlineSpan element in span.children!) { + final TextSpan textSpan = element as TextSpan; + expect(textSpan.recognizer, isNotNull); + expect(textSpan.recognizer, isA()); + final TapGestureRecognizer? tapRecognizer = textSpan.recognizer as TapGestureRecognizer?; + expect(tapRecognizer?.onTap, isNotNull); + + tapRecognizer!.onTap!(); + expectLinkTap(linkTapResults, const MarkdownLink('foo bar', '/url', 'title')); + + // Clear link tap results. + linkTapResults = null; + } + }, + ); + + testWidgets( + // Example 567 from GFM. + 'shortcut reference link with inline emphasis nested in link text', + (WidgetTester tester) async { + const String data = '[*foo* bar]\n\n[*foo* bar]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children, everyElement(isA())); + + expect(span.children![0].toPlainText(), 'foo'); + expectTextSpanStyle(span.children![0] as TextSpan, FontStyle.italic, FontWeight.normal); + + expect(span.children![1].toPlainText(), ' bar'); + expectTextSpanStyle(span.children![1] as TextSpan, null, FontWeight.normal); + + for (final InlineSpan element in span.children!) { + final TextSpan textSpan = element as TextSpan; + expect(textSpan.recognizer, isNotNull); + expect(textSpan.recognizer, isA()); + final TapGestureRecognizer? tapRecognizer = textSpan.recognizer as TapGestureRecognizer?; + expect(tapRecognizer?.onTap, isNotNull); + + tapRecognizer!.onTap!(); + expectLinkTap(linkTapResults, const MarkdownLink('foo bar', '/url', 'title')); + + // Clear link tap results. + linkTapResults = null; + } + }, + ); + + testWidgets( + // Example 568 from GFM. + 'shortcut reference link with unbalanced open square brackets', + (WidgetTester tester) async { + const String data = '[[bar [foo]\n\n[foo]: /url'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[[bar '); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![1] as TextSpan, 'foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url')); + }, + ); + + testWidgets( + // Example 569 from GFM. + 'shortcut reference links are case-insensitive', + (WidgetTester tester) async { + const String data = '[Foo]\n\n[foo]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('Foo'); + expectLinkTap(linkTapResults, const MarkdownLink('Foo', '/url', 'title')); + }, + ); + + testWidgets( + // Example 570 from GFM. + 'shortcut reference link should preserve space after link text', + (WidgetTester tester) async { + const String data = '[foo] bar\n\n[foo]: /url'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + + expectLinkTextSpan(span.children![0] as TextSpan, 'foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url')); + + expect(span.children![1], isA()); + expect(span.children![1].toPlainText(), ' bar'); + expectTextSpanStyle(span.children![1] as TextSpan, null, FontWeight.normal); + }, + ); + + testWidgets( + // Example 571 from GFM. + 'shortcut reference link backslash escape opening bracket to avoid link', + (WidgetTester tester) async { + const String data = '\\[foo]\n\n[foo]: /url "title"'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + // Link is treated as ordinary text. + expectInvalidLink('[foo]'); + expect(linkTapResults, isNull); + }, + ); + + testWidgets( + // Example 572 from GFM. + 'shortcut reference link text grouping has precedence over emphasis grouping', + (WidgetTester tester) async { + const String data = '[foo*]: /url\n\n*[foo*]'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '*'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![1] as TextSpan, 'foo*'); + expectLinkTap(linkTapResults, const MarkdownLink('foo*', '/url')); + }, + ); + + testWidgets( + // Example 573 from GFM. + 'full link reference takes precedence over shortcut link reference', + (WidgetTester tester) async { + const String data = '[foo][bar]\n\n[foo]: /url1\n[bar]: /url2'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url2')); + }, + ); + + testWidgets( + // Example 574 from GFM. + 'compact link reference takes precedence over shortcut link reference', + (WidgetTester tester) async { + const String data = '[foo][]\n\n[foo]: /url1'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url1')); + }, + ); + + testWidgets( + // Example 575 from GFM. + 'inline link reference, no link destination takes precedence over shortcut link reference', + (WidgetTester tester) async { + const String data = '[foo]()\n\n[foo]: /url1'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + expectValidLink('foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '')); + }, + ); + + testWidgets( + // Example 576 from GFM. + 'inline link reference, invalid link destination is a link followed by text', + (WidgetTester tester) async { + const String data = '[foo](not a link)\n\n[foo]: /url1'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + + expectLinkTextSpan(span.children![0] as TextSpan, 'foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url1')); + + expect(span.children![1], isA()); + expect(span.children![1].toPlainText(), '(not a link)'); + expectTextSpanStyle(span.children![1] as TextSpan, null, FontWeight.normal); + }, + ); + + testWidgets( + // Example 577 from GFM. + 'three sequential runs of square-bracketed text, normal text and a link reference', + (WidgetTester tester) async { + const String data = '[foo][bar][baz]\n\n[baz]: /url'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[foo]'); + expectTextSpanStyle(span.children![0] as TextSpan, null, FontWeight.normal); + + expectLinkTextSpan(span.children![1] as TextSpan, 'bar'); + expectLinkTap(linkTapResults, const MarkdownLink('bar', '/url')); + }, + ); + + testWidgets( + // Example 578 from GFM. + 'three sequential runs of square-bracketed text, two link references', + (WidgetTester tester) async { + const String data = '[foo][bar][baz]\n\n[baz]: /url1\n[bar]: /url2'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + + expectLinkTextSpan(span.children![0] as TextSpan, 'foo'); + expectLinkTap(linkTapResults, const MarkdownLink('foo', '/url2')); + + expectLinkTextSpan(span.children![1] as TextSpan, 'baz'); + expectLinkTap(linkTapResults, const MarkdownLink('baz', '/url1')); + }, + ); + + testWidgets( + // Example 579 from GFM. + 'full reference link followed by a shortcut reference link', + (WidgetTester tester) async { + const String data = '[foo][bar][baz]\n\n[baz]: /url1\n[foo]: /url2'; + MarkdownLink? linkTapResults; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + onTapLink: (String text, String? href, String title) => linkTapResults = MarkdownLink(text, href, title), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + final TextSpan span = textWidget.textSpan! as TextSpan; + expect(span.children!.length, 2); + expect(span.children![0], isA()); + expect(span.children![0].toPlainText(), '[foo]'); + + expectLinkTextSpan(span.children![1] as TextSpan, 'bar'); + expectLinkTap(linkTapResults, const MarkdownLink('bar', '/url1')); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/list_test.dart b/flutter_markdown_plus/test/list_test.dart new file mode 100644 index 0000000..f7e1a3d --- /dev/null +++ b/flutter_markdown_plus/test/list_test.dart @@ -0,0 +1,302 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Unordered List', () { + testWidgets( + 'simple 3 item list', + (WidgetTester tester) async { + const String data = '- Item 1\n- Item 2\n- Item 3'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '•', + 'Item 1', + '•', + 'Item 2', + '•', + 'Item 3', + ]); + }, + ); + + testWidgets( + 'empty list item', + (WidgetTester tester) async { + const String data = '- \n- Item 2\n- Item 3'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '•', + '•', + 'Item 2', + '•', + 'Item 3', + ]); + }, + ); + + testWidgets( + // Example 236 from the GitHub Flavored Markdown specification. + 'leading space are ignored', (WidgetTester tester) async { + const String data = ' - one\n\n two'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '•', + 'one', + 'two', + ]); + }); + + testWidgets( + 'leading spaces are ignored (non-paragraph test case)', + (WidgetTester tester) async { + const String data = '- one\n- two\n- three'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, [ + '•', + 'one', + '•', + 'two', + '•', + 'three', + ]); + }, + ); + + testWidgets('custom bullet builder', (WidgetTester tester) async { + const String data = '* Item 1\n * Item 2\n * Item 3\n * Item 4\n* Item 5'; + Widget builder(MarkdownBulletParameters parameters) => Text( + '${parameters.index} ${parameters.style == BulletStyle.orderedList ? 'ordered' : 'unordered'} ${parameters.nestLevel}', + ); + + await tester.pumpWidget( + boilerplate( + Markdown(data: data, bulletBuilder: builder), + ), + ); + + final Iterable widgets = tester.allWidgets; + + expectTextStrings(widgets, [ + '0 unordered 0', + 'Item 1', + '0 unordered 1', + 'Item 2', + '0 unordered 2', + 'Item 3', + '1 unordered 1', + 'Item 4', + '1 unordered 0', + 'Item 5', + ]); + }); + }); + + group('Ordered List', () { + testWidgets( + '2 distinct ordered lists with separate index values', + (WidgetTester tester) async { + const String data = '1. Item 1\n1. Item 2\n2. Item 3\n\n\n' + '10. Item 10\n13. Item 11'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings( + widgets, ['1.', 'Item 1', '2.', 'Item 2', '3.', 'Item 3', '4.', 'Item 10', '5.', 'Item 11']); + }, + ); + + testWidgets('leading space are ignored', (WidgetTester tester) async { + const String data = ' 1. one\n\n two'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, ['1.', 'one', 'two']); + }); + + testWidgets('custom bullet builder', (WidgetTester tester) async { + const String data = '1. Item 1\n 1. Item 2\n 1. Item 3\n 1. Item 4\n1. Item 5'; + Widget builder(MarkdownBulletParameters parameters) => Text( + '${parameters.index} ${parameters.style == BulletStyle.orderedList ? 'ordered' : 'unordered'} ${parameters.nestLevel}', + ); + + await tester.pumpWidget( + boilerplate( + Markdown(data: data, bulletBuilder: builder), + ), + ); + + final Iterable widgets = tester.allWidgets; + + expectTextStrings(widgets, [ + '0 ordered 0', + 'Item 1', + '0 ordered 1', + 'Item 2', + '0 ordered 2', + 'Item 3', + '1 ordered 1', + 'Item 4', + '1 ordered 0', + 'Item 5', + ]); + }); + }); + + group('Task List', () { + testWidgets( + 'simple 2 item task list', + (WidgetTester tester) async { + const String data = '- [x] Item 1\n- [ ] Item 2'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + + expectTextStrings(widgets, [ + String.fromCharCode(Icons.check_box.codePoint), + 'Item 1', + String.fromCharCode(Icons.check_box_outline_blank.codePoint), + 'Item 2', + ]); + }, + ); + + testWidgets('custom bullet builder', (WidgetTester tester) async { + const String data = '* Item 1\n* Item 2\n1) Item 3\n2) Item 4'; + Widget builder(MarkdownBulletParameters parameters) => + Text('${parameters.index} ${parameters.style == BulletStyle.orderedList ? 'ordered' : 'unordered'}'); + + await tester.pumpWidget( + boilerplate( + Markdown(data: data, bulletBuilder: builder), + ), + ); + + final Iterable widgets = tester.allWidgets; + + expectTextStrings(widgets, [ + '0 unordered', + 'Item 1', + '1 unordered', + 'Item 2', + '0 ordered', + 'Item 3', + '1 ordered', + 'Item 4', + ]); + }); + + testWidgets( + 'custom checkbox builder', + (WidgetTester tester) async { + const String data = '- [x] Item 1\n- [ ] Item 2'; + Widget builder(bool checked) => Text('$checked'); + + await tester.pumpWidget( + boilerplate( + Markdown(data: data, checkboxBuilder: builder), + ), + ); + + final Iterable widgets = tester.allWidgets; + + expectTextStrings(widgets, [ + 'true', + 'Item 1', + 'false', + 'Item 2', + ]); + }, + ); + }); + + group('fitContent', () { + testWidgets( + 'uses maximum width when false', + (WidgetTester tester) async { + const String data = '- Foo\n- Bar'; + + await tester.pumpWidget( + boilerplate( + const Column( + children: [ + MarkdownBody(fitContent: false, data: data), + ], + ), + ), + ); + + final double screenWidth = find.byType(Column).evaluate().first.size!.width; + final double markdownBodyWidth = find.byType(MarkdownBody).evaluate().single.size!.width; + + expect(markdownBodyWidth, equals(screenWidth)); + }, + ); + + testWidgets( + 'uses minimum width when true', + (WidgetTester tester) async { + const String data = '- Foo\n- Bar'; + + await tester.pumpWidget( + boilerplate( + const Column( + children: [ + MarkdownBody(data: data), + ], + ), + ), + ); + + final double screenWidth = find.byType(Column).evaluate().first.size!.width; + final double markdownBodyWidth = find.byType(MarkdownBody).evaluate().single.size!.width; + + expect(markdownBodyWidth, lessThan(screenWidth)); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/markdown_body_shrink_wrap_test.dart b/flutter_markdown_plus/test/markdown_body_shrink_wrap_test.dart new file mode 100644 index 0000000..dbee007 --- /dev/null +++ b/flutter_markdown_plus/test/markdown_body_shrink_wrap_test.dart @@ -0,0 +1,76 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('MarkdownBody shrinkWrap test', () { + testWidgets( + 'Given a MarkdownBody with shrinkWrap=true ' + 'Then it wraps its content', + (WidgetTester tester) async { + await tester.pumpWidget(boilerplate( + const Stack( + children: [ + Text('shrinkWrap=true'), + Align( + alignment: Alignment.bottomCenter, + child: MarkdownBody( + data: 'This is a [link](https://flutter.dev/)', + ), + ), + ], + ), + )); + + final Rect stackRect = tester.getRect(find.byType(Stack)); + final Rect textRect = tester.getRect(find.text('shrinkWrap=true')); + final Rect markdownBodyRect = tester.getRect(find.byType(MarkdownBody)); + + // The Text should be on the top of the Stack + expect(textRect.top, equals(stackRect.top)); + expect(textRect.bottom, lessThan(stackRect.bottom)); + // The MarkdownBody should be on the bottom of the Stack + expect(markdownBodyRect.top, greaterThan(stackRect.top)); + expect(markdownBodyRect.bottom, equals(stackRect.bottom)); + }, + ); + testWidgets( + 'Given a MarkdownBody with shrinkWrap=false ' + 'Then it expands to the maximum allowed height', + (WidgetTester tester) async { + await tester.pumpWidget(boilerplate( + const Stack( + children: [ + Text('shrinkWrap=false test'), + Align( + alignment: Alignment.bottomCenter, + child: MarkdownBody( + data: 'This is a [link](https://flutter.dev/)', + shrinkWrap: false, + ), + ), + ], + ), + )); + + final Rect stackRect = tester.getRect(find.byType(Stack)); + final Rect textRect = tester.getRect(find.text('shrinkWrap=false test')); + final Rect markdownBodyRect = tester.getRect(find.byType(MarkdownBody)); + + // The Text should be on the top of the Stack + expect(textRect.top, equals(stackRect.top)); + expect(textRect.bottom, lessThan(stackRect.bottom)); + // The MarkdownBody should take all Stack's height + expect(markdownBodyRect.top, equals(stackRect.top)); + expect(markdownBodyRect.bottom, equals(stackRect.bottom)); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/padding_test.dart b/flutter_markdown_plus/test/padding_test.dart new file mode 100644 index 0000000..ec835b5 --- /dev/null +++ b/flutter_markdown_plus/test/padding_test.dart @@ -0,0 +1,67 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Padding builders', () { + testWidgets( + 'use paddingBuilders for p', + (WidgetTester tester) async { + const double paddingX = 10.0; + + await tester.pumpWidget( + boilerplate( + Markdown( + data: '**line 1**\n\n# H1\n![alt](/assets/images/logo.png)', + paddingBuilders: { + 'p': CustomPaddingBuilder(paddingX * 1), + 'strong': CustomPaddingBuilder(paddingX * 2), + 'h1': CustomPaddingBuilder(paddingX * 3), + 'img': CustomPaddingBuilder(paddingX * 4), + }), + ), + ); + + final List paddings = tester.widgetList(find.byType(Padding)).toList(); + + expect(paddings.length, 4); + expect( + paddings[0].padding.along(Axis.horizontal) == paddingX * 1 * 2, + true, + ); + expect( + paddings[1].padding.along(Axis.horizontal) == paddingX * 3 * 2, + true, + ); + expect( + paddings[2].padding.along(Axis.horizontal) == paddingX * 1 * 2, + true, + ); + expect( + paddings[3].padding.along(Axis.horizontal) == paddingX * 4 * 2, + true, + ); + imageCache.clear(); + }, + ); + }); +} + +class CustomPaddingBuilder extends MarkdownPaddingBuilder { + CustomPaddingBuilder(this.paddingX); + + double paddingX; + + @override + EdgeInsets getPadding() { + return EdgeInsets.symmetric(horizontal: paddingX); + } +} diff --git a/flutter_markdown_plus/test/scrollable_test.dart b/flutter_markdown_plus/test/scrollable_test.dart new file mode 100644 index 0000000..a865cca --- /dev/null +++ b/flutter_markdown_plus/test/scrollable_test.dart @@ -0,0 +1,195 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Scrollable', () { + testWidgets( + 'code block', + (WidgetTester tester) async { + const String data = "```\nvoid main() {\n print('Hello World!');\n}\n```"; + + await tester.pumpWidget( + boilerplate( + const MediaQuery( + data: MediaQueryData(), + child: MarkdownBody(data: data), + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Iterable scrollViews = widgets.whereType(); + expect(scrollViews, isNotEmpty); + expect(scrollViews.first.controller, isNotNull); + }, + ); + + testWidgets( + 'two code blocks use different scroll controllers', + (WidgetTester tester) async { + const String data = "```\nvoid main() {\n print('Hello World!');\n}\n```" + '\n' + "```\nvoid main() {\n print('Hello World!');\n}\n```"; + + await tester.pumpWidget( + boilerplate( + const MediaQuery( + data: MediaQueryData(), + child: MarkdownBody(data: data), + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Iterable scrollViews = widgets.whereType(); + expect(scrollViews, hasLength(2)); + expect(scrollViews.first.controller, isNotNull); + expect(scrollViews.last.controller, isNotNull); + expect(scrollViews.first.controller, isNot(equals(scrollViews.last.controller))); + }, + ); + + testWidgets( + 'controller', + (WidgetTester tester) async { + final ScrollController controller = ScrollController( + initialScrollOffset: 209.0, + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + boilerplate( + Markdown(controller: controller, data: ''), + ), + ); + + double realOffset() { + return tester.state(find.byType(Scrollable)).position.pixels; + } + + expect(controller.offset, equals(209.0)); + expect(realOffset(), equals(controller.offset)); + }, + ); + + testWidgets( + 'Scrollable wrapping', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + const Markdown(data: ''), + ), + ); + + final List widgets = selfAndDescendantWidgetsOf( + find.byType(Markdown), + tester, + ).toList(); + expectWidgetTypes(widgets.take(2), [ + Markdown, + ListView, + ]); + expectWidgetTypes(widgets.reversed.take(2).toList().reversed, [ + SliverPadding, + SliverList, + ]); + }, + ); + + testWidgets( + 'table with fixed column width', + (WidgetTester tester) async { + const String data = '|Header 1|Header 2|Header 3|' + '\n|-----|-----|-----|' + '\n|Col 1|Col 2|Col 3|'; + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(), + child: MarkdownBody( + data: data, + styleSheet: MarkdownStyleSheet( + tableColumnWidth: const FixedColumnWidth(150), + ), + ), + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Iterable scrollViews = widgets.whereType(); + expect(scrollViews, isNotEmpty); + expect(scrollViews.first.controller, isNotNull); + }, + ); + + testWidgets( + 'table with intrinsic column width', + (WidgetTester tester) async { + const String data = '|Header 1|Header 2|Header 3|' + '\n|-----|-----|-----|' + '\n|Col 1|Col 2|Col 3|'; + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(), + child: MarkdownBody( + data: data, + styleSheet: MarkdownStyleSheet( + tableColumnWidth: const IntrinsicColumnWidth(), + ), + ), + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Iterable scrollViews = widgets.whereType(); + expect(scrollViews, isNotEmpty); + expect(scrollViews.first.controller, isNotNull); + }, + ); + + testWidgets( + 'two tables use different scroll controllers', + (WidgetTester tester) async { + const String data = '|Header 1|Header 2|Header 3|' + '\n|-----|-----|-----|' + '\n|Col 1|Col 2|Col 3|' + '\n' + '\n|Header 1|Header 2|Header 3|' + '\n|-----|-----|-----|' + '\n|Col 1|Col 2|Col 3|'; + + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(), + child: MarkdownBody( + data: data, + styleSheet: MarkdownStyleSheet( + tableColumnWidth: const FixedColumnWidth(150), + ), + ), + ), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Iterable scrollViews = widgets.whereType(); + expect(scrollViews, hasLength(2)); + expect(scrollViews.first.controller, isNotNull); + expect(scrollViews.last.controller, isNotNull); + expect(scrollViews.first.controller, isNot(equals(scrollViews.last.controller))); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/selection_area_compatibility_test.dart b/flutter_markdown_plus/test/selection_area_compatibility_test.dart new file mode 100644 index 0000000..985786b --- /dev/null +++ b/flutter_markdown_plus/test/selection_area_compatibility_test.dart @@ -0,0 +1,71 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Compatible with SelectionArea when selectable is default to false', () { + testWidgets( + 'Text can be selected', + (WidgetTester tester) async { + SelectedContent? content; + + const String data = 'How are you?'; + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + child: const Markdown( + data: data, + ), + onSelectionChanged: (SelectedContent? selectedContent) => content = selectedContent, + ))); + + final TestGesture gesture = + await tester.startGesture(tester.getTopLeft(find.text('How are you?')), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(tester.getBottomRight(find.text('How are you?'))); + await gesture.up(); + await tester.pump(); + + expect(content, isNotNull); + expect(content!.plainText, 'How are you?'); + }, + ); + + testWidgets( + 'List can be selected', + (WidgetTester tester) async { + SelectedContent? content; + + const String data = '- Item 1\n- Item 2\n- Item 3'; + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + child: const Markdown( + data: data, + ), + onSelectionChanged: (SelectedContent? selectedContent) => content = selectedContent, + ))); + + final TestGesture gesture = + await tester.startGesture(tester.getTopLeft(find.byType(Markdown)), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(tester.getBottomRight(find.byType(Markdown))); + await gesture.up(); + await tester.pump(); + + expect(content, isNotNull); + expect(content!.plainText, '•Item 1•Item 2•Item 3'); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/style_sheet_test.dart b/flutter_markdown_plus/test/style_sheet_test.dart new file mode 100644 index 0000000..8ef8e1b --- /dev/null +++ b/flutter_markdown_plus/test/style_sheet_test.dart @@ -0,0 +1,445 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Style Sheet', () { + testWidgets( + 'equality - Cupertino', + (WidgetTester tester) async { + const CupertinoThemeData theme = CupertinoThemeData(brightness: Brightness.light); + + final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromCupertinoTheme(theme); + final MarkdownStyleSheet style2 = MarkdownStyleSheet.fromCupertinoTheme(theme); + expect(style1, equals(style2)); + expect(style1.hashCode, equals(style2.hashCode)); + }, + ); + + testWidgets( + 'equality - Material', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromTheme(theme); + final MarkdownStyleSheet style2 = MarkdownStyleSheet.fromTheme(theme); + expect(style1, equals(style2)); + expect(style1.hashCode, equals(style2.hashCode)); + }, + ); + + testWidgets( + 'MarkdownStyleSheet.fromCupertinoTheme', + (WidgetTester tester) async { + const CupertinoThemeData cTheme = CupertinoThemeData( + brightness: Brightness.dark, + ); + + final MarkdownStyleSheet style = MarkdownStyleSheet.fromCupertinoTheme(cTheme); + + // a + expect(style.a!.color, CupertinoColors.link.darkColor); + expect(style.a!.fontSize, cTheme.textTheme.textStyle.fontSize); + + // p + expect(style.p, cTheme.textTheme.textStyle); + + // code + expect(style.code!.color, cTheme.textTheme.textStyle.color); + expect(style.code!.fontSize, cTheme.textTheme.textStyle.fontSize! * 0.85); + expect(style.code!.fontFamily, 'monospace'); + + // H1 + expect(style.h1!.color, cTheme.textTheme.textStyle.color); + expect(style.h1!.fontSize, cTheme.textTheme.textStyle.fontSize! + 10); + expect(style.h1!.fontWeight, FontWeight.w500); + + // H2 + expect(style.h2!.color, cTheme.textTheme.textStyle.color); + expect(style.h2!.fontSize, cTheme.textTheme.textStyle.fontSize! + 8); + expect(style.h2!.fontWeight, FontWeight.w500); + + // H3 + expect(style.h3!.color, cTheme.textTheme.textStyle.color); + expect(style.h3!.fontSize, cTheme.textTheme.textStyle.fontSize! + 6); + expect(style.h3!.fontWeight, FontWeight.w500); + + // H4 + expect(style.h4!.color, cTheme.textTheme.textStyle.color); + expect(style.h4!.fontSize, cTheme.textTheme.textStyle.fontSize! + 4); + expect(style.h4!.fontWeight, FontWeight.w500); + + // H5 + expect(style.h5!.color, cTheme.textTheme.textStyle.color); + expect(style.h5!.fontSize, cTheme.textTheme.textStyle.fontSize! + 2); + expect(style.h5!.fontWeight, FontWeight.w500); + + // H6 + expect(style.h6!.color, cTheme.textTheme.textStyle.color); + expect(style.h6!.fontSize, cTheme.textTheme.textStyle.fontSize); + expect(style.h6!.fontWeight, FontWeight.w500); + + // em + expect(style.em!.color, cTheme.textTheme.textStyle.color); + expect(style.em!.fontSize, cTheme.textTheme.textStyle.fontSize); + expect(style.em!.fontStyle, FontStyle.italic); + + // strong + expect(style.strong!.color, cTheme.textTheme.textStyle.color); + expect(style.strong!.fontSize, cTheme.textTheme.textStyle.fontSize); + expect(style.strong!.fontWeight, FontWeight.bold); + + // del + expect(style.del!.color, cTheme.textTheme.textStyle.color); + expect(style.del!.fontSize, cTheme.textTheme.textStyle.fontSize); + expect(style.del!.decoration, TextDecoration.lineThrough); + + // blockqoute + expect(style.blockquote, cTheme.textTheme.textStyle); + + // img + expect(style.img, cTheme.textTheme.textStyle); + + // checkbox + expect(style.checkbox!.color, cTheme.primaryColor); + expect(style.checkbox!.fontSize, cTheme.textTheme.textStyle.fontSize); + + // tableHead + expect(style.tableHead!.color, cTheme.textTheme.textStyle.color); + expect(style.tableHead!.fontSize, cTheme.textTheme.textStyle.fontSize); + expect(style.tableHead!.fontWeight, FontWeight.w600); + + // tableBody + expect(style.tableBody, cTheme.textTheme.textStyle); + }, + ); + + testWidgets( + 'MarkdownStyleSheet.fromTheme', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.dark().copyWith( + textTheme: const TextTheme( + bodyMedium: TextStyle(fontSize: 12.0), + ), + ); + + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme); + + // a + expect(style.a!.color, Colors.blue); + + // p + expect(style.p, theme.textTheme.bodyMedium); + + // code + expect(style.code!.color, theme.textTheme.bodyMedium!.color); + expect(style.code!.fontSize, theme.textTheme.bodyMedium!.fontSize! * 0.85); + expect(style.code!.fontFamily, 'monospace'); + expect(style.code!.backgroundColor, theme.cardTheme.color); + + // H1 + expect(style.h1, theme.textTheme.headlineSmall); + + // H2 + expect(style.h2, theme.textTheme.titleLarge); + + // H3 + expect(style.h3, theme.textTheme.titleMedium); + + // H4 + expect(style.h4, theme.textTheme.bodyLarge); + + // H5 + expect(style.h5, theme.textTheme.bodyLarge); + + // H6 + expect(style.h6, theme.textTheme.bodyLarge); + + // em + expect(style.em!.fontStyle, FontStyle.italic); + expect(style.em!.color, theme.textTheme.bodyMedium!.color); + + // strong + expect(style.strong!.fontWeight, FontWeight.bold); + expect(style.strong!.color, theme.textTheme.bodyMedium!.color); + + // del + expect(style.del!.decoration, TextDecoration.lineThrough); + expect(style.del!.color, theme.textTheme.bodyMedium!.color); + + // blockqoute + expect(style.blockquote, theme.textTheme.bodyMedium); + + // img + expect(style.img, theme.textTheme.bodyMedium); + + // checkbox + expect(style.checkbox!.color, theme.primaryColor); + expect(style.checkbox!.fontSize, theme.textTheme.bodyMedium!.fontSize); + + // tableHead + expect(style.tableHead!.fontWeight, FontWeight.w600); + + // tableBody + expect(style.tableBody, theme.textTheme.bodyMedium); + }, + ); + + testWidgets( + 'merge 2 style sheets', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromTheme(theme); + final MarkdownStyleSheet style2 = MarkdownStyleSheet( + p: const TextStyle(color: Colors.red), + blockquote: const TextStyle(fontSize: 16), + ); + + final MarkdownStyleSheet merged = style1.merge(style2); + expect(merged.p!.color, Colors.red); + expect(merged.blockquote!.fontSize, 16); + expect(merged.blockquote!.color, theme.textTheme.bodyMedium!.color); + }, + ); + + testWidgets( + 'create based on which theme', + (WidgetTester tester) async { + const String data = '[title](url)'; + await tester.pumpWidget( + boilerplate( + const Markdown( + data: data, + styleSheetTheme: MarkdownStyleSheetBaseTheme.cupertino, + ), + ), + ); + + final Text widget = tester.widget(find.byType(Text)); + expect(widget.textSpan!.style!.color, CupertinoColors.link.color); + }, + ); + + testWidgets( + 'apply 2 distinct style sheets', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromTheme(theme); + final MarkdownStyleSheet style2 = MarkdownStyleSheet.largeFromTheme(theme); + expect(style1, isNot(style2)); + + await tester.pumpWidget( + boilerplate( + Markdown( + data: '# Test', + styleSheet: style1, + ), + ), + ); + + final RichText text1 = tester.widget(find.byType(RichText)); + await tester.pumpWidget( + boilerplate( + Markdown( + data: '# Test', + styleSheet: style2, + ), + ), + ); + final RichText text2 = tester.widget(find.byType(RichText)); + + expect(text1.text, isNot(text2.text)); + }, + ); + + testWidgets( + 'use stylesheet option listBulletPadding', + (WidgetTester tester) async { + const double paddingX = 20.0; + final MarkdownStyleSheet style = + MarkdownStyleSheet(listBulletPadding: const EdgeInsets.symmetric(horizontal: paddingX)); + + await tester.pumpWidget( + boilerplate( + Markdown( + data: '1. Bullet\n 2. Bullet\n * Bullet', + styleSheet: style, + ), + ), + ); + + final List paddings = tester.widgetList(find.byType(Padding)).toList(); + + expect(paddings.length, 3); + expect( + paddings.every( + (Padding p) => p.padding.along(Axis.horizontal) == paddingX * 2, + ), + true, + ); + }, + ); + + testWidgets( + 'check widgets for use stylesheet option h1Padding', + (WidgetTester tester) async { + const String data = '# Header'; + const double paddingX = 20.0; + final MarkdownStyleSheet style = MarkdownStyleSheet( + h1Padding: const EdgeInsets.symmetric(horizontal: paddingX), + ); + + await tester.pumpWidget(boilerplate(MarkdownBody( + data: data, + styleSheet: style, + ))); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + Padding, + Wrap, + Text, + RichText, + ]); + expectTextStrings(widgets, ['Header']); + }, + ); + + testWidgets( + 'use stylesheet option pPadding', + (WidgetTester tester) async { + const double paddingX = 20.0; + final MarkdownStyleSheet style = MarkdownStyleSheet( + pPadding: const EdgeInsets.symmetric(horizontal: paddingX), + ); + + await tester.pumpWidget( + boilerplate( + Markdown( + data: 'Test line 1\n\nTest line 2\n\nTest line 3\n# H1', + styleSheet: style, + ), + ), + ); + + final List paddings = tester.widgetList(find.byType(Padding)).toList(); + + expect(paddings.length, 3); + expect( + paddings.every( + (Padding p) => p.padding.along(Axis.horizontal) == paddingX * 2, + ), + true, + ); + }, + ); + + testWidgets( + 'use stylesheet option h1Padding-h6Padding', + (WidgetTester tester) async { + const double paddingX = 20.0; + final MarkdownStyleSheet style = MarkdownStyleSheet( + h1Padding: const EdgeInsets.symmetric(horizontal: paddingX), + h2Padding: const EdgeInsets.symmetric(horizontal: paddingX), + h3Padding: const EdgeInsets.symmetric(horizontal: paddingX), + h4Padding: const EdgeInsets.symmetric(horizontal: paddingX), + h5Padding: const EdgeInsets.symmetric(horizontal: paddingX), + h6Padding: const EdgeInsets.symmetric(horizontal: paddingX), + ); + + await tester.pumpWidget( + boilerplate( + Markdown( + data: 'Test\n\n# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6\n', + styleSheet: style, + ), + ), + ); + + final List paddings = tester.widgetList(find.byType(Padding)).toList(); + + expect(paddings.length, 6); + expect( + paddings.every( + (Padding p) => p.padding.along(Axis.horizontal) == paddingX * 2, + ), + true, + ); + }, + ); + + testWidgets( + 'deprecated textScaleFactor is converted to linear scaler', + (WidgetTester tester) async { + const double scaleFactor = 2.0; + final MarkdownStyleSheet style = MarkdownStyleSheet( + textScaleFactor: scaleFactor, + ); + + expect(style.textScaler, const TextScaler.linear(scaleFactor)); + expect(style.textScaleFactor, scaleFactor); + }, + ); + + testWidgets( + 'deprecated textScaleFactor is null when a scaler is provided', + (WidgetTester tester) async { + const TextScaler scaler = TextScaler.linear(2.0); + final MarkdownStyleSheet style = MarkdownStyleSheet( + textScaler: scaler, + ); + + expect(style.textScaler, scaler); + expect(style.textScaleFactor, null); + }, + ); + + testWidgets( + 'copyWith textScaler overwrites both textScaler and textScaleFactor', + (WidgetTester tester) async { + final MarkdownStyleSheet original = MarkdownStyleSheet( + textScaleFactor: 2.0, + ); + + const TextScaler newScaler = TextScaler.linear(3.0); + final MarkdownStyleSheet copy = original.copyWith( + textScaler: newScaler, + ); + + expect(copy.textScaler, newScaler); + expect(copy.textScaleFactor, null); + }, + ); + + testWidgets( + 'copyWith textScaleFactor overwrites both textScaler and textScaleFactor', + (WidgetTester tester) async { + final MarkdownStyleSheet original = MarkdownStyleSheet( + textScaleFactor: 2.0, + ); + + const double newScaleFactor = 3.0; + final MarkdownStyleSheet copy = original.copyWith( + textScaleFactor: newScaleFactor, + ); + + expect(copy.textScaler, const TextScaler.linear(newScaleFactor)); + expect(copy.textScaleFactor, newScaleFactor); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/table_test.dart b/flutter_markdown_plus/test/table_test.dart new file mode 100644 index 0000000..bdfc343 --- /dev/null +++ b/flutter_markdown_plus/test/table_test.dart @@ -0,0 +1,702 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Table', () { + testWidgets( + 'should show properly', + (WidgetTester tester) async { + const String data = '|Header 1|Header 2|\n|-----|-----|\n|Col 1|Col 2|'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, ['Header 1', 'Header 2', 'Col 1', 'Col 2']); + }, + ); + + testWidgets( + 'work without the outer pipes', + (WidgetTester tester) async { + const String data = 'Header 1|Header 2\n-----|-----\nCol 1|Col 2'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + expectTextStrings(widgets, ['Header 1', 'Header 2', 'Col 1', 'Col 2']); + }, + ); + + testWidgets( + 'should work with alignments', + (WidgetTester tester) async { + const String data = '|Header 1|Header 2|Header 3|\n|:----|:----:|----:|\n|Col 1|Col 2|Col 3|'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable styles = tester.widgetList(find.byType(DefaultTextStyle)); + + expect(styles.first.textAlign, TextAlign.left); + expect(styles.elementAt(1).textAlign, TextAlign.center); + expect(styles.last.textAlign, TextAlign.right); + + final Iterable wraps = tester.widgetList(find.byType(Wrap)); + + expect(wraps.first.alignment, WrapAlignment.start); + expect(wraps.elementAt(1).alignment, WrapAlignment.center); + expect(wraps.last.alignment, WrapAlignment.end); + + final Iterable texts = tester.widgetList(find.byType(Text)); + + expect(texts.first.textAlign, TextAlign.left); + expect(texts.elementAt(1).textAlign, TextAlign.center); + expect(texts.last.textAlign, TextAlign.right); + }, + ); + + testWidgets( + 'should work with styling', + (WidgetTester tester) async { + const String data = '|Header|\n|----|\n|*italic*|'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Text text = widgets.lastWhere((Widget widget) => widget is Text) as Text; + + expectTextStrings(widgets, ['Header', 'italic']); + expect(text.textSpan!.style!.fontStyle, FontStyle.italic); + }, + ); + + testWidgets( + 'should work next to other tables', + (WidgetTester tester) async { + const String data = '|first header|\n|----|\n|first col|\n\n' + '|second header|\n|----|\n|second col|'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable tables = tester.widgetList(find.byType(Table)); + + expect(tables.length, 2); + }, + ); + + testWidgets( + 'column width should follow stylesheet', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Column|'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expect(table.defaultColumnWidth, columnWidth); + }, + ); + + testWidgets( + 'table cell vertical alignment should default to middle', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Column|'; + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme); + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expect(table.defaultVerticalAlignment, TableCellVerticalAlignment.middle); + }, + ); + + testWidgets( + 'table cell vertical alignment should follow stylesheet', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Column|'; + const TableCellVerticalAlignment tableCellVerticalAlignment = TableCellVerticalAlignment.top; + final MarkdownStyleSheet style = + MarkdownStyleSheet.fromTheme(theme).copyWith(tableVerticalAlignment: tableCellVerticalAlignment); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expect(table.defaultVerticalAlignment, tableCellVerticalAlignment); + }, + ); + + testWidgets( + 'table cell vertical alignment should follow stylesheet for different values', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Column|'; + const TableCellVerticalAlignment tableCellVerticalAlignment = TableCellVerticalAlignment.bottom; + final MarkdownStyleSheet style = + MarkdownStyleSheet.fromTheme(theme).copyWith(tableVerticalAlignment: tableCellVerticalAlignment); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expect(table.defaultVerticalAlignment, tableCellVerticalAlignment); + }, + ); + + testWidgets( + 'table scrollbar thumbVisibility should follow stylesheet', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Column|'; + const bool tableScrollbarThumbVisibility = true; + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: const FixedColumnWidth(100), + tableScrollbarThumbVisibility: tableScrollbarThumbVisibility); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Scrollbar scrollbar = tester.widget(find.byType(Scrollbar)); + + expect(scrollbar.thumbVisibility, tableScrollbarThumbVisibility); + }, + ); + + testWidgets( + 'table scrollbar thumbVisibility should follow stylesheet', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Column|'; + const bool tableScrollbarThumbVisibility = false; + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: const FixedColumnWidth(100), + tableScrollbarThumbVisibility: tableScrollbarThumbVisibility); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Scrollbar scrollbar = tester.widget(find.byType(Scrollbar)); + + expect(scrollbar.thumbVisibility, tableScrollbarThumbVisibility); + }, + ); + + testWidgets( + 'table with last row of empty table cells', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header 1|Header 2|\n|----|----|\n| | |'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expectTableSize(2, 2); + + expect(find.byType(Text), findsNWidgets(4)); + final List cellText = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text text) => text.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(cellText[0], 'Header 1'); + expect(cellText[1], 'Header 2'); + expect(cellText[2], ''); + expect(cellText[3], ''); + + expect(table.defaultColumnWidth, columnWidth); + }, + ); + + testWidgets( + 'table with an empty row an last row has an empty table cell', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header 1|Header 2|\n|----|----|\n| | |\n| bar | |'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expectTableSize(3, 2); + + expect(find.byType(RichText), findsNWidgets(6)); + final List cellText = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text richText) => richText.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(cellText[0], 'Header 1'); + expect(cellText[1], 'Header 2'); + expect(cellText[2], ''); + expect(cellText[3], ''); + expect(cellText[4], 'bar'); + expect(cellText[5], ''); + + expect(table.defaultColumnWidth, columnWidth); + }, + ); + + group('GFM Examples', () { + testWidgets( + // Example 198 from GFM. + 'simple table', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '| foo | bar |\n| --- | --- |\n| baz | bim |'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expectTableSize(2, 2); + + expect(find.byType(Text), findsNWidgets(4)); + final List cellText = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text text) => text.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(cellText[0], 'foo'); + expect(cellText[1], 'bar'); + expect(cellText[2], 'baz'); + expect(cellText[3], 'bim'); + expect(table.defaultColumnWidth, columnWidth); + }, + ); + + testWidgets( + // Example 199 from GFM. + 'input table cell data does not need to match column length', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '| abc | defghi |\n:-: | -----------:\nbar | baz'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expectTableSize(2, 2); + + expect(find.byType(Text), findsNWidgets(4)); + final List cellText = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text text) => text.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(cellText[0], 'abc'); + expect(cellText[1], 'defghi'); + expect(cellText[2], 'bar'); + expect(cellText[3], 'baz'); + expect(table.defaultColumnWidth, columnWidth); + }, + ); + + testWidgets( + // Example 200 from GFM. + 'include a pipe in table cell data by escaping the pipe', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '| f\\|oo |\n| ------ |\n| b \\| az |\n| b **\\|** im |'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expectTableSize(1, 3); + + expect(find.byType(Text), findsNWidgets(4)); + final List cellText = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text text) => text.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(cellText[0], 'f|oo'); + expect(cellText[1], 'defghi'); + expect(cellText[2], 'b | az'); + expect(cellText[3], 'b | im'); + expect(table.defaultColumnWidth, columnWidth); + }, + // TODO(mjordan56): Remove skip once the issue #340 in the markdown package + // is fixed and released. https://github.com/dart-lang/markdown/issues/340 + // This test will need adjusting once issue #340 is fixed. + skip: true, + ); + + testWidgets( + // Example 201 from GFM. + 'table definition is complete at beginning of new block', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '| abc | def |\n| --- | --- |\n| bar | baz |\n> bar'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expectTableSize(2, 2); + + expect(find.byType(Text), findsNWidgets(5)); + final List text = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text text) => text.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(text[0], 'abc'); + expect(text[1], 'def'); + expect(text[2], 'bar'); + expect(text[3], 'baz'); + expect(table.defaultColumnWidth, columnWidth); + + // Blockquote + expect(find.byType(DecoratedBox), findsOneWidget); + expect(text[4], 'bar'); + }, + ); + + testWidgets( + // Example 202 from GFM. + 'table definition is complete at first empty line', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '| abc | def |\n| --- | --- |\n| bar | baz |\nbar\n\nbar'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expectTableSize(3, 2); + + expect(find.byType(Text), findsNWidgets(7)); + final List text = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text text) => text.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(text, ['abc', 'def', 'bar', 'baz', 'bar', '', 'bar']); + expect(table.defaultColumnWidth, columnWidth); + }, + ); + + testWidgets( + // Example 203 from GFM. + 'table header row must match the delimiter row in number of cells', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '| abc | def |\n| --- |\n| bar |'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + expect(find.byType(Table), findsNothing); + final List text = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text text) => text.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(text[0], '| abc | def | | --- | | bar |'); + }, + ); + + testWidgets( + // Example 204 from GFM. + 'remainder of table cells may vary, excess cells are ignored', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '| abc | def |\n| --- | --- |\n| bar |\n| bar | baz | boo |'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expectTableSize(3, 2); + + expect(find.byType(Text), findsNWidgets(6)); + final List cellText = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text text) => text.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(cellText, ['abc', 'def', 'bar', '', 'bar', 'baz']); + expect(table.defaultColumnWidth, columnWidth); + }, + ); + + testWidgets( + // Example 205 from GFM. + 'no table body is created when no rows are defined', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '| abc | def |\n| --- | --- |'; + const FixedColumnWidth columnWidth = FixedColumnWidth(100); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableColumnWidth: columnWidth, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expectTableSize(1, 2); + + expect(find.byType(Text), findsNWidgets(2)); + final List cellText = find + .byType(Text) + .evaluate() + .map((Element e) => e.widget) + .cast() + .map((Text text) => text.textSpan!) + .cast() + .map((TextSpan e) => e.text) + .toList(); + expect(cellText[0], 'abc'); + expect(cellText[1], 'def'); + expect(table.defaultColumnWidth, columnWidth); + }, + ); + + testWidgets( + 'table header cells should use tableHeadCellsPadding when specified', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Body|'; + const EdgeInsets headerPadding = EdgeInsets.all(20); + const EdgeInsets bodyPadding = EdgeInsets.all(10); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableHeadCellsPadding: headerPadding, + tableCellsPadding: bodyPadding, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Iterable paddings = tester.widgetList(find.byType(Padding)); + // Filter to get only TableCell paddings (not other paddings like table padding) + final List cellPaddings = paddings.where((Padding p) { + return p.padding == headerPadding || p.padding == bodyPadding; + }).toList(); + + expect(cellPaddings.length, 2); + expect(cellPaddings[0].padding, headerPadding); // Header cell + expect(cellPaddings[1].padding, bodyPadding); // Body cell + }, + ); + + testWidgets( + 'table header cells should use tableCellsPadding when tableHeadCellsPadding is null', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Body|'; + const EdgeInsets cellPadding = EdgeInsets.all(12); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableCellsPadding: cellPadding, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Iterable paddings = tester.widgetList(find.byType(Padding)); + final List cellPaddings = paddings.where((Padding p) { + return p.padding == cellPadding; + }).toList(); + + expect(cellPaddings.length, 2); // Both header and body use same padding + }, + ); + + testWidgets( + 'table header row should use tableHeadCellsDecoration when specified', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Body|'; + final BoxDecoration headerDecoration = BoxDecoration( + color: Colors.blue.shade100, + ); + final BoxDecoration bodyDecoration = BoxDecoration( + color: Colors.grey.shade100, + ); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableHeadCellsDecoration: headerDecoration, + tableCellsDecoration: bodyDecoration, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expect(table.children.length, 2); // Header and body rows + expect(table.children[0].decoration, headerDecoration); // Header row + expect(table.children[1].decoration, isNull); // Body row (odd row, no decoration by default) + }, + ); + + testWidgets( + 'table header row should use tableCellsDecoration when tableHeadCellsDecoration is null', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|Header|\n|----|\n|Body|'; + final BoxDecoration bodyDecoration = BoxDecoration( + color: Colors.grey.shade100, + ); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableCellsDecoration: bodyDecoration, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expect(table.children.length, 2); // Header and body rows + expect(table.children[0].decoration, bodyDecoration); // Header falls back to body decoration + expect(table.children[1].decoration, isNull); // First body row (odd) remains undecorated + }, + ); + + testWidgets( + 'table with multiple rows should apply decorations correctly', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + + const String data = '|H1|H2|\n|--|--|\n|R1C1|R1C2|\n|R2C1|R2C2|\n|R3C1|R3C2|'; + final BoxDecoration headerDecoration = BoxDecoration( + color: Colors.blue.shade100, + ); + final BoxDecoration bodyDecoration = BoxDecoration( + color: Colors.grey.shade100, + ); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + tableHeadCellsDecoration: headerDecoration, + tableCellsDecoration: bodyDecoration, + ); + + await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style))); + + final Table table = tester.widget(find.byType(Table)); + + expect(table.children.length, 4); // 1 header + 3 body rows + expect(table.children[0].decoration, headerDecoration); // Header row (length=0) + expect(table.children[1].decoration, isNull); // Body row 1 (length=1, odd) + expect(table.children[2].decoration, bodyDecoration); // Body row 2 (length=2, even) + expect(table.children[3].decoration, isNull); // Body row 3 (length=3, odd) + }, + ); + }); + }); +} diff --git a/flutter_markdown_plus/test/text_alignment_test.dart b/flutter_markdown_plus/test/text_alignment_test.dart new file mode 100644 index 0000000..3519b5b --- /dev/null +++ b/flutter_markdown_plus/test/text_alignment_test.dart @@ -0,0 +1,107 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Text Alignment', () { + testWidgets( + 'apply text alignments from stylesheet', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromTheme(theme).copyWith( + h1Align: WrapAlignment.center, + h3Align: WrapAlignment.end, + ); + + const String data = '# h1\n ## h2'; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + styleSheet: style1, + ), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + Column, + Wrap, + Text, + RichText, + SizedBox, + Column, + Wrap, + Text, + RichText, + ]); + + expect((widgets.firstWhere((Widget w) => w is RichText) as RichText).textAlign, TextAlign.center); + expect((widgets.last as RichText).textAlign, TextAlign.start, + reason: 'default alignment if none is set in stylesheet'); + }, + ); + + testWidgets( + 'should align formatted text', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + textAlign: WrapAlignment.spaceBetween, + ); + + const String data = 'hello __my formatted text__'; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + styleSheet: style, + ), + ), + ); + + final RichText text = tester.widgetList(find.byType(RichText)).single as RichText; + expect(text.textAlign, TextAlign.justify); + }, + ); + + testWidgets( + 'should align selectable text', + (WidgetTester tester) async { + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith( + textAlign: WrapAlignment.spaceBetween, + ); + + const String data = 'hello __my formatted text__'; + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(), + child: MarkdownBody( + data: data, + styleSheet: style, + selectable: true, + ), + ), + ), + ); + + final SelectableText text = tester.widgetList(find.byType(SelectableText)).single as SelectableText; + expect(text.textAlign, TextAlign.justify); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/text_scaler_test.dart b/flutter_markdown_plus/test/text_scaler_test.dart new file mode 100644 index 0000000..50e82a6 --- /dev/null +++ b/flutter_markdown_plus/test/text_scaler_test.dart @@ -0,0 +1,76 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Text Scaler', () { + testWidgets( + 'should use style textScaler in RichText', + (WidgetTester tester) async { + const TextScaler scaler = TextScaler.linear(2.0); + const String data = 'Hello'; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + styleSheet: MarkdownStyleSheet(textScaler: scaler), + data: data, + ), + ), + ); + + final RichText richText = tester.widget(find.byType(RichText)); + expect(richText.textScaler, scaler); + }, + ); + + testWidgets( + 'should use MediaQuery textScaler in RichText', + (WidgetTester tester) async { + const TextScaler scaler = TextScaler.linear(2.0); + const String data = 'Hello'; + await tester.pumpWidget( + boilerplate( + const MediaQuery( + data: MediaQueryData(textScaler: scaler), + child: MarkdownBody( + data: data, + ), + ), + ), + ); + + final RichText richText = tester.widget(find.byType(RichText)); + expect(richText.textScaler, scaler); + }, + ); + + testWidgets( + 'should use MediaQuery textScaler in SelectableText.rich', + (WidgetTester tester) async { + const TextScaler scaler = TextScaler.linear(2.0); + const String data = 'Hello'; + await tester.pumpWidget( + boilerplate( + const MediaQuery( + data: MediaQueryData(textScaler: scaler), + child: MarkdownBody( + data: data, + selectable: true, + ), + ), + ), + ); + + final SelectableText selectableText = tester.widget(find.byType(SelectableText)); + expect(selectableText.textScaler, scaler); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/text_test.dart b/flutter_markdown_plus/test/text_test.dart new file mode 100644 index 0000000..72f0efc --- /dev/null +++ b/flutter_markdown_plus/test/text_test.dart @@ -0,0 +1,400 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Data', () { + testWidgets( + 'simple data', + (WidgetTester tester) async { + // extract to variable; if run with --track-widget-creation using const + // widgets aren't necessarily identical if created on different lines. + const Markdown markdown = Markdown(data: 'Data1'); + + await tester.pumpWidget(boilerplate(markdown)); + expectTextStrings(tester.allWidgets, ['Data1']); + + final String stateBefore = dumpRenderView(); + await tester.pumpWidget(boilerplate(markdown)); + final String stateAfter = dumpRenderView(); + expect(stateBefore, equals(stateAfter)); + + await tester.pumpWidget(boilerplate(const Markdown(data: 'Data2'))); + expectTextStrings(tester.allWidgets, ['Data2']); + }, + ); + }); + + group('Text', () { + testWidgets( + 'Empty string', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: ''), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + ]); + }, + ); + + testWidgets( + 'Simple string', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: 'Hello'), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + Wrap, + Text, + RichText, + ]); + expectTextStrings(widgets, ['Hello']); + }, + ); + }); + + group('Leading spaces', () { + testWidgets( + // Example 192 from the GitHub Flavored Markdown specification. + 'leading space are ignored', (WidgetTester tester) async { + const String data = ' aaa\n bbb'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + Wrap, + Text, + RichText, + ]); + expectTextStrings(widgets, ['aaa bbb']); + }); + }); + + group('Line Break', () { + testWidgets( + // Example 654 from the GitHub Flavored Markdown specification. + 'two spaces at end of line inside a block element', + (WidgetTester tester) async { + const String data = 'line 1 \nline 2'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [MarkdownBody, Column, Wrap, Text, RichText]); + expectTextStrings(widgets, ['line 1\nline 2']); + }, + ); + + testWidgets( + // Example 655 from the GitHub Flavored Markdown specification. + 'backslash at end of line inside a block element', + (WidgetTester tester) async { + const String data = 'line 1\\\nline 2'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [MarkdownBody, Column, Wrap, Text, RichText]); + expectTextStrings(widgets, ['line 1\nline 2']); + }, + ); + + testWidgets( + 'non-applicable line break', + (WidgetTester tester) async { + const String data = 'line 1.\nline 2.'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + Wrap, + Text, + RichText, + ]); + expectTextStrings(widgets, ['line 1. line 2.']); + }, + ); + + testWidgets( + 'non-applicable line break', + (WidgetTester tester) async { + const String data = 'line 1.\nline 2.'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + Wrap, + Text, + RichText, + ]); + expectTextStrings(widgets, ['line 1. line 2.']); + }, + ); + + testWidgets( + 'soft line break', + (WidgetTester tester) async { + const String data = 'line 1.\nline 2.'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody( + data: data, + softLineBreak: true, + ), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [MarkdownBody, Column, Wrap, Text, RichText]); + expectTextStrings(widgets, ['line 1.\nline 2.']); + }, + ); + }); + + group('Selectable', () { + testWidgets( + 'header with line of text', + (WidgetTester tester) async { + const String data = '# Title\nHello _World_!'; + await tester.pumpWidget( + boilerplate( + const MediaQuery( + data: MediaQueryData(), + child: Markdown( + data: data, + selectable: true, + ), + ), + ), + ); + + expect(find.byType(SelectableText), findsNWidgets(2)); + }, + ); + + testWidgets( + 'header with line of text and onTap callback', + (WidgetTester tester) async { + const String data = '# Title\nHello _World_!'; + String? textTapResults; + + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(), + child: Markdown( + data: data, + selectable: true, + onTapText: () => textTapResults = 'Text has been tapped.', + ), + ), + ), + ); + + final Iterable selectableWidgets = tester.widgetList(find.byType(SelectableText)); + expect(selectableWidgets.length, 2); + + final SelectableText selectableTitle = selectableWidgets.first as SelectableText; + expect(selectableTitle, isNotNull); + expect(selectableTitle.onTap, isNotNull); + selectableTitle.onTap!(); + expect(textTapResults == 'Text has been tapped.', true); + + textTapResults = null; + final SelectableText selectableText = selectableWidgets.last as SelectableText; + expect(selectableText, isNotNull); + expect(selectableText.onTap, isNotNull); + selectableText.onTap!(); + expect(textTapResults == 'Text has been tapped.', true); + }, + ); + + testWidgets( + 'Selectable without onSelectionChanged', + (WidgetTester tester) async { + const String data = '# abc def ghi\njkl opq'; + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: MarkdownBody( + data: data, + selectable: true, + ), + ), + ), + ); + + // Find the positions before character 'd' and 'f'. + final Offset dPos = positionInRenderedText(tester, 'abc def ghi', 4); + final Offset fPos = positionInRenderedText(tester, 'abc def ghi', 6); + // Select from 'd' until 'f'. + final TestGesture firstGesture = await tester.startGesture(dPos, kind: PointerDeviceKind.mouse); + addTearDown(firstGesture.removePointer); + await tester.pump(); + await firstGesture.moveTo(fPos); + await firstGesture.up(); + await tester.pump(); + + // Find the positions before character 'j' and 'o'. + final Offset jPos = positionInRenderedText(tester, 'jkl opq', 0); + final Offset oPos = positionInRenderedText(tester, 'jkl opq', 4); + // Select from 'j' until 'o'. + final TestGesture secondGesture = await tester.startGesture(jPos, kind: PointerDeviceKind.mouse); + addTearDown(secondGesture.removePointer); + await tester.pump(); + await secondGesture.moveTo(oPos); + await secondGesture.up(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'header with line of text and onSelectionChanged callback', + (WidgetTester tester) async { + const String data = '# abc def ghi\njkl opq'; + String? selectableText; + String? selectedText; + void onSelectionChanged(String? text, TextSelection selection, SelectionChangedCause? cause) { + selectableText = text; + selectedText = text != null ? selection.textInside(text) : null; + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MarkdownBody( + data: data, + selectable: true, + onSelectionChanged: onSelectionChanged, + ), + ), + ), + ); + + // Find the positions before character 'd' and 'f'. + final Offset dPos = positionInRenderedText(tester, 'abc def ghi', 4); + final Offset fPos = positionInRenderedText(tester, 'abc def ghi', 6); + // Select from 'd' until 'f'. + final TestGesture firstGesture = await tester.startGesture(dPos, kind: PointerDeviceKind.mouse); + addTearDown(firstGesture.removePointer); + await tester.pump(); + await firstGesture.moveTo(fPos); + await firstGesture.up(); + await tester.pump(); + + expect(selectableText, 'abc def ghi'); + expect(selectedText, 'de'); + + // Find the positions before character 'j' and 'o'. + final Offset jPos = positionInRenderedText(tester, 'jkl opq', 0); + final Offset oPos = positionInRenderedText(tester, 'jkl opq', 4); + // Select from 'j' until 'o'. + final TestGesture secondGesture = await tester.startGesture(jPos, kind: PointerDeviceKind.mouse); + addTearDown(secondGesture.removePointer); + await tester.pump(); + await secondGesture.moveTo(oPos); + await secondGesture.up(); + await tester.pump(); + + expect(selectableText, 'jkl opq'); + expect(selectedText, 'jkl '); + }, + ); + }); + + group('Strikethrough', () { + testWidgets('single word', (WidgetTester tester) async { + const String data = '~~strikethrough~~'; + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: data), + ), + ); + + final Iterable widgets = selfAndDescendantWidgetsOf( + find.byType(MarkdownBody), + tester, + ); + expectWidgetTypes(widgets, [ + MarkdownBody, + Column, + Wrap, + Text, + RichText, + ]); + expectTextStrings(widgets, ['strikethrough']); + }); + }); +} diff --git a/flutter_markdown_plus/test/uri_test.dart b/flutter_markdown_plus/test/uri_test.dart new file mode 100644 index 0000000..830e80f --- /dev/null +++ b/flutter_markdown_plus/test/uri_test.dart @@ -0,0 +1,94 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'utils.dart'; + +void main() => defineTests(); + +void defineTests() { + group('Uri Data Scheme', () { + testWidgets( + 'should work with image in uri data scheme', + (WidgetTester tester) async { + const String data = '![alt](data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image; + expect(image.image.runtimeType, MemoryImage); + }, + ); + + testWidgets( + 'should work with base64 text in uri data scheme', + (WidgetTester tester) async { + const String imageData = '![alt](data:text/plan;base64,Rmx1dHRlcg==)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: imageData), + ), + ); + + final Text widget = tester.widget(find.byType(Text)); + expect(widget.runtimeType, Text); + expect(widget.data, 'Flutter'); + }, + ); + + testWidgets( + 'should work with text in uri data scheme', + (WidgetTester tester) async { + const String imageData = '![alt](data:text/plan,Hello%2C%20Flutter)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: imageData), + ), + ); + + final Text widget = tester.widget(find.byType(Text)); + expect(widget.runtimeType, Text); + expect(widget.data, 'Hello, Flutter'); + }, + ); + + testWidgets( + 'should work with empty uri data scheme', + (WidgetTester tester) async { + const String imageData = '![alt](data:,)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: imageData), + ), + ); + + final Text widget = tester.widget(find.byType(Text)); + expect(widget.runtimeType, Text); + expect(widget.data, ''); + }, + ); + + testWidgets( + 'should work with unsupported mime types of uri data scheme', + (WidgetTester tester) async { + const String data = '![alt](data:application/javascript,var%20test=1)'; + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + final Iterable widgets = tester.allWidgets; + final SizedBox widget = widgets.firstWhere((Widget widget) => widget is SizedBox) as SizedBox; + expect(widget.runtimeType, SizedBox); + }, + ); + }); +} diff --git a/flutter_markdown_plus/test/utils.dart b/flutter_markdown_plus/test/utils.dart new file mode 100644 index 0000000..d4ec80a --- /dev/null +++ b/flutter_markdown_plus/test/utils.dart @@ -0,0 +1,248 @@ +// Copyright 2013 The Flutter Authors. 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 'dart:io' as io; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final TextTheme textTheme = + Typography.material2018().black.merge(const TextTheme(bodyMedium: TextStyle(fontSize: 12.0))); + +Iterable selfAndDescendantWidgetsOf(Finder start, WidgetTester tester) { + final Element startElement = tester.element(start); + final Iterable descendants = + collectAllElementsFrom(startElement, skipOffstage: false).map((Element e) => e.widget); + return [ + startElement.widget, + ...descendants, + ]; +} + +// Returns the RenderEditable displaying the given text. +RenderEditable findRenderEditableWithText(WidgetTester tester, String text) { + final Iterable roots = tester.renderObjectList(find.byType(EditableText)); + expect(roots, isNotEmpty); + + late RenderEditable renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable && child.plainText == text) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + for (final RenderObject root in roots) { + root.visitChildren(recursiveFinder); + } + + expect(renderEditable, isNotNull); + return renderEditable; +} + +// Returns the [textOffset] position in rendered [text]. +Offset positionInRenderedText(WidgetTester tester, String text, int textOffset) { + final RenderEditable renderEditable = findRenderEditableWithText(tester, text); + final Iterable textOffsetPoints = renderEditable.getEndpointsForSelection( + TextSelection.collapsed(offset: textOffset), + ); + // Map the points to global positions. + final List endpoints = textOffsetPoints.map((TextSelectionPoint point) { + return TextSelectionPoint( + renderEditable.localToGlobal(point.point), + point.direction, + ); + }).toList(); + expect(endpoints.length, 1); + return endpoints[0].point + const Offset(kIsWeb ? 1.0 : 0.0, -2.0); +} + +void expectWidgetTypes(Iterable widgets, List expected) { + final List actual = widgets.map((Widget w) => w.runtimeType).toList(); + expect(actual, expected); +} + +void expectTextStrings(Iterable widgets, List strings) { + int currentString = 0; + for (final Widget widget in widgets) { + TextSpan? span; + if (widget is RichText) { + span = widget.text as TextSpan; + } else if (widget is SelectableText) { + span = widget.textSpan; + } + if (span != null) { + final String text = _extractTextFromTextSpan(span); + expect(text, equals(strings[currentString])); + currentString += 1; + } + } +} + +String _extractTextFromTextSpan(TextSpan span) { + String text = span.text ?? ''; + if (span.children != null) { + for (final TextSpan child in span.children!.toList().cast()) { + text += _extractTextFromTextSpan(child); + } + } + return text; +} + +// Check the font style and weight of the text span. +void expectTextSpanStyle(TextSpan textSpan, FontStyle? style, FontWeight weight) { + // Verify a text style is set + expect(textSpan.style, isNotNull, reason: 'text span text style is null'); + + // Font style check + if (style == null) { + expect(textSpan.style!.fontStyle, isNull, reason: 'font style is not null'); + } else { + expect(textSpan.style!.fontStyle, isNotNull, reason: 'font style is null'); + expect( + textSpan.style!.fontStyle == style, + isTrue, + reason: 'font style is not $style', + ); + } + + // Font weight check + expect(textSpan.style, isNotNull, reason: 'font style is null'); + expect( + textSpan.style!.fontWeight == weight, + isTrue, + reason: 'font weight is not $weight', + ); +} + +@immutable +class MarkdownLink { + const MarkdownLink(this.text, this.destination, [this.title = '']); + + final String text; + final String? destination; + final String title; + + @override + bool operator ==(Object other) => + other is MarkdownLink && other.text == text && other.destination == destination && other.title == title; + + @override + int get hashCode => '$text$destination$title'.hashCode; + + @override + String toString() { + return '[$text]($destination "$title")'; + } +} + +/// Verify a valid link structure has been created. This routine checks for the +/// link text and the associated [TapGestureRecognizer] on the text span. +void expectValidLink(String linkText) { + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + final Text text = textFinder.evaluate().first.widget as Text; + + // Verify the link text. + expect(text.textSpan, isNotNull); + expect(text.textSpan, isA()); + + // Verify the link text is a onTap gesture recognizer. + final TextSpan textSpan = text.textSpan! as TextSpan; + expectLinkTextSpan(textSpan, linkText); +} + +void expectLinkTextSpan(TextSpan textSpan, String linkText) { + expect(textSpan.children, isNull); + expect(textSpan.toPlainText(), linkText); + expect(textSpan.recognizer, isNotNull); + expect(textSpan.recognizer, isA()); + final TapGestureRecognizer? tapRecognizer = textSpan.recognizer as TapGestureRecognizer?; + expect(tapRecognizer?.onTap, isNotNull); + + // Execute the onTap callback handler. + tapRecognizer!.onTap!(); +} + +void expectInvalidLink(String linkText) { + final Finder textFinder = find.byType(Text); + expect(textFinder, findsOneWidget); + final Text text = textFinder.evaluate().first.widget as Text; + + expect(text.textSpan, isNotNull); + expect(text.textSpan, isA()); + final String plainText = text.textSpan!.toPlainText(); + expect(plainText, linkText); + + final TextSpan textSpan = text.textSpan! as TextSpan; + expect(textSpan.recognizer, isNull); +} + +void expectTableSize(int rows, int columns) { + final Finder tableFinder = find.byType(Table); + expect(tableFinder, findsOneWidget); + final Table table = tableFinder.evaluate().first.widget as Table; + + expect(table.children.length, rows); + for (int index = 0; index < rows; index++) { + expect(table.children[index].children.length, columns); + } +} + +void expectLinkTap(MarkdownLink? actual, MarkdownLink expected) { + expect(actual, equals(expected), reason: 'incorrect link tap results, actual: $actual expected: $expected'); +} + +String dumpRenderView() { + return WidgetsBinding.instance.rootElement!.toStringDeep().replaceAll( + RegExp(r'SliverChildListDelegate#\d+', multiLine: true), + 'SliverChildListDelegate', + ); +} + +/// Wraps a widget with a left-to-right [Directionality] for tests. +Widget boilerplate(Widget child) { + return Directionality( + textDirection: TextDirection.ltr, + child: child, + ); +} + +class TestAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + if (key == 'AssetManifest.json') { + const String manifest = r'{"assets/logo.png":["assets/logo.png"]}'; + final ByteData asset = ByteData.view(utf8.encoder.convert(manifest).buffer); + return Future.value(asset); + } else if (key == 'AssetManifest.bin') { + final ByteData manifest = + const StandardMessageCodec().encodeMessage(>{'assets/logo.png': []})!; + return Future.value(manifest); + } else if (key == 'AssetManifest.smcbin') { + final ByteData manifest = + const StandardMessageCodec().encodeMessage(>{'assets/logo.png': []})!; + return Future.value(manifest); + } else if (key == 'assets/logo.png') { + // The root directory tests are run from is different for 'flutter test' + // verses 'flutter test test/*_test.dart'. Adjust the root directory + // to access the assets directory. + final io.Directory rootDirectory = io.Directory.current.path.endsWith('${io.Platform.pathSeparator}test') + ? io.Directory.current.parent + : io.Directory.current; + final io.File file = io.File('${rootDirectory.path}/test/assets/images/logo.png'); + + final ByteData asset = ByteData.view(file.readAsBytesSync().buffer); + return asset; + } else { + throw ArgumentError('Unknown asset key: $key'); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9106396..50ec375 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,6 +5,8 @@ dependency_overrides: path: ./dependencies/dots_indicator ed25519_edwards: path: ./dependencies/ed25519_edwards + flutter_markdown_plus: + path: ./dependencies/flutter_markdown_plus flutter_sharing_intent: path: ./dependencies/flutter_sharing_intent hand_signature: