PyQt+QML 实现解析ANSI Color Code的日志显示器源码分享

发布时间:2026/10/25 1:42:50
PyQt+QML 实现解析ANSI Color Code的日志显示器源码分享 PyQtQML 实现解析ANSI Color Code的日志显示器源码分享一、效果展示二、源码分享1、工程结构2、库安装3、main.py4、main.qml三、实现原理1、整体架构2、ANSI 转义序列解析3、HTML 渲染与转义4、换行与滚动处理5、数据流小结一、效果展示二、源码分享1、工程结构2、库安装3、main.pyimportsysimportosfromPyQt6.QtCoreimportQUrlfromPyQt6.QtGuiimportQGuiApplicationfromPyQt6.QtQmlimportQQmlApplicationEnginedefmain():appQGuiApplication(sys.argv)engineQQmlApplicationEngine()qml_pathos.path.join(os.path.dirname(os.path.abspath(__file__)),main.qml)engine.load(QUrl.fromLocalFile(qml_path))ifnotengine.rootObjects():print(错误无法加载 QML 文件,filesys.stderr)sys.exit(-1)sys.exit(app.exec())if__name____main__:main()4、main.qmlimportQtQuickimportQtQuick.WindowimportQtQuick.Controls ApplicationWindow{id:root visible:true width:900height:600minimumWidth:360minimumHeight:240title:qsTr(日志显示器)color:#1e1e1epropertyvar logSource:[\u001B[0m\u001B[32m01-01 10:51:53 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[31m01-01 10:51:54 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[33m01-01 10:51:55 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[34m01-01 10:51:56 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[35m01-01 10:51:57 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[36m01-01 10:51:58 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[91m01-01 10:51:59 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[92m01-01 10:52:00 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[32m01-01 10:51:53 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[31m01-01 10:51:54 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[33m01-01 10:51:55 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[34m01-01 10:51:56 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[35m01-01 10:51:57 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[36m01-01 10:51:58 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[91m01-01 10:51:59 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[92m01-01 10:52:00 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[32m01-01 10:51:53 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[31m01-01 10:51:54 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[33m01-01 10:51:55 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[34m01-01 10:51:56 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[35m01-01 10:51:57 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[36m01-01 10:51:58 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[91m01-01 10:51:59 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[92m01-01 10:52:00 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[32m01-01 10:51:53 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[31m01-01 10:51:54 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[33m01-01 10:51:55 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[34m01-01 10:51:56 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[35m01-01 10:51:57 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[36m01-01 10:51:58 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[91m01-01 10:51:59 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[92m01-01 10:52:00 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[32m01-01 10:51:53 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[31m01-01 10:51:54 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[33m01-01 10:51:55 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[34m01-01 10:51:56 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[35m01-01 10:51:57 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[36m01-01 10:51:58 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[91m01-01 10:51:59 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m,\u001B[0m\u001B[92m01-01 10:52:00 I/mavlink_comm: mavlink active!\r\r\n\u001B[0m]propertyintcolorIndex:0propertyvar colorCodes:[32,31,33,34,35,36,91,92]//----------ANSI 解析直接写在 QML 里不再需要 ansi.js----------function ansiToHtml(text){//颜色表普通/粗体升级/高亮90-97 var NORMAL{30:#2e3436,31:#cc0000,32:#4e9a06,33:#c4a000,34:#3465a4,35:#75507b,36:#06989a,37:#d3d7cf};var BRIGHT{30:#555753,31:#ef2929,32:#8ae234,33:#fce94f,34:#729fcf,35:#ad7fa8,36:#34e2e2,37:#eeeeec};var BRIGHT_HIGH{90:#555753,91:#ef2929,92:#8ae234,93:#fce94f,94:#729fcf,95:#ad7fa8,96:#34e2e2,97:#eeeeec};function resolveColor(code,bold){if(code90code97)returnBRIGHT_HIGH[code];if(code30code37)returnbold ? BRIGHT[code]:NORMAL[code];returnnull;}function escapeHtml(s){returns.replace(//g,amp;).replace(//g,lt;).replace(//g,gt;);}function wrapSegment(t,color,bold,underline){var sescapeHtml(t);if(bold)sbs/b;if(underline)sus/u;if(color)sfont colorcolors/font;returns;}//同时兼容真正的 ESC 字节(0x1B)和字面字符串\x1Bvar re/(?:\x1b|\\x1[bB])\[([\d;]*)m/g;var parts[];var colorCodenull,colornull,boldfalse,underlinefalse;var last0,m;while((mre.exec(text))!null){if(m.indexlast){parts.push(wrapSegment(text.substring(last,m.index),color,bold,underline));}m[1].split(;).filter(function(s){returns.length0;}).map(function(s){returnparseInt(s,10);}).forEach(function(c){if(c0){colorCodenull;colornull;boldfalse;underlinefalse;}elseif(c1){boldtrue;if(colorCode!null)colorresolveColor(colorCode,bold);}elseif(c4){underlinetrue;}elseif((c30c37)||(c90c97)){colorCodec;colorresolveColor(c,bold);}});lastre.lastIndex;}if(lasttext.length){parts.push(wrapSegment(text.substring(last),color,bold,underline));}returnparts.join().replace(/\r\n?/g,br);}header:ToolBar{Row{anchors.fill:parent anchors.margins:6spacing:8Label{anchors.verticalCenter:parent.verticalCenter text:qsTr(log.txt · %1 行).arg(logSource.length)font.bold:true color:#e6e6e6}Item{width:8;height:8}Button{text:qsTr(添加日志)flat:true palette.buttonText:#9cdcfeonClicked:{var codecolorCodes[colorIndex%colorCodes.length]var nownew Date()var ts(0(now.getMonth()1)).slice(-2)-(0now.getDate()).slice(-2) (0now.getHours()).slice(-2):(0now.getMinutes()).slice(-2):(0now.getSeconds()).slice(-2)var newLine[0m[codemts I/test: new log entry\r\r\n[0mlogSourcelogSource.concat([newLine])colorIndexlogView.positionViewAtEnd()}}Button{text:qsTr(滚动到底部)flat:true palette.buttonText:#9cdcfeonClicked:logView.positionViewAtEnd()}}}ScrollView{id:scroll anchors.fill:parent anchors.margins:8contentWidth:availableWidth clip:true ScrollBar.vertical.policy:ScrollBar.AsNeeded ScrollBar.horizontal.policy:ScrollBar.AlwaysOff ListView{id:logView width:scroll.availableWidth implicitHeight:contentHeight model:logSource spacing:2Timer{id:scrollTimer interval:30onTriggered:logView.positionViewAtEnd()}delegate:Text{requiredpropertystring modelData width:logView.width textFormat:Text.RichText text:root.ansiToHtml(modelData.replace(/[\r\n]/g,))color:#d4d4d4font.family:Consolas, Courier New, monospacefont.pointSize:10wrapMode:Text.Wrap}}}}三、实现原理1、整体架构整个日志显示器采用PyQt6 QML的前后端分离架构main.py负责创建 Qt 应用与加载 QML 界面main.qml负责界面渲染与 ANSI 颜色解析。二者通过QQmlApplicationEngine桥接QML 中无需任何 C 或 Python 逻辑即可独立完成日志的展示与着色。2、ANSI 转义序列解析ANSI 颜色码以ESC[开头、以m结尾例如\u001B[32m表示将后续文本设为绿色。ansiToHtml()函数通过正则表达式/(?:\x1b|\\x1[bB])\[([\d;]*)m/g同时匹配真正的 ESC 字节0x1B与字面字符串\x1B从而兼容不同来源的日志文本。解析时按;拆分参数并逐项处理0重置所有样式颜色、粗体、下划线全部清空1开启粗体并基于当前颜色码重新解析出亮色4开启下划线30~37/90~97设置前景色其中90~97为高亮色。颜色解析通过resolveColor()完成普通色30~37在粗体时自动升级为亮色表BRIGHT高亮色90~97直接查BRIGHT_HIGH表最终映射为十六进制颜色值。3、HTML 渲染与转义QML 的Text控件开启textFormat: Text.RichText后即可渲染 HTML。为避免日志中的、、等字符破坏 HTML 结构escapeHtml()会先将其转义为lt;、gt;、amp;再包裹font、b、u标签实现着色与样式。4、换行与滚动处理日志原始文本中的\r\r\n会被ansiToHtml()统一替换为br换行标签ListView的 delegate 中通过modelData.replace(/[\r\n]/g, )先剔除多余换行避免重复空行。点击「添加日志」或「滚动到底部」时调用positionViewAtEnd()并配合 30ms 的Timer确保新日志稳定滚动到可视区域底部。5、数据流小结main.py 加载 main.qmllogSource 日志数组ListView delegate 逐行取文本ansiToHtml 解析 ANSI 码转义 生成 HTML 片段Text.RichText 渲染彩色日志整体流程为日志数组 → 逐行委托 → ANSI 解析 → HTML 转义 → 富文本渲染最终在界面上呈现带颜色的日志输出。