generateSpansFromText method

void generateSpansFromText(
  1. ChantContext ctxt,
  2. String text
)

Parses markup in text to fill spans with styled TextSpans.

Implementation

void generateSpansFromText(ChantContext ctxt, String text) {
  text = text.replaceAll(RegExp(r'\s+'), ' ');
  this.text = '';
  spans = [];

  if (text == '*' || text == '+' || text == '†') {
    final List<Map<String, dynamic>> properties = switch (text) {
      '*' => [ctxt.asteriskProperties],
      '+' => [ctxt.plusProperties],
      _ => [],
    };

    final mapped = ctxt.specialCharText(text);
    spans.add(TextSpan(mapped, properties, []));
    return;
  }

  final markupStack = <MarkupStackFrame>[];
  var spanStartIndex = 0;
  var newLineInNextSpan = 0;

  bool filterFrames(MarkupStackFrame frame, String? symbol) =>
      frame.symbol == symbol;

  void closeSpan(
    String spanText,
    int index, [
    Map<String, dynamic>? extraProperties,
  ]) {
    if (spanText.isEmpty && dropCap == null) return;

    this.text += spanText;
    final properties = <Map<String, dynamic>>[];
    for (final frame in markupStack) {
      properties.addAll(frame.propertyArray);
    }
    if (extraProperties != null && extraProperties.isNotEmpty) {
      properties.add(extraProperties);
    }

    final span = TextSpan(
      spanText,
      properties,
      markupStack.map((frame) => frame.tagName).toList(),
      index,
    );
    spans.add(span);
    if (newLineInNextSpan > 0) {
      span.newLine = newLineInNextSpan;
      newLineInNextSpan = 0;
    }
  }

  final markupRegex = RegExp(
    r'(<br/?>)|<v>([\s\S]*?)(?:<\/v>|$)|(\*)(?=\s*\*|[^*]*(?:$|<v>))|(\+)|<sp>(?:(~)|('
    "'"
    r')?([ao]e|[æœaeiouy])|([arv])\/)<\/sp>|([arv])\/\.|([℣℟])\.?|(?:([*_^%])|<(\/)?([bceiuv]|ul|sc|font)(?:\s+(?:family="([^"]+)"|fill="([^"]+)"|class="([^"]+)"))*>)(?=(?:(.+?)(?:\11|<\/\13>))?)',
    caseSensitive: false,
  );
  final vTagRegex = RegExp(
    r"(\\grecross)|\{greextra\}\{([^}]*)\}|\{?(\\?')?(?:\\([ao]e|æœaeiouy))\}?",
    caseSensitive: false,
  );

  var openedAsterisk = false;

  void closeCurrentSpan(RegExpMatch match) {
    closeSpan(text.substring(spanStartIndex, match.start), spanStartIndex);
  }

  for (final match in markupRegex.allMatches(text)) {
    final newLine = match.group(1);
    final vTag = match.group(2);
    final asterisk = match.group(3);
    final plus = match.group(4);
    final tilde = match.group(5);
    final accent = match.group(6);
    final vowelLigature = match.group(7);
    final specialCharSp = match.group(8);
    final specialCharSlash = match.group(9);
    final specialCharVbar = match.group(10);
    final markupSymbol = match.group(11);
    final closingTag = match.group(12);
    var tagName = match.group(13);
    final family = match.group(14);
    final fill = match.group(15);
    final cssClass = match.group(16);
    final enclosedText = match.group(17);

    final specialChar = specialCharSp ?? specialCharSlash ?? specialCharVbar;

    if (newLine != null) {
      if (match.start > spanStartIndex) {
        closeCurrentSpan(match);
      }
      newLineInNextSpan++;
    } else if (vTag != null) {
      closeCurrentSpan(match);
      var lastIndex = 0;
      var iOffset = 0;
      for (final vMatch in vTagRegex.allMatches(vTag)) {
        if (lastIndex < vMatch.start) {
          closeSpan(
            vTag.substring(lastIndex, vMatch.start),
            match.start + lastIndex + iOffset,
          );
          iOffset = 3; // length of '<v>'
        }
        final grecross = vMatch.group(1);
        var greextra = vMatch.group(2);
        final vAccent = vMatch.group(3);
        final diphthong = vMatch.group(4);
        var char = '';
        if (diphthong != null) {
          char = makeLigature(diphthong);
          if (vAccent != null) char = addAccent(char);
          closeSpan(char, match.start + vMatch.start + iOffset);
        } else {
          if (grecross != null) {
            // grecross is just the command for the Cross:
            // set up greextra so it will get handled with it below:
            greextra = 'Cross';
          }
          char = glyphs.greextraGlyphs[greextra] ?? '';
          if (char.isNotEmpty) {
            closeSpan(char, match.start + vMatch.start + iOffset, {
              'font-family': 'greextra',
            });
          }
        }
        lastIndex = vMatch.end;
        iOffset = 3; // length of '<v>'
      }
      if (lastIndex < vTag.length) {
        closeSpan(
          vTag.substring(lastIndex),
          match.start + lastIndex + iOffset,
        );
      }
    } else if (asterisk != null) {
      closeCurrentSpan(match);
      // first check if it is just a symbol to close:
      if (markupStack.isNotEmpty && markupStack.last.symbol == asterisk) {
        markupStack.removeLast();
      } else {
        closeSpan(
          ctxt.specialCharText(asterisk),
          match.start,
          ctxt.asteriskProperties,
        );
      }
    } else if (plus != null) {
      closeCurrentSpan(match);
      closeSpan(ctxt.specialCharText(plus), match.start, ctxt.plusProperties);
    } else if (tilde != null) {
      closeCurrentSpan(match);
      closeSpan('∼', match.start);
    } else if (vowelLigature != null) {
      var vowel = makeLigature(vowelLigature);
      if (accent != null) vowel = addAccent(vowel);
      closeCurrentSpan(match);
      closeSpan(vowel, match.start);
    } else if (specialChar != null) {
      closeCurrentSpan(match);
      closeSpan(
        ctxt.textBeforeSpecialChar +
            ctxt.specialCharText(specialChar) +
            ctxt.textAfterSpecialChar,
        match.start,
        ctxt.specialCharProperties,
      );
    } else {
      // otherwise we're dealing with matching markup delimeters
      if (markupSymbol == '*') {
        // we are only strict with the asterisk, because there are cases
        // when it needs to be displayed rather than count as a markup
        // symbol
        if (enclosedText != null &&
            RegExp(r'[^\s*]').hasMatch(enclosedText)) {
          openedAsterisk = true;
        } else if (openedAsterisk) {
          openedAsterisk = false;
        } else {
          // actually use the asterisk, since it doesn't have a matching
          // closing asterisk
          continue;
        }
      }
      if (markupSymbol != null) {
        tagName = ctxt.markupSymbolDictionary[markupSymbol];
        if (markupStack.isNotEmpty &&
            markupStack.last.tagName == tagName &&
            markupStack.last.symbol == markupSymbol) {
          if (closingTag != null) {
            closeCurrentSpan(match);
            markupStack.removeLast();
          }
          // otherwise: matching symbol on top of stack but not a closing
          // tag — fall through to group open below
          else {
            closeCurrentSpan(match);
            final extraProperties = <String, dynamic>{};
            if (family != null) extraProperties['font-family'] = family;
            if (fill != null) extraProperties['fill'] = fill;
            if (cssClass != null) extraProperties['class'] = cssClass;
            markupStack.add(
              MarkupStackFrame.createStackFrame(
                ctxt,
                tagName!,
                match.start,
                extraProperties,
                markupSymbol,
              ),
            );
          }
        } else if (markupStack.isNotEmpty &&
            markupStack.last.tagName == tagName) {
          if (closingTag != null) {
            closeCurrentSpan(match);
            markupStack.removeLast();
          }
        } else if (markupStack
            .where((f) => filterFrames(f, markupSymbol))
            .isNotEmpty) {
          // trying to open a recursive group (or forgot to close a previous
          // group). In either case, we just unwind to the previous stack
          // frame
          spanStartIndex = markupStack.last.startIndex;
          markupStack.removeLast();
          continue;
        } else {
          closeCurrentSpan(match);
          if (closingTag != null) {
            // out of order group close:
            final index = markupStack.indexWhere(
              (frame) => frame.tagName == tagName,
            );
            if (index >= 0) {
              markupStack.removeRange(index, index + 1);
            }
          } else {
            // group open
            final extraProperties = <String, dynamic>{};
            if (family != null) extraProperties['font-family'] = family;
            if (fill != null) extraProperties['fill'] = fill;
            if (cssClass != null) extraProperties['class'] = cssClass;
            markupStack.add(
              MarkupStackFrame.createStackFrame(
                ctxt,
                tagName!,
                match.start,
                extraProperties,
                markupSymbol,
              ),
            );
          }
        }
      } else if (tagName != null) {
        if (markupStack.isNotEmpty && markupStack.last.tagName == tagName) {
          if (closingTag != null) {
            closeCurrentSpan(match);
            markupStack.removeLast();
          }
        } else if (markupStack
            .where((f) => filterFrames(f, null))
            .isNotEmpty) {
          spanStartIndex = markupStack.last.startIndex;
          markupStack.removeLast();
          continue;
        } else {
          closeCurrentSpan(match);
          if (closingTag != null) {
            final index = markupStack.indexWhere(
              (frame) => frame.tagName == tagName,
            );
            if (index >= 0) {
              markupStack.removeRange(index, index + 1);
            }
          } else {
            final extraProperties = <String, dynamic>{};
            if (family != null) extraProperties['font-family'] = family;
            if (fill != null) extraProperties['fill'] = fill;
            if (cssClass != null) extraProperties['class'] = cssClass;
            markupStack.add(
              MarkupStackFrame.createStackFrame(
                ctxt,
                tagName,
                match.start,
                extraProperties,
                '',
              ),
            );
          }
        }
      }
    }

    // advance the start index past the current markup
    spanStartIndex = match.end;
  }

  // if we finished matches, and there is still some text left,
  // or if we haven't generated any spans yet, create one final run
  if (spanStartIndex < text.length || spans.isEmpty) {
    closeSpan(text.substring(spanStartIndex), spanStartIndex);
  }
}