PyQt+QML 实现解析ANSI Color Code的日志显示器源码分享
- 一、效果展示
- 二、源码分享
- 1、工程结构
- 2、库安装
- 3、main.py
- 4、main.qml
- 三、实现原理
- 1、整体架构
- 2、ANSI 转义序列解析
- 3、HTML 渲染与转义
- 4、换行与滚动处理
- 5、数据流小结
一、效果展示
二、源码分享
1、工程结构
2、库安装
3、main.py
importsysimportosfromPyQt6.QtCoreimportQUrlfromPyQt6.QtGuiimportQGuiApplicationfromPyQt6.QtQmlimportQQmlApplicationEnginedefmain():app=QGuiApplication(sys.argv)engine=QQmlApplicationEngine()qml_path=os.path.join(os.path.dirname(os.path.abspath(__file__)),"main.qml")engine.load(QUrl.fromLocalFile(qml_path))ifnotengine.rootObjects():print("错误:无法加载 QML 文件",file=sys.stderr)sys.exit(-1)sys.exit(app.exec())if__name__=="__main__":main()4、main.qml
importQtQuickimportQtQuick.WindowimportQtQuick.Controls ApplicationWindow{id:root visible:true width:900height:600minimumWidth:360minimumHeight:240title:qsTr("日志显示器")color:"#1e1e1e"propertyvar 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(code>=90&&code<=97)returnBRIGHT_HIGH[code];if(code>=30&&code<=37)returnbold ? BRIGHT[code]:NORMAL[code];returnnull;}function escapeHtml(s){returns.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}function wrapSegment(t,color,bold,underline){var s=escapeHtml(t);if(bold)s="<b>"+s+"</b>";if(underline)s="<u>"+s+"</u>";if(color)s='<font color="'+color+'">'+s+"</font>";returns;}//同时兼容真正的 ESC 字节(0x1B)和字面字符串"\x1B"var re=/(?:\x1b|\\x1[bB])\[([\d;]*)m/g;var parts=[];var colorCode=null,color=null,bold=false,underline=false;var last=0,m;while((m=re.exec(text))!==null){if(m.index>last){parts.push(wrapSegment(text.substring(last,m.index),color,bold,underline));}m[1].split(";").filter(function(s){returns.length>0;}).map(function(s){returnparseInt(s,10);}).forEach(function(c){if(c===0){colorCode=null;color=null;bold=false;underline=false;}elseif(c===1){bold=true;if(colorCode!==null)color=resolveColor(colorCode,bold);}elseif(c===4){underline=true;}elseif((c>=30&&c<=37)||(c>=90&&c<=97)){colorCode=c;color=resolveColor(c,bold);}});last=re.lastIndex;}if(last<text.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:"#9cdcfe"onClicked:{var code=colorCodes[colorIndex%colorCodes.length]var now=new Date()var ts=("0"+(now.getMonth()+1)).slice(-2)+"-"+("0"+now.getDate()).slice(-2)+" "+("0"+now.getHours()).slice(-2)+":"+("0"+now.getMinutes()).slice(-2)+":"+("0"+now.getSeconds()).slice(-2)var newLine="[0m["+code+"m"+ts+" I/test: new log entry\r\r\n[0m"logSource=logSource.concat([newLine])colorIndex++logView.positionViewAtEnd()}}Button{text:qsTr("滚动到底部")flat:true palette.buttonText:"#9cdcfe"onClicked: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:"#d4d4d4"font.family:"Consolas, Courier New, monospace"font.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()会先将其转义为<、>、&,再包裹<font>、<b>、<u>标签实现着色与样式。
4、换行与滚动处理
日志原始文本中的\r\r\n会被ansiToHtml()统一替换为<br>换行标签;ListView的 delegate 中通过modelData.replace(/[\r\n]/g, "")先剔除多余换行,避免重复空行。点击「添加日志」或「滚动到底部」时,调用positionViewAtEnd()并配合 30ms 的Timer确保新日志稳定滚动到可视区域底部。
5、数据流小结
整体流程为:日志数组 → 逐行委托 → ANSI 解析 → HTML 转义 → 富文本渲染,最终在界面上呈现带颜色的日志输出。