From bcd8af3e2d2ad1906d3452a455ca76f41c3cdbd2 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sat, 4 Apr 2026 00:59:46 +0800 Subject: [PATCH] feat: add XML and HTML formatting support (#721) --- lib/network/http/content_type.dart | 1 + lib/network/http/http.dart | 10 +- lib/ui/component/search_condition.dart | 3 +- lib/ui/component/utils.dart | 1 + lib/ui/content/body.dart | 84 +++++++++---- lib/ui/desktop/request/search.dart | 2 +- lib/utils/html_formatter.dart | 166 +++++++++++++++++++++++++ lib/utils/xml_formatter.dart | 18 +++ pubspec.yaml | 2 + test/html_format_test.dart | 43 +++++++ test/xml_format_test.dart | 46 +++++++ 11 files changed, 343 insertions(+), 33 deletions(-) create mode 100644 lib/utils/html_formatter.dart create mode 100644 lib/utils/xml_formatter.dart create mode 100644 test/html_format_test.dart create mode 100644 test/xml_format_test.dart diff --git a/lib/network/http/content_type.dart b/lib/network/http/content_type.dart index ff68e44..3ae6b81 100644 --- a/lib/network/http/content_type.dart +++ b/lib/network/http/content_type.dart @@ -25,6 +25,7 @@ enum ContentType { formData, js, html, + xml, text, css, font, diff --git a/lib/network/http/http.dart b/lib/network/http/http.dart index a1c0cba..4ec6a38 100644 --- a/lib/network/http/http.dart +++ b/lib/network/http/http.dart @@ -38,6 +38,10 @@ abstract class HttpMessage { "text/css": ContentType.css, "font-woff": ContentType.font, "text/html": ContentType.html, + "application/xhtml+xml": ContentType.html, + "+xml": ContentType.xml, + "application/xml": ContentType.xml, + "text/xml": ContentType.xml, "text/plain": ContentType.text, "application/x-www-form-urlencoded": ContentType.formUrl, "form-data": ContentType.formData, @@ -128,11 +132,9 @@ abstract class HttpMessage { if (headers.isGzip) { rawBody = gzipDecode(body!); - }else - - if (headers.contentEncoding == 'br') { + } else if (headers.contentEncoding == 'br') { rawBody = brDecode(body!); - } else if (headers.contentEncoding == 'deflate') { + } else if (headers.contentEncoding == 'deflate') { rawBody = zlibDecode(body!); } diff --git a/lib/ui/component/search_condition.dart b/lib/ui/component/search_condition.dart index 19ca083..e295aef 100644 --- a/lib/ui/component/search_condition.dart +++ b/lib/ui/component/search_condition.dart @@ -47,11 +47,12 @@ class SearchConditionsState extends State { final Map responseContentMap = { 'JSON': ContentType.json, + 'IMAGE': ContentType.image, 'HTML': ContentType.html, 'JS': ContentType.js, 'CSS': ContentType.css, 'TEXT': ContentType.text, - 'IMAGE': ContentType.image + 'XML': ContentType.xml, }; late SearchModel searchModel; diff --git a/lib/ui/component/utils.dart b/lib/ui/component/utils.dart index d854225..b28a0f5 100644 --- a/lib/ui/component/utils.dart +++ b/lib/ui/component/utils.dart @@ -30,6 +30,7 @@ import '../../utils/platform.dart'; const contentMap = { ContentType.json: Icons.data_object, ContentType.html: Icons.html, + ContentType.xml: Icons.code, ContentType.js: Icons.javascript, ContentType.image: Icons.image, ContentType.video: Icons.video_call, diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index b2263bd..9dd1b53 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -36,9 +36,11 @@ import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/desktop/setting/request_rewrite.dart'; import 'package:proxypin/ui/mobile/setting/request_rewrite.dart'; import 'package:proxypin/utils/crypto_body_decoder.dart'; +import 'package:proxypin/utils/html_formatter.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/num.dart'; import 'package:proxypin/utils/platform.dart'; +import 'package:proxypin/utils/xml_formatter.dart'; import 'package:window_manager/window_manager.dart'; import '../component/json/json_text.dart'; @@ -509,43 +511,61 @@ class _BodyState extends State<_Body> { return _getBody(viewType); } + HttpMessage? _effectiveMessage(HttpBodyState? parent) { + if (parent?.showDecoded == true && parent?.decoded != null && message != null) { + return _DecodedHttpMessage(message!, parent!.decoded!); + } + return message; + } + + String _formatTextBody(ViewType type, String body) { + try { + if (type == ViewType.formUrl) { + return Uri.decodeFull(body); + } + + if (type == ViewType.html) { + return HTML.pretty(body); + } + + if (type == ViewType.xml) { + return XML.pretty(body); + } + + if (type == ViewType.jsonText || type == ViewType.json) { + var jsonObject = json.decode(body); + return const JsonEncoder.withIndent(" ").convert(jsonObject); + } + } catch (_) {} + + return body; + } + Future getBody() async { final parent = context.findAncestorStateOfType(); - if (parent?.showDecoded == true && parent?.decoded?.text != null) { - return parent!.decoded!.text; + final currentMessage = _effectiveMessage(parent); + + if (currentMessage?.isWebSocket == true) { + return currentMessage?.messages.map((e) => e.payloadDataAsString).join("\n"); } - if (message?.isWebSocket == true) { - return message?.messages.map((e) => e.payloadDataAsString).join("\n"); - } - - if (message == null || message?.body == null) { + if (currentMessage == null || currentMessage.body == null) { return null; } if (viewType == ViewType.hex) { - return message!.body!.map(intToHex).join(" "); + return currentMessage.body!.map(intToHex).join(" "); } - try { - if (viewType == ViewType.formUrl) { - return Uri.decodeFull(message!.bodyAsString); - } - - if (viewType == ViewType.jsonText || viewType == ViewType.json) { - //json格式化 - var jsonObject = json.decode(await message!.decodeBodyString()); - return const JsonEncoder.withIndent(" ").convert(jsonObject); - } - } catch (_) {} - return message!.decodeBodyString(); + final body = parent?.showDecoded == true && parent?.decoded?.text != null + ? parent!.decoded!.text! + : await currentMessage.decodeBodyString(); + return _formatTextBody(viewType, body); } Widget _getBody(ViewType type) { final parent = context.findAncestorStateOfType(); - final message = parent?.showDecoded == true && parent?.decoded != null - ? _DecodedHttpMessage(widget.message!, parent!.decoded!) - : widget.message; + final message = _effectiveMessage(parent); if (message?.isWebSocket == true || (message?.contentType == ContentType.sse && message?.messages.isNotEmpty == true)) { @@ -592,12 +612,16 @@ class _BodyState extends State<_Body> { if (type == ViewType.formUrl) { return HighlightTextWidget( - text: Uri.decodeFull(message.getBodyString()), + text: _formatTextBody(type, message.getBodyString()), searchController: widget.searchController, contextMenuBuilder: contextMenu); } - return futureWidget(message.decodeBodyString(), initialData: message.getBodyString(), (body) { + final initialData = (type == ViewType.html || type == ViewType.xml) + ? _formatTextBody(type, message.getBodyString()) + : message.getBodyString(); + + return futureWidget(message.decodeBodyString(), initialData: initialData, (body) { try { if (type == ViewType.jsonText) { var jsonObject = json.decode(body); @@ -615,7 +639,9 @@ class _BodyState extends State<_Body> { } return HighlightTextWidget( - text: body, searchController: widget.searchController, contextMenuBuilder: contextMenu); + text: _formatTextBody(type, body), + searchController: widget.searchController, + contextMenuBuilder: contextMenu); } catch (e) { logger.e(e, stackTrace: StackTrace.current); } @@ -653,7 +679,10 @@ class Tabs { tabs.list.add(ViewType.json); } - if (contentType == ContentType.formUrl || contentType == ContentType.json) { + if (contentType == ContentType.formUrl || + contentType == ContentType.json || + contentType == ContentType.html || + contentType == ContentType.xml) { tabs.list.add(ViewType.text); } @@ -672,6 +701,7 @@ enum ViewType { json("JSON"), jsonText("JSON Text"), html("HTML"), + xml("XML"), image("Image"), video("Video"), css("CSS"), diff --git a/lib/ui/desktop/request/search.dart b/lib/ui/desktop/request/search.dart index c87d61a..68b806b 100644 --- a/lib/ui/desktop/request/search.dart +++ b/lib/ui/desktop/request/search.dart @@ -155,7 +155,7 @@ class ContentTypeState extends State { @override Widget build(BuildContext context) { value ??= localizations.all; - types ??= ["JSON", "HTML", "JS", "CSS", "TEXT", "IMAGE", localizations.all]; + types ??= ["JSON", "IMAGE", "HTML", "JS", "CSS", "TEXT", "XML", localizations.all]; return PopupMenuButton( initialValue: value, diff --git a/lib/utils/html_formatter.dart b/lib/utils/html_formatter.dart new file mode 100644 index 0000000..2795c16 --- /dev/null +++ b/lib/utils/html_formatter.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; + +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +class HTML { + static final RegExp _documentTagPattern = RegExp(r'^\s*<(?:!doctype|html|head|body)\b', caseSensitive: false); + + /// 格式化 HTML + static String pretty(String htmlString) { + if (htmlString.trim().isEmpty || !htmlString.contains('<')) { + return htmlString; + } + + try { + final root = _documentTagPattern.hasMatch(htmlString) + ? html_parser.parse(htmlString) + : html_parser.parseFragment(htmlString); + final buffer = StringBuffer(); + + for (final node in root.nodes) { + _HtmlPrettyPrinter.writeNode(node, buffer, 0); + } + + final formatted = buffer.toString().trimRight(); + return formatted.isEmpty ? htmlString : formatted; + } catch (_) { + return htmlString; + } + } +} + +class _HtmlPrettyPrinter { + static const String _indent = ' '; + static const Set _voidElements = { + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + }; + static const Set _preserveContentElements = {'pre', 'script', 'style', 'textarea'}; + static const HtmlEscape _attributeEscaper = HtmlEscape(HtmlEscapeMode.attribute); + + static void writeNode(dom.Node node, StringBuffer buffer, int depth) { + if (node is dom.Text) { + final text = _normalizeText(node.text); + if (text.isNotEmpty) { + _writeLine(buffer, depth, text); + } + return; + } + + if (node is dom.Comment) { + _writeLine(buffer, depth, node.toString().trim()); + return; + } + + if (node is dom.DocumentType) { + _writeLine(buffer, depth, node.toString().trim()); + return; + } + + if (node is dom.Element) { + _writeElement(node, buffer, depth); + return; + } + + for (final child in node.nodes) { + writeNode(child, buffer, depth); + } + } + + static void _writeElement(dom.Element element, StringBuffer buffer, int depth) { + final tag = (element.localName ?? '').toLowerCase(); + if (tag.isEmpty) { + for (final child in element.nodes) { + writeNode(child, buffer, depth); + } + return; + } + + final openTag = _openTag(element, tag); + + if (_voidElements.contains(tag)) { + _writeLine(buffer, depth, openTag); + return; + } + + if (_preserveContentElements.contains(tag)) { + _writeLine(buffer, depth, openTag); + final content = element.innerHtml.trimRight(); + if (content.isNotEmpty) { + for (final line in content.split('\n')) { + _writeLine(buffer, depth + 1, line.trimRight()); + } + } + _writeLine(buffer, depth, ''); + return; + } + + final children = element.nodes.where(_hasVisibleContent).toList(); + if (children.isEmpty) { + _writeLine(buffer, depth, '$openTag'); + return; + } + + final inlineText = _inlineText(children); + if (inlineText != null) { + _writeLine(buffer, depth, '$openTag$inlineText'); + return; + } + + _writeLine(buffer, depth, openTag); + for (final child in children) { + writeNode(child, buffer, depth + 1); + } + _writeLine(buffer, depth, ''); + } + + static bool _hasVisibleContent(dom.Node node) { + if (node is dom.Text) { + return _normalizeText(node.text).isNotEmpty; + } + return true; + } + + static String? _inlineText(List children) { + if (children.length != 1 || children.first is! dom.Text) { + return null; + } + + final text = _normalizeText((children.first as dom.Text).text); + return text.isEmpty ? null : text; + } + + static String _openTag(dom.Element element, String tag) { + if (element.attributes.isEmpty) { + return '<$tag>'; + } + + final attributes = + element.attributes.entries.map((entry) => '${entry.key}="${_attributeEscaper.convert(entry.value)}"').join(' '); + return '<$tag $attributes>'; + } + + static String _normalizeText(String text) { + return text.replaceAll(RegExp(r'\s+'), ' ').trim(); + } + + static void _writeLine(StringBuffer buffer, int depth, String line) { + buffer + ..write(_indent * depth) + ..writeln(line); + } +} + diff --git a/lib/utils/xml_formatter.dart b/lib/utils/xml_formatter.dart new file mode 100644 index 0000000..d99d035 --- /dev/null +++ b/lib/utils/xml_formatter.dart @@ -0,0 +1,18 @@ +import 'package:xml/xml.dart'; + +class XML { + /// 格式化 XML + static String pretty(String xmlString) { + if (xmlString.trim().isEmpty || !xmlString.contains('<')) { + return xmlString; + } + + try { + final document = XmlDocument.parse(xmlString); + return document.toXmlString(pretty: true, indent: ' '); + } catch (_) { + return xmlString; + } + } +} + diff --git a/pubspec.yaml b/pubspec.yaml index 4d37a61..ece919a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,8 @@ dependencies: qr_flutter: ^4.1.0 flutter_qr_reader_plus: ^1.0.6 brotli: ^0.6.0 + html: ^0.15.6 + xml: ^6.6.1 # macos_window_utils: 1.6.1 win32audio: ^1.3.1 vclibs: ^0.1.3 diff --git a/test/html_format_test.dart b/test/html_format_test.dart new file mode 100644 index 0000000..b167f55 --- /dev/null +++ b/test/html_format_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:proxypin/network/http/content_type.dart'; +import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/utils/html_formatter.dart'; + +void main() { + test('HTML.pretty formats nested markup', () { + const input = '

Hello

World !

'; + + expect( + HTML.pretty(input), + '
\n' + '

Hello

\n' + '

\n' + ' World\n' + ' !\n' + '

\n' + '
', + ); + }); + + test('HTML.pretty tolerates malformed HTML', () { + const input = '
hello
'; + + expect( + HTML.pretty(input), + '
\n' + ' hello\n' + '
', + ); + }); + + test('HTML.pretty leaves plain text unchanged', () { + expect(HTML.pretty('plain text body'), 'plain text body'); + }); + + test('xhtml content type maps to html', () { + final response = HttpResponse(HttpStatus.ok); + response.headers.set('content-type', 'application/xhtml+xml; charset=utf-8'); + + expect(response.contentType, ContentType.html); + }); +} diff --git a/test/xml_format_test.dart b/test/xml_format_test.dart new file mode 100644 index 0000000..b60d629 --- /dev/null +++ b/test/xml_format_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:proxypin/network/http/content_type.dart'; +import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/utils/xml_formatter.dart'; + +void main() { + test('XML.pretty formats nested document', () { + const input = '12'; + + expect( + XML.pretty(input), + '\n' + ' 1\n' + ' \n' + ' 2\n' + ' \n' + '', + ); + }); + + test('XML.pretty falls back for malformed XML', () { + const input = ''; + expect(XML.pretty(input), input); + }); + + test('xml content types map to ContentType.xml', () { + final response = HttpResponse(HttpStatus.ok); + + response.headers.set('content-type', 'application/xml; charset=utf-8'); + expect(response.contentType, ContentType.xml); + + response.headers.set('content-type', 'text/xml; charset=utf-8'); + expect(response.contentType, ContentType.xml); + + response.headers.set('content-type', 'application/soap+xml; charset=utf-8'); + expect(response.contentType, ContentType.xml); + }); + + test('xhtml keeps html content type', () { + final response = HttpResponse(HttpStatus.ok); + response.headers.set('content-type', 'application/xhtml+xml; charset=utf-8'); + + expect(response.contentType, ContentType.html); + }); +} +