Localizing Rich Text Without Breaking Grammar in Flutter

Localizing Rich Text Without Breaking Grammar in Flutter

Sometimes, translated texts require inline styling. For example, a privacy policy usually looks like "By continuing, you agree to the Privacy Policy and Terms of Service". Many people use string concatenation, but while it may work for one language, it may look unnatural in another.

TL;DR: Instead of concatenating strings, tag the part that needs styling, like "Hello world". Then, build a component that parses a string and builds TextSpans from it.

Code is here:

sizzle_starter/common/common_ui/lib/src/components/text.dart at main · hawkkiller/sizzle_starter
A production-ready template for flutter applications. - hawkkiller/sizzle_starter

Table of Contents

  1. The Problem with Rich Text Localization
  2. Keep the Whole Sentence Together
  3. Implementing Semantic Markup in Flutter

The Problem with Rich Text Localization

When part of a localised text should have a different style, the first idea that comes to mind is string concatenation:

TextSpan(
  text: l10n.learn_more_in
  children: [
    TextSpan(
      text: l10n.terms_of_service,
      style: ...,
      recognizer: ...,
    ),
    TextSpan(text: l10n.and),
    TextSpan(
      text: l10n.privacy_policy,
      style: ...,
      recognizer: ...,
    ),
  ]
);

By using string concatenation, you assume that every language uses:

  • the same word order
  • the same spacing and punctuation
  • the same grammar around the linked phrases

In reality, it doesn't work that way. Languages are different and have different grammar. This works for English, though: 'Learn more in the Terms of Service and Privacy Policy'. But, for example, the Polish equivalent cannot be built safely from concatenated pieces.

In Polish, the preposition w (in) requires the locative case, so the linked phrases must change form within the sentence:

  • Incorrect: Dowiedz się więcej w Regulamin i Polityka Prywatności.
  • Correct: Dowiedz się więcej w Regulaminie i Polityce Prywatności.

This is where string concatenation fails: the standalone labels "Regulamin" and "Polityka Prywatności" are not the forms the sentence requires. However, if you decide to add them in the locative case initially, this will just break in another place where you expect the base form.

Keep the Whole Sentence Together

Instead of concatenation, keep the sentence together and add tags:

// English
"terms_and_privacy_label": "Learn more in the <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>"

// Polish
"terms_and_privacy_label": "Dowiedz się więcej w <terms>Regulaminie</terms> i <privacy>Polityce Prywatności</privacy>"

The idea is simple: wrap the text in a tag, parse it in the code, and apply the styling where needed. This way, the translation preserves context and provides semantics so the code can parse it correctly.

Implementing Semantic Markup in Flutter

Who is interested in implementation nowadays, huh? Just give all the above to the agent 😄. Anyway, in this section, I'll tell you what I created for myself. While reading it, navigate the code sources:

sizzle_starter/common/common_ui/lib/src/components/text.dart at main · hawkkiller/sizzle_starter
A production-ready template for flutter applications. - hawkkiller/sizzle_starter

Component API

You should have a component that takes the string with tags and transforms it into spans. Here's how the interface looks:

factory UiText.markup(
  String markup, {
  TextStyle? style,
  Color? color,
  int? maxLines,
  VoidCallback? onTap,
  TextAlign textAlign = TextAlign.start,
  TextOverflow? overflow = TextOverflow.ellipsis,
  Map<String, UiTextMarkupBuilder> builders = const {},
  Key? key,
});

// Usage
UiText.markup(
  'Next due in <time>10h</time>',
  style: typography.labelLarge,
  builders: {
    'time': (tag) => TextSpan(
      style: TextStyle(color: tokens.color.warning),
      children: tag.children,
    ),
  },
);

The main difference from Flutter's Text is that UiText has a builders parameter. These builders create spans for the tagged parts.

Node Structure

Under the hood, the component parses a string into a list of nodes, so for Next due in <time>10h</time> it will create:

- TextNode(Next due in) 
- TagNode(time)
    TextNode(10h)

Which, after applying builders, translates to:

TextSpan(
  style: typography.labelLarge,
  children: [
    TextSpan(text: "Next due in"),
    TextSpan(
      style: TextStyle(color: tokens.color.warning),
      children: [TextSpan(text: "10h")]
    ),
  ],
)

Note that the parent node only applies styling, while the child node provides the text. This approach supports nested styles, such as Next due in <bold><time>10h</time></bold>. The 10h text span inherits styles from two parents: "bold" and "time".

Here is the TextNode, which is a leaf node that provides text:

class _UiTextMarkupTextNode extends _UiTextMarkupNode {
  _UiTextMarkupTextNode(this.text);

  final String text;

  @override
  InlineSpan build(Map<String, UiTextMarkupBuilder> builders) => TextSpan(text: text);
}

TagNode is a container node that has child nodes:

class _UiTextMarkupTagNode extends _UiTextMarkupNode {
  _UiTextMarkupTagNode(this.name);

  _UiTextMarkupTagNode.root() : name = null;

  final String? name;
  final List<_UiTextMarkupNode> children = <_UiTextMarkupNode>[];

  List<InlineSpan> buildChildren(Map<String, UiTextMarkupBuilder> builders) {
    return children.map((child) => child.build(builders)).toList(growable: false);
  }

