Fushinn

fix: support balanced parentheses in image and link URLs

Fixed a parsing issue where URLs containing parentheses would be incorrectly truncated, breaking image and link rendering.
Implements a balanced parentheses parser compliant with CommonMark
Supports nested parentheses like (value(nested))
Maintains backward compatibility
Efficient O(n) algorithm with clean logic
Verified with unit, integration, regression, and edge case tests
@@ -779,10 +779,12 @@ class SourceTag extends InlineMd { @@ -779,10 +779,12 @@ class SourceTag extends InlineMd {
779 } 779 }
780 } 780 }
781 781
  782 +
  783 +
782 /// Link text component 784 /// Link text component
783 class ATagMd extends InlineMd { 785 class ATagMd extends InlineMd {
784 @override 786 @override
785 - RegExp get exp => RegExp(r"\[([^\s\*\[][^\n]*?[^\s]?)?\]\(([^\s\*]*[^\)])\)"); 787 + RegExp get exp => RegExp(r"\[[^\[\]]*\]\([^\s]*\)");
786 788
787 @override 789 @override
788 InlineSpan span( 790 InlineSpan span(
@@ -790,13 +792,41 @@ class ATagMd extends InlineMd { @@ -790,13 +792,41 @@ class ATagMd extends InlineMd {
790 String text, 792 String text,
791 final GptMarkdownConfig config, 793 final GptMarkdownConfig config,
792 ) { 794 ) {
793 - var match = exp.firstMatch(text.trim());  
794 - if (match?[1] == null && match?[2] == null) { 795 + // First try to find the basic pattern
  796 + final basicMatch = RegExp(r'\[([^\[\]]*)\]\(').firstMatch(text.trim());
  797 + if (basicMatch == null) {
795 return const TextSpan(); 798 return const TextSpan();
796 } 799 }
797 800
798 - final linkText = match?[1] ?? "";  
799 - final url = match?[2] ?? ""; 801 + final linkText = basicMatch.group(1) ?? '';
  802 + final urlStart = basicMatch.end;
  803 +
  804 + // Now find the balanced closing parenthesis
  805 + int parenCount = 0;
  806 + int urlEnd = urlStart;
  807 +
  808 + for (int i = urlStart; i < text.length; i++) {
  809 + final char = text[i];
  810 +
  811 + if (char == '(') {
  812 + parenCount++;
  813 + } else if (char == ')') {
  814 + if (parenCount == 0) {
  815 + // This is the closing parenthesis of the link
  816 + urlEnd = i;
  817 + break;
  818 + } else {
  819 + parenCount--;
  820 + }
  821 + }
  822 + }
  823 +
  824 + if (urlEnd == urlStart) {
  825 + // No closing parenthesis found
  826 + return const TextSpan();
  827 + }
  828 +
  829 + final url = text.substring(urlStart, urlEnd).trim();
800 830
801 var builder = config.linkBuilder; 831 var builder = config.linkBuilder;
802 832
@@ -834,7 +864,7 @@ class ATagMd extends InlineMd { @@ -834,7 +864,7 @@ class ATagMd extends InlineMd {
834 /// Image component 864 /// Image component
835 class ImageMd extends InlineMd { 865 class ImageMd extends InlineMd {
836 @override 866 @override
837 - RegExp get exp => RegExp(r"\!\[([^\s][^\n]*[^\s]?)?\]\(([^\s]+?)\)"); 867 + RegExp get exp => RegExp(r"\!\[[^\[\]]*\]\([^\s]*\)");
838 868
839 @override 869 @override
840 InlineSpan span( 870 InlineSpan span(
@@ -842,25 +872,61 @@ class ImageMd extends InlineMd { @@ -842,25 +872,61 @@ class ImageMd extends InlineMd {
842 String text, 872 String text,
843 final GptMarkdownConfig config, 873 final GptMarkdownConfig config,
844 ) { 874 ) {
845 - var match = exp.firstMatch(text.trim()); 875 + // First try to find the basic pattern
  876 + final basicMatch = RegExp(r'\!\[([^\[\]]*)\]\(').firstMatch(text.trim());
  877 + if (basicMatch == null) {
  878 + return const TextSpan();
  879 + }
  880 +
  881 + final altText = basicMatch.group(1) ?? '';
  882 + final urlStart = basicMatch.end;
  883 +
  884 + // Now find the balanced closing parenthesis
  885 + int parenCount = 0;
  886 + int urlEnd = urlStart;
  887 +
  888 + for (int i = urlStart; i < text.length; i++) {
  889 + final char = text[i];
  890 +
  891 + if (char == '(') {
  892 + parenCount++;
  893 + } else if (char == ')') {
  894 + if (parenCount == 0) {
  895 + // This is the closing parenthesis of the image
  896 + urlEnd = i;
  897 + break;
  898 + } else {
  899 + parenCount--;
  900 + }
  901 + }
  902 + }
  903 +
  904 + if (urlEnd == urlStart) {
  905 + // No closing parenthesis found
  906 + return const TextSpan();
  907 + }
  908 +
  909 + final url = text.substring(urlStart, urlEnd).trim();
  910 +
846 double? height; 911 double? height;
847 double? width; 912 double? width;
848 - if (match?[1] != null) { 913 + if (altText.isNotEmpty) {
849 var size = RegExp( 914 var size = RegExp(
850 r"^([0-9]+)?x?([0-9]+)?", 915 r"^([0-9]+)?x?([0-9]+)?",
851 - ).firstMatch(match![1].toString().trim()); 916 + ).firstMatch(altText.trim());
852 width = double.tryParse(size?[1]?.toString().trim() ?? 'a'); 917 width = double.tryParse(size?[1]?.toString().trim() ?? 'a');
853 height = double.tryParse(size?[2]?.toString().trim() ?? 'a'); 918 height = double.tryParse(size?[2]?.toString().trim() ?? 'a');
854 } 919 }
  920 +
855 final Widget image; 921 final Widget image;
856 if (config.imageBuilder != null) { 922 if (config.imageBuilder != null) {
857 - image = config.imageBuilder!(context, '${match?[2]}'); 923 + image = config.imageBuilder!(context, url);
858 } else { 924 } else {
859 image = SizedBox( 925 image = SizedBox(
860 width: width, 926 width: width,
861 height: height, 927 height: height,
862 child: Image( 928 child: Image(
863 - image: NetworkImage("${match?[2]}"), 929 + image: NetworkImage(url),
864 loadingBuilder: ( 930 loadingBuilder: (
865 BuildContext context, 931 BuildContext context,
866 Widget child, 932 Widget child,