Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support custom attributes syntax to allow for multiple styles in the text rendering pipeline #3519

Merged
merged 1 commit into from
Mar 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/flame/lib/src/text/nodes/custom_text_node.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:flame/src/text/nodes/inline_text_node.dart';
import 'package:flame/text.dart';

/// An [InlineTextNode] representing a span of text with a custom style applied.
class CustomInlineTextNode extends InlineTextNode {
final String styleName;

CustomInlineTextNode(this.child, {required this.styleName});

CustomInlineTextNode.simple(String text, {required this.styleName})
: child = PlainTextNode(text);

final InlineTextNode child;

@override
void fillStyles(DocumentStyle stylesheet, InlineTextStyle parentTextStyle) {
style = FlameTextStyle.merge(
parentTextStyle,
stylesheet.getCustomStyle(styleName),
) ??
stylesheet.text;
child.fillStyles(stylesheet, style);
}

@override
TextNodeLayoutBuilder get layoutBuilder => child.layoutBuilder;
}
1 change: 1 addition & 0 deletions packages/flame/lib/src/text/nodes/inline_text_node.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:flame/text.dart';
/// * ItalicTextNode - italic string
/// * CodeTextNode - inline code string
/// * StrikethroughTextNode - strikethrough string
/// * CustomTextNode - applies arbitrary attributes to a span of text
/// * GroupTextNode - collection of multiple [InlineTextNode]'s to be joined one
/// after the other.
abstract class InlineTextNode extends TextNode<InlineTextStyle> {
Expand Down
7 changes: 7 additions & 0 deletions packages/flame/lib/src/text/styles/document_style.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class DocumentStyle extends FlameTextStyle {
InlineTextStyle? italicText,
InlineTextStyle? codeText,
InlineTextStyle? strikethroughText,
Map<String, InlineTextStyle>? customStyles,
BlockStyle? paragraph,
BlockStyle? header1,
BlockStyle? header2,
Expand All @@ -38,6 +39,7 @@ class DocumentStyle extends FlameTextStyle {
StrikethroughTextNode.defaultStyle,
strikethroughText,
),
_customStyles = customStyles,
_paragraph =
FlameTextStyle.merge(ParagraphNode.defaultStyle, paragraph),
_header1 = FlameTextStyle.merge(HeaderNode.defaultStyleH1, header1),
Expand All @@ -52,6 +54,7 @@ class DocumentStyle extends FlameTextStyle {
final InlineTextStyle? _italicText;
final InlineTextStyle? _codeText;
final InlineTextStyle? _strikethroughText;
final Map<String, InlineTextStyle>? _customStyles;
final BlockStyle? _paragraph;
final BlockStyle? _header1;
final BlockStyle? _header2;
Expand Down Expand Up @@ -106,6 +109,10 @@ class DocumentStyle extends FlameTextStyle {
InlineTextStyle get codeText => _codeText!;
InlineTextStyle get strikethroughText => _strikethroughText!;

InlineTextStyle? getCustomStyle(String className) {
return _customStyles?[className];
}

/// Style for [ParagraphNode]s.
BlockStyle get paragraph => _paragraph!;

Expand Down
1 change: 1 addition & 0 deletions packages/flame/lib/text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export 'src/text/nodes/block_node.dart' show BlockNode;
export 'src/text/nodes/bold_text_node.dart' show BoldTextNode;
export 'src/text/nodes/code_text_node.dart' show CodeTextNode;
export 'src/text/nodes/column_node.dart' show ColumnNode;
export 'src/text/nodes/custom_text_node.dart' show CustomInlineTextNode;
export 'src/text/nodes/document_root.dart' show DocumentRoot;
export 'src/text/nodes/group_text_node.dart' show GroupTextNode;
export 'src/text/nodes/header_node.dart' show HeaderNode;
Expand Down
2 changes: 2 additions & 0 deletions packages/flame_markdown/example/assets/fire_and_ice.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ Some say in *ice*.
From what I've tasted of >desire<,

I hold with those who favor **fire**.

[- by Robert Frost]{.author}
9 changes: 9 additions & 0 deletions packages/flame_markdown/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flame/components.dart';
import 'package:flame/flame.dart';
import 'package:flame/game.dart';
import 'package:flame/text.dart';
import 'package:flame_markdown/custom_attribute_syntax.dart';
import 'package:flame_markdown/flame_markdown.dart';
import 'package:flutter/widgets.dart' hide Animation;
import 'package:markdown/markdown.dart';
Expand All @@ -26,11 +27,19 @@ class MarkdownGame extends FlameGame {
encodeHtml: false,
inlineSyntaxes: [
StrikethroughSyntax(),
CustomAttributeSyntax(),
],
),
),
style: DocumentStyle(
padding: const EdgeInsets.all(16),
customStyles: {
'author': InlineTextStyle(
color: const Color(0xFF888888),
fontSize: 16,
fontStyle: FontStyle.italic,
),
},
),
size: size,
),
Expand Down
2 changes: 1 addition & 1 deletion packages/flame_markdown/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ dependencies:
flame_markdown: ^0.2.3+2
flutter:
sdk: flutter
markdown: ^7.1.1
markdown: ^7.3.0

dev_dependencies:
flame_lint: ^1.2.2
Expand Down
111 changes: 111 additions & 0 deletions packages/flame_markdown/lib/custom_attribute_syntax.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'package:markdown/markdown.dart';

// cSpell:ignore charcode.dart (file name from another package)
// NOTE: values obtained from file `charcode.dart` from the markdown package
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sadly we cannot access the file as it is not exported. tbh I do prefer my naming schema though :P

class _Chars {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this even need a class?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No but I thought it was more organized to group these together than let them laying around

/// Character `[`.
static const int leftBracket = 0x5B;

/// Character `{`.
static const int leftBrace = 0x7B;

/// Character `}`.
static const int rightBrace = 0x7D;
}

/// Allows for a toned-down version of custom attributes extension for markdown,
/// inspired by the markdown-it-attrs package.
///
/// This allows users to specify a custom class name to a span of text:
///
/// ```markdown
/// [This is a custom class]{.my-custom-class}
/// This word will be [red]{.red} and this one will be [blue]{.blue}.
/// ```
///
/// This is based on the standard Link markdown parser (which matches the
/// `[text](url)` and `[text][ref]` syntaxes).
class CustomAttributeSyntax extends LinkSyntax {
/// Creates a new custom attribute syntax.
CustomAttributeSyntax()
: super(
pattern: r'\[',
startCharacter: _Chars.leftBracket,
);

@override
Iterable<Node>? close(
InlineParser parser,
covariant SimpleDelimiter opener,
Delimiter? closer, {
required List<Node> Function() getChildren,
String? tag,
}) {
final text = parser.source.substring(opener.endPos, parser.pos);

// The current character is the `]` that closed the span text.
// The next character must be a `{`:
parser.advanceBy(1);
if (parser.isDone) {
return null; // not valid syntax - skip
}
final char = parser.charAt(parser.pos);
if (char != _Chars.leftBrace) {
return null; // not valid syntax - skip
}

final attributes = _parseAttributes(parser) ?? {};
final node = _createNode(text, attributes, getChildren: getChildren);
return [node];
}

/// Create this node represented by a span with custom attributes.
Node _createNode(
String text,
Map<String, String> attributes, {
required List<Node> Function() getChildren,
}) {
final children = getChildren();
final element = Element('span', children);
for (final attr in attributes.entries) {
element.attributes[attr.key] = attr.value;
}
return element;
}

/// At this point, we have parsed a custom tag opening `[`, and then a
/// matching closing `]`, and now [parser] is pointing at an opening `{`.
Map<String, String>? _parseAttributes(InlineParser parser) {
// Start walking to the character just after the opening `{`.
parser.advanceBy(1);

final buffer = StringBuffer();

while (true) {
final char = parser.charAt(parser.pos);
if (char == _Chars.rightBrace) {
final attributes = buffer.toString();
return _parseAttributeList(attributes);
}

buffer.writeCharCode(char);
parser.advanceBy(1);
if (parser.isDone) {
return null; // not valid syntax - skip
}
}
}

Map<String, String>? _parseAttributeList(String attributes) {
// Currently we only support one attribute being the class name.
// More support can be added in the future following the syntax from
// the markdown-it library.
final regex = RegExp(r'\.([a-zA-Z0-9_-]+)'); // matches `.class-name`
final content = attributes.trim();
final className = regex.firstMatch(content)?.group(1);
if (className == null) {
return null; // not valid syntax - skip
}
return {'class': className};
}
}
13 changes: 13 additions & 0 deletions packages/flame_markdown/lib/flame_markdown.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,20 @@ class FlameMarkdown {
.map(_castCheck<InlineTextNode>)
.toList();
final child = _groupInlineChildren(children);

final customClassName = element.attributes['class'];
if (customClassName != null) {
if (element.tag != 'span') {
throw Exception(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could consider support for other inline elements, for example **foo**{.super-bold}, which is part of markdown-it-attrs.
but I think it is unnecessary because you can just make your custom style bold or whatever you want.

'Invalid markdown structure: '
'Only <span> elements can have custom classes',
);
}
return CustomInlineTextNode(child, styleName: customClassName);
}

return switch (element.tag) {
'span' => child,
'h1' => HeaderNode(child, level: 1),
'h2' => HeaderNode(child, level: 2),
'h3' => HeaderNode(child, level: 3),
Expand Down
104 changes: 104 additions & 0 deletions packages/flame_markdown/test/flame_markdown_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:io';
import 'dart:ui';

import 'package:flame/text.dart';
import 'package:flame_markdown/custom_attribute_syntax.dart';
import 'package:flame_markdown/flame_markdown.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:markdown/markdown.dart';
Expand Down Expand Up @@ -299,6 +300,10 @@ void main() {
(node) => _expectPlain(node, '.'),
]);
}),
// note: custom attribute is only parsed if enabled
(node) => _expectParagraph(node, (p) {
_expectPlain(p, '[- by Robert Frost]{.author}');
}),
]);
});