  @override
  InlineSpan build(Map<String, UiTextMarkupBuilder> builders) {
    final builtChildren = buildChildren(builders);
    final name = this.name;

    if (name == null) return TextSpan(children: builtChildren);

    final builder = builders[name];
    if (builder == null) return TextSpan(children: builtChildren);

    return _UiTextMarkupInteractionExpander.expand(
      builder(UiTextMarkupTag(name: name, children: builtChildren)),
    );
  }
}

TagNode recursively builds its children by calling their "build" methods. Finally, here is the parse method:

abstract final class UiTextMarkup {
  /// Parses a localized message that contains semantic tags like `<time>...</time>`.
  static TextSpan parse(
    String markup, {
    Map<String, UiTextMarkupBuilder> builders = const {},
  }) {
    final root = _UiTextMarkupTagNode.root();
    final stack = <_UiTextMarkupTagNode>[root];
    final tagPattern = RegExp(r'<(/?)([a-zA-Z][\w-]*)>');
    var cursor = 0;

    for (final match in tagPattern.allMatches(markup)) {
      if (match.start > cursor) {
        stack.last.children.add(_UiTextMarkupTextNode(markup.substring(cursor, match.start)));
      }

      final isClosingTag = match.group(1) == '/';
      final tagName = match.group(2)!;

      if (isClosingTag) {
        if (stack.length == 1 || stack.last.name != tagName) {
          return TextSpan(text: markup);
        }

        stack.removeLast();
      } else {
        final tag = _UiTextMarkupTagNode(tagName);
        stack.last.children.add(tag);
        stack.add(tag);
      }

      cursor = match.end;
    }

    if (cursor < markup.length) {
      stack.last.children.add(_UiTextMarkupTextNode(markup.substring(cursor)));
    }

    if (stack.length != 1) {
      return TextSpan(text: markup);
    }

    return TextSpan(children: root.buildChildren(builders));
  }
}

This is a simple stack-based algorithm that uses Regex to find opening and closing tags. Here's how it works for the Hello <b>world</b> string:

  1. Regex finds a match, which starts on the 6th index.
  2. The first if checks match.start > cursor, creates a _UiTextMarkupTextNode(Hello) from the substring up to index 6, and adds it to the root's children.
  3. Then, the <b> tag is added to root's children as well and is also added on top of the stack.
  4. Loop goes to the second match, a closing tag </b>. Again, the first if checks match.start > cursor and creates a _UiTextMarkupTextNode(world). Now, it gets added to <b> node's children.
  5. There are no more matches left; building text spans and returning them.

Support Gesture Recognizers

One problem with nested nodes that the parser produces is that only the span that has the recognizer becomes actually tappable, not its children. Imagine we need to make the "time" node tappable:

UiText.markup(
  'Next due in <time>10h</time>',
  style: typography.labelLarge,
  builders: {
    'time': (tag) => TextSpan(
      style: TextStyle(color: tokens.color.warning),
      recognizer: TapGestureRecognizer()..onTap=onPressed,
      children: tag.children,
    ),
  },
);

// Which, under the hood, creates these spans:
TextSpan(
  style: typography.labelLarge,
  children: [
    TextSpan(text: "next due in"),
    TextSpan(
      style: TextStyle(color: tokens.color.warning),
      recognizer: TapGestureRecognizer()..onTap=onPressed,
      children: [TextSpan(text: "10h")]
    ),
  ],
)

The issue is that only the parent node is tappable, but it can't actually be pressed because it doesn't have any text. So, I added an expander, which provides the recognizer to child nodes if the parent has one:

abstract final class _UiTextMarkupInteractionExpander {
  static InlineSpan expand(InlineSpan span) {
    return _expand(span, inherited: null);
  }

  static InlineSpan _expand(
    InlineSpan span, {
    required _UiTextMarkupInteraction? inherited,
  }) {
    if (span is! TextSpan) {
      return span;
    }

    final interaction = _UiTextMarkupInteraction.resolve(
      span,
      inherited: inherited,
    );

    return TextSpan(
      text: span.text,
      style: span.style,
      recognizer: interaction.recognizer,
      mouseCursor: interaction.explicitMouseCursor,
      onEnter: interaction.onEnter,
      onExit: interaction.onExit,
      semanticsLabel: span.semanticsLabel,
      semanticsIdentifier: span.semanticsIdentifier,
      locale: span.locale,
      spellOut: span.spellOut,
      children: span.children
          ?.map((child) => _expand(child, inherited: interaction))
          .toList(growable: false),
    );
  }
}

Usage

In practice, this is how I use UiText.markup with localizations:

// ARB file
"home_hero_next_due_in": "Next due in <time>{time}</time>"

// Flutter
UiText.markup(
  l10n.home_hero_next_due_in(_formatDuration(nextCardDueIn ?? Duration.zero)),
  type: UiTypographySize.labelLarge,
  color: tokens.color.onSurfaceMuted,
  builders: {
    'time': (tag) => TextSpan(
      style: TextStyle(color: tokens.color.warning),
      children: tag.children,
    ),
  },
);

Depending on your use case, you can also create some default builders, e.g. for bold <b>, italic <i> or underline <u> that are applied automatically when using UiText.markup constructor.

Check the full code and give a star to sizzle starter:

sizzle_starter/common/common_ui/lib/src/components/text.dart at main · hawkkiller/sizzle_starter
A production-ready template for flutter applications. - hawkkiller/sizzle_starter