顾海波

Merge remote-tracking branch 'github/main'

# Conflicts:
#	example/pubspec.lock
#	lib/gpt_markdown.dart
#	lib/markdown_component.dart
#	pubspec.yaml
{
"cSpell.words": [
"cupertino"
]
}
\ No newline at end of file
... ...
## 1.1.4
* 🔗 Fixed vertical alignment issue with link text rendering ([#92](https://github.com/Infinitix-LLC/gpt_markdown/issues/92))
* 📝 Resolved "null" rendering issue in ordered lists with multiple spaces and line breaks ([#89](https://github.com/Infinitix-LLC/gpt_markdown/issues/89))
* 🧹 Removed erroneous `trim()` from `CodeBlockMd` to preserve necessary whitespace in code blocks ([#99](https://github.com/Infinitix-LLC/gpt_markdown/issues/99))
* 🎨 Fixed heading style customization issue where custom colors in heading styles were not being applied ([#95](https://github.com/Infinitix-LLC/gpt_markdown/issues/95))
## 1.1.3
* Added `RadioGroup` widget for managing radio buttons.
* Updated to align with Flutter 3.35 by resolving the deprecations of `Radio.groupValue` and `Radio.onChanged`.
## 1.1.2
* 📊 Fixed table column alignment support ([#65](https://github.com/Infinitix-LLC/gpt_markdown/issues/65))
* 🎨 Added `tableBuilder` parameter to customize table rendering
* 🔗 Fixed text decoration color of link markdown component
## 1.1.1
* 🖼️ Fixed issue where images wrapped in links (e.g. `[![](img)](url)`) were not rendering properly (#72)
* 🔗 Resolved parsing errors for consecutive inline links without spacing (e.g. `[a](url)[b](url)`) (#34)
## 1.1.0
* Changed `onLinkTab` to `onLinkTap` fixed issues of newLine issues.
## 1.0.20
* Fix: support balanced parentheses in image and link URLs. [#68](https://github.com/Infinitix-LLC/gpt_markdown/pull/68)
## 1.0.19
* Performance improvements.
## 1.0.18
* dollarSignForLatex is added and by default it is false.
## 1.0.17
* Bloc components rendering inside table.
## 1.0.16
* `IndentMd` and `BlockQuote` fixed.
... ...
... ... @@ -4,6 +4,8 @@
A comprehensive Flutter package for rendering rich Markdown and LaTeX content in your apps, designed for seamless integration with AI outputs like ChatGPT and Gemini.
gpt_markdown is a drop-in replacement for flutter_markdown, offering extended support for LaTeX, custom builders, and better AI integration for Flutter apps.
⭐ If you find this package helpful, please give it a like on [pub.dev](https://pub.dev/packages/gpt_markdown)! Your support means a lot! ⭐
---
... ... @@ -30,7 +32,7 @@ A comprehensive Flutter package for rendering rich Markdown and LaTeX content in
| 🔗 Links | ✅ |
| 📱 Selectable | ✅ |
| 🧩 Custom components | ✅ | |
| 📎 Underline | | 🔜 |
| 📎 Underline | ✅ | |
## ✨ Key Features
... ... @@ -84,6 +86,11 @@ Render a wide variety of content with full Markdown and LaTeX support, including
*Italic text*
```
- <u>Underline text</u>
```
<u>Underline text</u>
```
- heading texts
```
... ... @@ -138,7 +145,8 @@ return GptMarkdown(
* This is a unordered list.
''',
style: const TextStyle(
color: Colors.red,
color: Colors.red,
),
),
```
... ... @@ -186,6 +194,7 @@ Markdown and LaTeX can be powerful tools for formatting text and mathematical ex
<img width="614" alt="Screenshot 2024-02-15 at 4 13 59 AM" src="https://github.com/saminsohag/flutter_packages/assets/59507062/8f4a4068-a12c-45d1-a954-ebaf3822e754">
If you're using flutter_markdown and need more customization or LaTeX support, gpt_markdown is a great alternative.
## 🔗 Additional Information
... ...
... ... @@ -123,5 +123,28 @@ darkTheme: ThemeData(
),
```
Use `tableBuilder` to customize table rendering:
```dart
GptMarkdown(
markdownText,
tableBuilder: (context, tableRows, textStyle, config) {
return Table(
border: TableBorder.all(
width: 1,
color: Colors.red,
),
children: tableRows.map((e) {
return TableRow(
children: e.fields.map((e) {
return Text(e.data);
}).toList(),
);
}).toList(),
);
},
);
```
Please see the [README.md](https://github.com/Infinitix-LLC/gpt_markdown) and also [example](https://github.com/Infinitix-LLC/gpt_markdown/tree/main/example/lib/main.dart) app for more details.
... ...
... ... @@ -75,7 +75,15 @@ class MyHomePage extends StatefulWidget {
class _MyHomePageState extends State<MyHomePage> {
TextDirection _direction = TextDirection.ltr;
final TextEditingController _controller = TextEditingController(
text: r'''
text: r'''This is a sample markdown document.
* **bold**
* *italic*
* **_bold and italic_**
* ~~strikethrough~~
* `code`
* [link](https://www.google.com)
[![alt text](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)](link_url)
```markdown
# Complex Markdown Document for Testing
... ... @@ -317,9 +325,16 @@ This document was created to test the robustness of Markdown parsers and to ensu
bool writingMod = true;
bool selectable = false;
bool useDollarSignsForLatex = false;
@override
Widget build(BuildContext context) {
// var data = '''|asdfasfd|asdfasf|
// |---|---|
// |sohag|asdfasf|
// |asdfasf|asdfasf|
// ''';
return GptMarkdownTheme(
gptThemeData: GptMarkdownTheme.of(context).copyWith(
highlightColor: Colors.purple,
... ... @@ -331,6 +346,22 @@ This document was created to test the robustness of Markdown parsers and to ensu
IconButton(
onPressed: () {
setState(() {
useDollarSignsForLatex = !useDollarSignsForLatex;
});
},
icon: Icon(
Icons.monetization_on_outlined,
color: useDollarSignsForLatex
? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.38),
),
),
IconButton(
onPressed: () {
setState(() {
selectable = !selectable;
});
},
... ... @@ -383,9 +414,10 @@ This document was created to test the robustness of Markdown parsers and to ensu
Expanded(
child: ListView(
children: [
AnimatedBuilder(
animation: _controller,
ListenableBuilder(
listenable: _controller,
builder: (context, _) {
var data = _controller.text;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
... ... @@ -415,17 +447,20 @@ This document was created to test the robustness of Markdown parsers and to ensu
child: Builder(
builder: (context) {
Widget child = GptMarkdown(
_controller.text,
data,
textDirection: _direction,
onLinkTab: (url, title) {
onLinkTap: (url, title) {
debugPrint(url);
debugPrint(title);
},
useDollarSignsForLatex:
useDollarSignsForLatex,
textAlign: TextAlign.justify,
textScaler: const TextScaler.linear(1),
style: const TextStyle(
fontSize: 15,
),
// fontFamily: 'monospace',
// fontWeight: FontWeight.bold,
),
highlightBuilder: (context, text, style) {
return Container(
padding: const EdgeInsets.symmetric(
... ... @@ -487,13 +522,13 @@ This document was created to test the robustness of Markdown parsers and to ensu
RegExp(r"align\*"),
(match) => "aligned");
},
imageBuilder: (context, url) {
return Image.network(
url,
width: 100,
height: 100,
);
},
// imageBuilder: (context, url) {
// return Image.network(
// url,
// width: 100,
// height: 100,
// );
// },
latexBuilder:
(context, tex, textStyle, inline) {
if (tex.contains(r"\begin{tabular}")) {
... ... @@ -579,47 +614,65 @@ This document was created to test the robustness of Markdown parsers and to ensu
},
linkBuilder:
(context, label, path, style) {
return Text(
return Text.rich(
label,
style: style.copyWith(
color: Colors.blue,
),
);
},
components: [
CodeBlockMd(),
NewLines(),
BlockQuote(),
ImageMd(),
ATagMd(),
TableMd(),
HTag(),
UnOrderedList(),
OrderedList(),
RadioButtonMd(),
CheckBoxMd(),
HrLine(),
StrikeMd(),
BoldMd(),
ItalicMd(),
LatexMath(),
LatexMathMultiLine(),
HighlightedText(),
SourceTag(),
IndentMd(),
],
inlineComponents: [
ImageMd(),
ATagMd(),
TableMd(),
StrikeMd(),
BoldMd(),
ItalicMd(),
LatexMath(),
LatexMathMultiLine(),
HighlightedText(),
SourceTag(),
],
// tableBuilder: (context, tableRows,
// textStyle, config) {
// return Table(
// border: TableBorder.all(
// width: 1,
// color: Colors.red,
// ),
// children: tableRows.map((e) {
// return TableRow(
// children: e.fields.map((e) {
// return Text(e.data);
// }).toList(),
// );
// }).toList(),
// );
// },
// components: [
// CodeBlockMd(),
// NewLines(),
// BlockQuote(),
// ImageMd(),
// ATagMd(),
// TableMd(),
// HTag(),
// UnOrderedList(),
// OrderedList(),
// RadioButtonMd(),
// CheckBoxMd(),
// HrLine(),
// StrikeMd(),
// BoldMd(),
// ItalicMd(),
// LatexMath(),
// LatexMathMultiLine(),
// HighlightedText(),
// SourceTag(),
// IndentMd(),
// ],
// inlineComponents: [
// ImageMd(),
// ATagMd(),
// TableMd(),
// StrikeMd(),
// BoldMd(),
// ItalicMd(),
// LatexMath(),
// LatexMathMultiLine(),
// HighlightedText(),
// SourceTag(),
// ],
// codeBuilder: (context, name, code, closed) {
// return Padding(
// padding: const EdgeInsets.symmetric(
... ...
platform :osx, '10.14'
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
... ...
... ... @@ -15,8 +15,8 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367
PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3
COCOAPODS: 1.16.2
... ...
... ... @@ -553,7 +553,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
... ... @@ -632,7 +632,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
... ... @@ -679,7 +679,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
... ...
... ... @@ -72,7 +72,13 @@ class _CodeFieldState extends State<CodeField> {
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.all(16),
child: Text(widget.codes),
child: Text(
widget.codes,
style: TextStyle(
fontFamily: 'JetBrainsMono',
package: "gpt_markdown",
),
),
),
],
),
... ...
... ... @@ -24,6 +24,7 @@ class CustomRb extends StatelessWidget {
return Directionality(
textDirection: textDirection,
child: Row(
mainAxisSize: MainAxisSize.min,
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: [
... ... @@ -35,16 +36,18 @@ class CustomRb extends StatelessWidget {
start: spacing,
end: spacing,
),
child: Radio(
value: value,
groupValue: true,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
child: RadioGroup(
onChanged: (value) {},
groupValue: true,
child: Radio(
value: value,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
),
),
Expanded(child: child),
Flexible(child: child),
],
),
);
... ... @@ -75,6 +78,7 @@ class CustomCb extends StatelessWidget {
return Directionality(
textDirection: textDirection,
child: Row(
mainAxisSize: MainAxisSize.min,
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: [
... ... @@ -90,7 +94,7 @@ class CustomCb extends StatelessWidget {
),
),
),
Expanded(child: child),
Flexible(child: child),
],
),
);
... ...
... ... @@ -13,6 +13,9 @@ class LinkButton extends StatefulWidget {
/// The text of the link.
final String text;
/// The child of the link.
final Widget? child;
/// The callback function to be called when the link is pressed.
final VoidCallback? onPressed;
... ... @@ -40,6 +43,7 @@ class LinkButton extends StatefulWidget {
this.onPressed,
this.textStyle,
this.url,
this.child,
});
@override
... ... @@ -65,7 +69,9 @@ class _LinkButtonState extends State<LinkButton> {
onTapUp: (_) => _handlePress(false),
onTapCancel: () => _handlePress(false),
onTap: widget.onPressed,
child: widget.config.getRich(TextSpan(text: widget.text, style: style)),
child:
widget.child ??
widget.config.getRich(TextSpan(text: widget.text, style: style)),
),
);
}
... ...
... ... @@ -44,11 +44,20 @@ typedef LatexBuilder =
typedef LinkBuilder =
Widget Function(
BuildContext context,
String text,
InlineSpan text,
String url,
TextStyle style,
);
/// A builder function for the table.
typedef TableBuilder =
Widget Function(
BuildContext context,
List<CustomTableRow> tableRows,
TextStyle textStyle,
GptMarkdownConfig config,
);
/// A builder function for the highlight.
typedef HighlightBuilder =
Widget Function(BuildContext context, String text, TextStyle style);
... ... @@ -61,12 +70,12 @@ typedef ImageBuilder = Widget Function(BuildContext context, String imageUrl);
/// The [GptMarkdownConfig] class is used to configure the GPT Markdown component.
/// It takes a [style] parameter to set the style of the text,
/// a [textDirection] parameter to set the direction of the text,
/// and an optional [onLinkTab] parameter to handle link clicks.
/// and an optional [onLinkTap] parameter to handle link clicks.
class GptMarkdownConfig {
const GptMarkdownConfig({
this.style,
this.textDirection = TextDirection.ltr,
this.onLinkTab,
this.onLinkTap,
this.textAlign,
this.textScaler,
this.latexWorkaround,
... ... @@ -83,6 +92,7 @@ class GptMarkdownConfig {
this.overflow,
this.components,
this.inlineComponents,
this.tableBuilder,
});
/// The direction of the text.
... ... @@ -98,7 +108,7 @@ class GptMarkdownConfig {
final TextScaler? textScaler;
/// The callback function to handle link clicks.
final void Function(String url, String title)? onLinkTab;
final void Function(String url, String title)? onLinkTap;
/// The LaTeX workaround.
final String Function(String tex)? latexWorkaround;
... ... @@ -142,11 +152,14 @@ class GptMarkdownConfig {
/// The list of inline components.
final List<MarkdownComponent>? inlineComponents;
/// The table builder.
final TableBuilder? tableBuilder;
/// A copy of the configuration with the specified parameters.
GptMarkdownConfig copyWith({
TextStyle? style,
TextDirection? textDirection,
final void Function(String url, String title)? onLinkTab,
final void Function(String url, String title)? onLinkTap,
final TextAlign? textAlign,
final TextScaler? textScaler,
final String Function(String tex)? latexWorkaround,
... ... @@ -163,11 +176,12 @@ class GptMarkdownConfig {
final UnOrderedListBuilder? unOrderedListBuilder,
final List<MarkdownComponent>? components,
final List<MarkdownComponent>? inlineComponents,
final TableBuilder? tableBuilder,
}) {
return GptMarkdownConfig(
style: style ?? this.style,
textDirection: textDirection ?? this.textDirection,
onLinkTab: onLinkTab ?? this.onLinkTab,
onLinkTap: onLinkTap ?? this.onLinkTap,
textAlign: textAlign ?? this.textAlign,
textScaler: textScaler ?? this.textScaler,
latexWorkaround: latexWorkaround ?? this.latexWorkaround,
... ... @@ -184,6 +198,7 @@ class GptMarkdownConfig {
unOrderedListBuilder: unOrderedListBuilder ?? this.unOrderedListBuilder,
components: components ?? this.components,
inlineComponents: inlineComponents ?? this.inlineComponents,
tableBuilder: tableBuilder ?? this.tableBuilder,
);
}
... ... @@ -198,4 +213,27 @@ class GptMarkdownConfig {
overflow: overflow,
);
}
/// A method to check if the configuration is the same.
bool isSame(GptMarkdownConfig other) {
return style == other.style &&
textAlign == other.textAlign &&
textScaler == other.textScaler &&
maxLines == other.maxLines &&
overflow == other.overflow &&
followLinkColor == other.followLinkColor &&
// latexWorkaround == other.latexWorkaround &&
// components == other.components &&
// inlineComponents == other.inlineComponents &&
// latexBuilder == other.latexBuilder &&
// sourceTagBuilder == other.sourceTagBuilder &&
// codeBuilder == other.codeBuilder &&
// orderedListBuilder == other.orderedListBuilder &&
// unOrderedListBuilder == other.unOrderedListBuilder &&
// linkBuilder == other.linkBuilder &&
// imageBuilder == other.imageBuilder &&
// highlightBuilder == other.highlightBuilder &&
// onLinkTap == other.onLinkTap &&
textDirection == other.textDirection;
}
}
... ...
... ... @@ -38,6 +38,7 @@ class UnorderedListView extends StatelessWidget {
return Directionality(
textDirection: textDirection,
child: Row(
mainAxisSize: MainAxisSize.min,
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: [
... ... @@ -63,7 +64,7 @@ class UnorderedListView extends StatelessWidget {
),
),
),
Expanded(child: child),
Flexible(child: child),
],
),
);
... ... @@ -104,6 +105,7 @@ class OrderedListView extends StatelessWidget {
return Directionality(
textDirection: textDirection,
child: Row(
mainAxisSize: MainAxisSize.min,
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: [
... ... @@ -111,7 +113,7 @@ class OrderedListView extends StatelessWidget {
padding: EdgeInsetsDirectional.only(start: padding, end: spacing),
child: Text.rich(TextSpan(text: no), style: _style),
),
Expanded(child: child),
Flexible(child: child),
],
),
);
... ...
No preview for this file type
... ... @@ -30,7 +30,7 @@ class GptMarkdown extends StatelessWidget {
this.textAlign,
this.imageBuilder,
this.textScaler,
this.onLinkTab,
this.onLinkTap,
this.latexBuilder,
this.codeBuilder,
this.sourceTagBuilder,
... ... @@ -40,8 +40,10 @@ class GptMarkdown extends StatelessWidget {
this.overflow,
this.orderedListBuilder,
this.unOrderedListBuilder,
this.tableBuilder,
this.components,
this.inlineComponents,
this.useDollarSignsForLatex = false,
});
/// The direction of the text.
... ... @@ -60,7 +62,7 @@ class GptMarkdown extends StatelessWidget {
final TextScaler? textScaler;
/// The callback function to handle link clicks.
final void Function(String url, String title)? onLinkTab;
final void Function(String url, String title)? onLinkTap;
/// The LaTeX workaround.
final String Function(String tex)? latexWorkaround;
... ... @@ -96,6 +98,12 @@ class GptMarkdown extends StatelessWidget {
/// The unordered list builder.
final UnOrderedListBuilder? unOrderedListBuilder;
/// Whether to use dollar signs for LaTeX.
final bool useDollarSignsForLatex;
/// The table builder.
final TableBuilder? tableBuilder;
/// The list of components.
/// ```dart
/// List<MarkdownComponent> components = [
... ... @@ -154,37 +162,40 @@ class GptMarkdown extends StatelessWidget {
@override
Widget build(BuildContext context) {
String tex = data.trim();
// print("texBefore:\n:$tex");
//去除 $$前面的空格 会导致渲染出错
tex = tex.replaceAllMapped(
RegExp(r"(?<!\\)\s*\$\$(.*?)(?<!\\)\$\$", dotAll: true),
(match) => "\n\\[${match[1] ?? ""}\\]",
);
if (useDollarSignsForLatex) {
// print("texBefore:\n:$tex");
// print("texAfter:\n:$tex");
if (!tex.contains(r"\(")) {
//去除 $$前面的空格 会导致渲染出错
tex = tex.replaceAllMapped(
RegExp(r"(?<!\\)\$(.*?)(?<!\\)\$"),
(match) => "\\(${match[1] ?? ""}\\)",
);
tex = tex.splitMapJoin(
RegExp(r"\[.*?\]|\(.*?\)"),
onNonMatch: (p0) {
return p0.replaceAll("\\\$", "\$");
},
RegExp(r"(?<!\\)\s*\$\$(.*?)(?<!\\)\$\$", dotAll: true),
(match) => "\n\\[${match[1] ?? ""}\\]",
);
// print("texAfter:\n:$tex");
if (!tex.contains(r"\(")) {
tex = tex.replaceAllMapped(
RegExp(r"(?<!\\)\$(.*?)(?<!\\)\$"),
(match) => "\\(${match[1] ?? ""}\\)",
);
tex = tex.splitMapJoin(
RegExp(r"\[.*?\]|\(.*?\)"),
onNonMatch: (p0) {
return p0.replaceAll("\\\$", "\$");
},
);
}
}
// tex = _removeExtraLinesInsideBlockLatex(tex);
return ClipRRect(
child: MdWidget(
context,
tex,
true,
config: GptMarkdownConfig(
textDirection: textDirection,
style: style,
onLinkTab: onLinkTab,
onLinkTap: onLinkTap,
textAlign: textAlign,
textScaler: textScaler,
followLinkColor: followLinkColor,
... ... @@ -201,6 +212,7 @@ class GptMarkdown extends StatelessWidget {
unOrderedListBuilder: unOrderedListBuilder,
components: components,
inlineComponents: inlineComponents,
tableBuilder: tableBuilder,
),
),
);
... ...
... ... @@ -2,12 +2,11 @@ part of 'gpt_markdown.dart';
/// Markdown components
abstract class MarkdownComponent {
static final List<MarkdownComponent> components = [
static List<MarkdownComponent> get globalComponents => [
CodeBlockMd(),
LatexMathMultiLine(),
NewLines(),
BlockQuote(),
ImageMd(),
ATagMd(),
TableMd(),
HTag(),
UnOrderedList(),
... ... @@ -15,23 +14,17 @@ abstract class MarkdownComponent {
RadioButtonMd(),
CheckBoxMd(),
HrLine(),
StrikeMd(),
BoldMd(),
ItalicMd(),
LatexMath(),
LatexMathMultiLine(),
HighlightedText(),
SourceTag(),
IndentMd(),
];
static final List<MarkdownComponent> inlineComponents = [
ImageMd(),
ATagMd(),
ImageMd(),
TableMd(),
StrikeMd(),
BoldMd(),
ItalicMd(),
UnderLineMd(),
LatexMath(),
LatexMathMultiLine(),
HighlightedText(),
... ... @@ -47,7 +40,7 @@ abstract class MarkdownComponent {
) {
var components =
includeGlobalComponents
? config.components ?? MarkdownComponent.components
? config.components ?? MarkdownComponent.globalComponents
: config.inlineComponents ?? MarkdownComponent.inlineComponents;
List<InlineSpan> spans = [];
Iterable<String> regexes = components.map<String>((e) => e.exp.pattern);
... ... @@ -75,6 +68,14 @@ abstract class MarkdownComponent {
return "";
},
onNonMatch: (p0) {
if (p0.isEmpty) {
return "";
}
if (includeGlobalComponents) {
var newSpans = generate(context, p0, config.copyWith(), false);
spans.addAll(newSpans);
return "";
}
spans.add(TextSpan(text: p0, style: config.style));
return "";
},
... ... @@ -112,7 +113,8 @@ abstract class BlockMd extends MarkdownComponent {
bool get inline => false;
@override
RegExp get exp => RegExp(r'^\ *?' + expString, dotAll: true, multiLine: true);
RegExp get exp =>
RegExp(r'^\ *?' + expString + r"$", dotAll: true, multiLine: true);
String get expString;
... ... @@ -134,7 +136,10 @@ abstract class BlockMd extends MarkdownComponent {
child: child,
);
}
child = Row(children: [Flexible(child: child)]);
child = Row(
mainAxisSize: MainAxisSize.min,
children: [Flexible(child: child)],
);
return WidgetSpan(
child: child,
alignment: PlaceholderAlignment.baseline,
... ... @@ -164,6 +169,7 @@ class IndentMd extends BlockMd {
return Directionality(
textDirection: config.textDirection,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: config.getRich(
... ... @@ -196,14 +202,15 @@ class HTag extends BlockMd {
var theme = GptMarkdownTheme.of(context);
var match = this.exp.firstMatch(text.trim());
var conf = config.copyWith(
style: [
theme.h1,
theme.h2,
theme.h3,
theme.h4,
theme.h5,
theme.h6,
][match![1]!.length - 1]?.copyWith(color: config.style?.color),
style:
[
theme.h1,
theme.h2,
theme.h3,
theme.h4,
theme.h5,
theme.h6,
][match![1]!.length - 1],
);
return config.getRich(
TextSpan(
... ... @@ -257,7 +264,7 @@ class NewLines extends InlineMd {
/// Horizontal line component
class HrLine extends BlockMd {
@override
String get expString => (r"(--)[-]+$");
String get expString => (r"⸻|((--)[-]+)$");
@override
Widget build(
BuildContext context,
... ... @@ -277,7 +284,6 @@ class HrLine extends BlockMd {
class CheckBoxMd extends BlockMd {
@override
String get expString => (r"\[((?:\x|\ ))\]\ (\S[^\n]*?)$");
get onLinkTab => null;
@override
Widget build(
... ... @@ -289,7 +295,7 @@ class CheckBoxMd extends BlockMd {
return CustomCb(
value: ("${match?[1]}" == "x"),
textDirection: config.textDirection,
child: MdWidget("${match?[2]}", false, config: config),
child: MdWidget(context, "${match?[2]}", false, config: config),
);
}
}
... ... @@ -298,7 +304,6 @@ class CheckBoxMd extends BlockMd {
class RadioButtonMd extends BlockMd {
@override
String get expString => (r"\(((?:\x|\ ))\)\ (\S[^\n]*)$");
get onLinkTab => null;
@override
Widget build(
... ... @@ -310,7 +315,7 @@ class RadioButtonMd extends BlockMd {
return CustomRb(
value: ("${match?[1]}" == "x"),
textDirection: config.textDirection,
child: MdWidget("${match?[2]}", false, config: config),
child: MdWidget(context, "${match?[2]}", false, config: config),
);
}
}
... ... @@ -389,7 +394,7 @@ class UnOrderedList extends BlockMd {
) {
var match = this.exp.firstMatch(text);
var child = MdWidget("${match?[1]?.trim()}", true, config: config);
var child = MdWidget(context, "${match?[1]?.trim()}", true, config: config);
return config.unOrderedListBuilder?.call(
context,
... ... @@ -423,11 +428,11 @@ class OrderedList extends BlockMd {
String text,
final GptMarkdownConfig config,
) {
var match = this.exp.firstMatch(text.trim());
var match = this.exp.firstMatch(text);
var no = "${match?[1]}";
var no = "${match?[1]}".trim();
var child = MdWidget("${match?[2]?.trim()}", true, config: config);
var child = MdWidget(context, "${match?[2]}".trim(), true, config: config);
return config.orderedListBuilder?.call(
context,
no,
... ... @@ -779,7 +784,7 @@ class SourceTag extends InlineMd {
/// Link text component
class ATagMd extends InlineMd {
@override
RegExp get exp => RegExp(r"\[([^\s\*\[][^\n]*?[^\s]?)?\]\(([^\s\*]*[^\)])\)");
RegExp get exp => RegExp(r"(?<!\!)\[.*\]\([^\s]*\)");
@override
InlineSpan span(
... ... @@ -787,24 +792,91 @@ class ATagMd extends InlineMd {
String text,
final GptMarkdownConfig config,
) {
var match = exp.firstMatch(text.trim());
if (match?[1] == null && match?[2] == null) {
var bracketCount = 0;
var start = 1;
var end = 0;
for (var i = 0; i < text.length; i++) {
if (text[i] == '[') {
bracketCount++;
} else if (text[i] == ']') {
bracketCount--;
if (bracketCount == 0) {
end = i;
break;
}
}
}
if (text[end + 1] != '(') {
return const TextSpan();
}
final linkText = match?[1] ?? "";
final url = match?[2] ?? "";
// First try to find the basic pattern
// final basicMatch = RegExp(r'(?<!\!)\[(.*)\]\(').firstMatch(text.trim());
// if (basicMatch == null) {
// return const TextSpan();
// }
final linkText = text.substring(start, end);
final urlStart = end + 2;
// Now find the balanced closing parenthesis
int parenCount = 0;
int urlEnd = urlStart;
for (int i = urlStart; i < text.length; i++) {
final char = text[i];
if (char == '(') {
parenCount++;
} else if (char == ')') {
if (parenCount == 0) {
// This is the closing parenthesis of the link
urlEnd = i;
break;
} else {
parenCount--;
}
}
}
if (urlEnd == urlStart) {
// No closing parenthesis found
return const TextSpan();
}
final url = text.substring(urlStart, urlEnd).trim();
var builder = config.linkBuilder;
var ending = text.substring(urlEnd + 1);
var endingSpans = MarkdownComponent.generate(
context,
ending,
config,
false,
);
var theme = GptMarkdownTheme.of(context);
var linkTextSpan = TextSpan(
children: MarkdownComponent.generate(context, linkText, config, false),
style: config.style?.copyWith(
color: theme.linkColor,
decorationColor: theme.linkColor,
),
);
// Use custom builder if provided
WidgetSpan? child;
if (builder != null) {
return WidgetSpan(
child = WidgetSpan(
baseline: TextBaseline.alphabetic,
alignment: PlaceholderAlignment.baseline,
child: GestureDetector(
onTap: () => config.onLinkTab?.call(url, linkText),
onTap: () => config.onLinkTap?.call(url, linkText),
child: builder(
context,
linkText,
linkTextSpan,
url,
config.style ?? const TextStyle(),
),
... ... @@ -813,25 +885,29 @@ class ATagMd extends InlineMd {
}
// Default rendering
var theme = GptMarkdownTheme.of(context);
return WidgetSpan(
child ??= WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: LinkButton(
hoverColor: theme.linkHoverColor,
color: theme.linkColor,
onPressed: () {
config.onLinkTab?.call(url, linkText);
config.onLinkTap?.call(url, linkText);
},
text: linkText,
config: config,
child: config.getRich(linkTextSpan),
),
);
var textSpan = TextSpan(children: [child, ...endingSpans]);
return textSpan;
}
}
/// Image component
class ImageMd extends InlineMd {
@override
RegExp get exp => RegExp(r"\!\[([^\s][^\n]*[^\s]?)?\]\(([^\s]+?)\)");
RegExp get exp => RegExp(r"\!\[[^\[\]]*\]\([^\s]*\)");
@override
InlineSpan span(
... ... @@ -839,25 +915,59 @@ class ImageMd extends InlineMd {
String text,
final GptMarkdownConfig config,
) {
var match = exp.firstMatch(text.trim());
// First try to find the basic pattern
final basicMatch = RegExp(r'\!\[([^\[\]]*)\]\(').firstMatch(text.trim());
if (basicMatch == null) {
return const TextSpan();
}
final altText = basicMatch.group(1) ?? '';
final urlStart = basicMatch.end;
// Now find the balanced closing parenthesis
int parenCount = 0;
int urlEnd = urlStart;
for (int i = urlStart; i < text.length; i++) {
final char = text[i];
if (char == '(') {
parenCount++;
} else if (char == ')') {
if (parenCount == 0) {
// This is the closing parenthesis of the image
urlEnd = i;
break;
} else {
parenCount--;
}
}
}
if (urlEnd == urlStart) {
// No closing parenthesis found
return const TextSpan();
}
final url = text.substring(urlStart, urlEnd).trim();
double? height;
double? width;
if (match?[1] != null) {
var size = RegExp(
r"^([0-9]+)?x?([0-9]+)?",
).firstMatch(match![1].toString().trim());
if (altText.isNotEmpty) {
var size = RegExp(r"^([0-9]+)?x?([0-9]+)?").firstMatch(altText.trim());
width = double.tryParse(size?[1]?.toString().trim() ?? 'a');
height = double.tryParse(size?[2]?.toString().trim() ?? 'a');
}
final Widget image;
if (config.imageBuilder != null) {
image = config.imageBuilder!(context, '${match?[2]}');
image = config.imageBuilder!(context, url);
} else {
image = SizedBox(
width: width,
height: height,
child: Image(
image: NetworkImage("${match?[2]}"),
image: NetworkImage(url),
loadingBuilder: (
BuildContext context,
Widget child,
... ... @@ -909,19 +1019,80 @@ class TableMd extends BlockMd {
.asMap(),
)
.toList();
bool heading = RegExp(
r"^\|.*?\|\n\|-[-\\ |]*?-\|$",
multiLine: true,
).hasMatch(text.trim());
// Check if table has a header and separator row
bool hasHeader = value.length >= 2;
List<TextAlign> columnAlignments = [];
if (hasHeader) {
// Parse alignment from the separator row (second row)
var separatorRow = value[1];
columnAlignments = List.generate(separatorRow.length, (index) {
String separator = separatorRow[index] ?? "";
separator = separator.trim();
// Check for alignment indicators
bool hasLeftColon = separator.startsWith(':');
bool hasRightColon = separator.endsWith(':');
if (hasLeftColon && hasRightColon) {
return TextAlign.center;
} else if (hasRightColon) {
return TextAlign.right;
} else if (hasLeftColon) {
return TextAlign.left;
} else {
return TextAlign.left; // Default alignment
}
});
}
int maxCol = 0;
for (final each in value) {
if (maxCol < each.keys.length) {
maxCol = each.keys.length;
}
}
if (maxCol == 0) {
return Text("", style: config.style);
}
// Ensure we have alignment for all columns
while (columnAlignments.length < maxCol) {
columnAlignments.add(TextAlign.left);
}
var tableBuilder = config.tableBuilder;
if (tableBuilder != null) {
var customTable =
List<CustomTableRow?>.generate(value.length, (index) {
var isHeader = index == 0;
var row = value[index];
if (row.isEmpty) {
return null;
}
if (index == 1) {
return null;
}
var fields = List<CustomTableField>.generate(maxCol, (index) {
var field = row[index];
return CustomTableField(
data: field ?? "",
alignment: columnAlignments[index],
);
});
return CustomTableRow(isHeader: isHeader, fields: fields);
}).nonNulls.toList();
return tableBuilder(
context,
customTable,
config.style ?? const TextStyle(),
config,
);
}
final controller = ScrollController();
return Scrollbar(
controller: controller,
... ... @@ -940,17 +1111,22 @@ class TableMd extends BlockMd {
value
.asMap()
.entries
.where((entry) {
// Skip the separator row (second row) from rendering
if (hasHeader && entry.key == 1) {
return false;
}
return true;
})
.map<TableRow>(
(entry) => TableRow(
decoration:
(heading)
(hasHeader && entry.key == 0)
? BoxDecoration(
color:
(entry.key == 0)
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: null,
Theme.of(
context,
).colorScheme.surfaceContainerHighest,
)
: null,
children: List.generate(maxCol, (index) {
... ... @@ -961,19 +1137,41 @@ class TableMd extends BlockMd {
return const SizedBox();
}
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: MdWidget(
(e[index] ?? "").trim(),
false,
config: config,
),
// Apply alignment based on column alignment
Widget content = Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: MdWidget(
context,
(e[index] ?? "").trim(),
false,
config: config,
),
);
// Wrap with alignment widget
switch (columnAlignments[index]) {
case TextAlign.center:
content = Center(child: content);
break;
case TextAlign.right:
content = Align(
alignment: Alignment.centerRight,
child: content,
);
break;
case TextAlign.left:
default:
content = Align(
alignment: Alignment.centerLeft,
child: content,
);
break;
}
return content;
}),
),
)
... ... @@ -995,10 +1193,54 @@ class CodeBlockMd extends BlockMd {
) {
String codes = this.exp.firstMatch(text)?[2] ?? "";
String name = this.exp.firstMatch(text)?[1] ?? "";
codes = codes.replaceAll(r"```", "").trim();
codes = codes.replaceAll(r"```", "");
bool closed = text.endsWith("```");
return config.codeBuilder?.call(context, name, codes, closed) ??
CodeField(name: name, codes: codes);
}
}
class UnderLineMd extends InlineMd {
@override
RegExp get exp =>
RegExp(r"<u>(.*?)(?:</u>|$)", multiLine: true, dotAll: true);
@override
InlineSpan span(
BuildContext context,
String text,
final GptMarkdownConfig config,
) {
var match = exp.firstMatch(text.trim());
var conf = config.copyWith(
style: (config.style ?? const TextStyle()).copyWith(
decoration: TextDecoration.underline,
decorationColor: config.style?.color,
),
);
return TextSpan(
children: MarkdownComponent.generate(
context,
"${match?[1]}",
conf,
false,
),
style: conf.style,
);
}
}
class CustomTableField {
final String data;
final TextAlign alignment;
CustomTableField({required this.data, this.alignment = TextAlign.left});
}
class CustomTableRow {
final bool isHeader;
final List<CustomTableField> fields;
CustomTableRow({this.isHeader = false, required this.fields});
}
... ...
part of 'gpt_markdown.dart';
/// It creates a markdown widget closed to each other.
class MdWidget extends StatelessWidget {
class MdWidget extends StatefulWidget {
const MdWidget(
this.context,
this.exp,
this.includeGlobalComponents, {
super.key,
... ... @@ -11,6 +12,7 @@ class MdWidget extends StatelessWidget {
/// The expression to be displayed.
final String exp;
final BuildContext context;
/// Whether to include global components.
final bool includeGlobalComponents;
... ... @@ -19,25 +21,46 @@ class MdWidget extends StatelessWidget {
final GptMarkdownConfig config;
@override
Widget build(BuildContext context) {
List<InlineSpan> list = MarkdownComponent.generate(
context,
exp,
// .replaceAllMapped(
// RegExp(
// r"\\\[(.*?)\\\]|(\\begin.*?\\end{.*?})",
// multiLine: true,
// dotAll: true,
// ), (match) {
// //
// String body = (match[1] ?? match[2])?.replaceAll("\n", " ") ?? "";
// return "\\[$body\\]";
// }),
config,
includeGlobalComponents,
State<MdWidget> createState() => _MdWidgetState();
}
class _MdWidgetState extends State<MdWidget> {
List<InlineSpan> list = [];
@override
void initState() {
super.initState();
list = MarkdownComponent.generate(
widget.context,
widget.exp,
widget.config,
widget.includeGlobalComponents,
);
return config.getRich(
TextSpan(children: list, style: config.style?.copyWith()),
}
@override
void didUpdateWidget(covariant MdWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.exp != widget.exp ||
!oldWidget.config.isSame(widget.config)) {
list = MarkdownComponent.generate(
context,
widget.exp,
widget.config,
widget.includeGlobalComponents,
);
}
}
@override
Widget build(BuildContext context) {
// List<InlineSpan> list = MarkdownComponent.generate(
// context,
// widget.exp,
// widget.config,
// widget.includeGlobalComponents,
// );
return widget.config.getRich(
TextSpan(children: list, style: widget.config.style?.copyWith()),
);
}
}
... ...
name: gpt_markdown
description: "Powerful Markdown & LaTeX Renderer for Flutter: Rich Text, Math, Tables, Links, and Text Selection. Ideal for ChatGPT, Gemini, and more."
version: 1.0.16
description: "Powerful Flutter Markdown & LaTeX Renderer: Rich Text, Math, Tables, Links, and Text Selection. Ideal for ChatGPT, Gemini, and more."
version: 1.1.4
homepage: https://github.com/Infinitix-LLC/gpt_markdown
environment:
... ... @@ -14,9 +14,13 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
# flutter_lints: ^5.0.0
# flutter_lints: ^6.0.0
flutter:
fonts:
- family: JetBrainsMono
fonts:
- asset: lib/fonts/JetBrainsMono-Regular.ttf
topics:
- markdown
... ... @@ -24,3 +28,13 @@ topics:
- selectable
- chatgpt
- gemini
keywords:
- flutter
- markdown
- flutter markdown
- gpt
- latex
- chatgpt
- rich text
- ai
... ...