Expand All @@ -324,6 +329,93 @@ void main() {
}),
]);
});

test('custom attributes can be enabled', () {
const markdown =
'This one will be [red]{.red} and this one will be [blue]{.blue}.';
final doc = FlameMarkdown.toDocument(
markdown,
document: Document(
encodeHtml: false,
inlineSyntaxes: [
CustomAttributeSyntax(),
],
),
);

_expectDocument(doc, [
(node) => _expectParagraph(node, (p) {
_expectGroup(p, [
(node) => _expectPlain(node, 'This one will be '),
(node) => _expectCustom(node, 'red', styleName: 'red'),
(node) => _expectPlain(node, ' and this one will be '),
(node) => _expectCustom(node, 'blue', styleName: 'blue'),
(node) => _expectPlain(node, '.'),
]);
}),
]);

final element = doc.format(
DocumentStyle(
width: 1000,
text: InlineTextStyle(
fontSize: 12,
),
customStyles: {
'red': InlineTextStyle(
color: const Color(0xFFFF0000),
),
'blue': InlineTextStyle(
color: const Color(0xFF0000FF),
),
},
),
);

_expectElementGroup(element, [
(el) => _expectElementGroup(el, [
(el) => _expectElementGroupText(el, [
(el) => _expectElementTextPainter(
el,
'This one will be ',
const TextStyle(
fontSize: 12,
),
),
(el) => _expectElementTextPainter(
el,
'red',
const TextStyle(
fontSize: 12,
color: Color(0xFFFF0000),
),
),
(el) => _expectElementTextPainter(
el,
' and this one will be ',
const TextStyle(
fontSize: 12,
),
),
(el) => _expectElementTextPainter(
el,
'blue',
const TextStyle(
fontSize: 12,
color: Color(0xFF0000FF),
),
),
(el) => _expectElementTextPainter(
el,
'.',
const TextStyle(
fontSize: 12,
),
),
]),
]),
]);
});
});
}

Expand Down Expand Up @@ -374,6 +466,18 @@ void _expectPlain(InlineTextNode node, String text) {
expect(span.text, text);
}

void _expectCustom(
InlineTextNode node,
String text, {
required String styleName,
}) {
expect(node, isA<CustomInlineTextNode>());
final custom = node as CustomInlineTextNode;
expect(custom.child, isA<PlainTextNode>());
expect((custom.child as PlainTextNode).text, text);
expect(custom.styleName, styleName);
}

void _expectCode(InlineTextNode node, String text) {
expect(node, isA<CodeTextNode>());
final content = (node as CodeTextNode).child;
Expand Down