feat: add XML and HTML formatting support (#721)

This commit is contained in:
wanghongenpin
2026-04-04 00:59:46 +08:00
parent 87fb5a7f21
commit bcd8af3e2d
11 changed files with 343 additions and 33 deletions

View File

@@ -25,6 +25,7 @@ enum ContentType {
formData,
js,
html,
xml,
text,
css,
font,

View File

@@ -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!);
}

View File

@@ -47,11 +47,12 @@ class SearchConditionsState extends State<SearchConditions> {
final Map<String, ContentType?> 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;

View File

@@ -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,

View File

@@ -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<String?> getBody() async {
final parent = context.findAncestorStateOfType<HttpBodyState>();
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<HttpBodyState>();
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"),

View File

@@ -155,7 +155,7 @@ class ContentTypeState extends State<ContentTypeSelect> {
@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,

View File

@@ -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<String> _voidElements = {
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
};
static const Set<String> _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, '</$tag>');
return;
}
final children = element.nodes.where(_hasVisibleContent).toList();
if (children.isEmpty) {
_writeLine(buffer, depth, '$openTag</$tag>');
return;
}
final inlineText = _inlineText(children);
if (inlineText != null) {
_writeLine(buffer, depth, '$openTag$inlineText</$tag>');
return;
}
_writeLine(buffer, depth, openTag);
for (final child in children) {
writeNode(child, buffer, depth + 1);
}
_writeLine(buffer, depth, '</$tag>');
}
static bool _hasVisibleContent(dom.Node node) {
if (node is dom.Text) {
return _normalizeText(node.text).isNotEmpty;
}
return true;
}
static String? _inlineText(List<dom.Node> 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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -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 = '<div><h1>Hello</h1><p>World <strong>!</strong></p></div>';
expect(
HTML.pretty(input),
'<div>\n'
' <h1>Hello</h1>\n'
' <p>\n'
' World\n'
' <strong>!</strong>\n'
' </p>\n'
'</div>',
);
});
test('HTML.pretty tolerates malformed HTML', () {
const input = '<div><span>hello</div>';
expect(
HTML.pretty(input),
'<div>\n'
' <span>hello</span>\n'
'</div>',
);
});
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);
});
}

46
test/xml_format_test.dart Normal file
View File

@@ -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 = '<root><a>1</a><b><c>2</c></b></root>';
expect(
XML.pretty(input),
'<root>\n'
' <a>1</a>\n'
' <b>\n'
' <c>2</c>\n'
' </b>\n'
'</root>',
);
});
test('XML.pretty falls back for malformed XML', () {
const input = '<root><a></root>';
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);
});
}