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
Showing
1 changed file
with
77 additions
and
11 deletions
| @@ -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) { | ||
| 798 | + return const TextSpan(); | ||
| 799 | + } | ||
| 800 | + | ||
| 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 | ||
| 795 | return const TextSpan(); | 826 | return const TextSpan(); |
| 796 | } | 827 | } |
| 797 | 828 | ||
| 798 | - final linkText = match?[1] ?? ""; | ||
| 799 | - final url = match?[2] ?? ""; | 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, |
-
Please register or login to post a comment