
基于paCy模型进行依存句法分析,以json、yaml、xml、csv格式传入jsoncrack以二次可视

基于Python PyQt5开发的依存句法分析树可视化桌面应用。让我为您详细解读这个应用程序:
概述
用于中文/英文文本依存句法分析的可视化工具,能够将句子结构以树状图形式展示,支持多种格式导出和交互式可视化。
架构
核心库依赖
-
PyQt5:GUI框架
-
PyQtWebEngine:内嵌浏览器组件
-
自定义模块:
-
dependency_analyzer:句法分析核心 -
dependency_visualizer:树形可视化 -
data_exporter:数据导出
-
界面结构
左侧控制面板
┌───────────────────────────┐
│ [输入文本区域] │
│ ────────────────────── │
│ [控制按钮] │
│ • 分析句法 │
│ • 清除 │
│ • JSONCrack可视化 │
│ • 复制JSON │
│ ────────────────────── │
│ [导出设置] │
│ • 格式选择(JSON/YAML...)│
│ • 导出结果按钮 │
└───────────────────────────┘
右侧展示区域
-
单一标签页显示依存树可视化结果
-
基于自定义
DependencyTreeVisualizer绘制
详解
1. 依存句法分析流程
def analyze_text(self):
text = self.text_input.toPlainText().strip() # 1.获取输入文本
dependency_data = self.analyzer.analyze(text) # 2.执行句法分析
self.visualizer.draw_tree(dependency_data) # 3.可视化展示
self.current_json_str = json.dumps(dependency_data) # 4.保存JSON
2. 可视化引擎
应用支持两种可视化方案:
A. 本地PyQt绘图
-
使用
DependencyTreeVisualizer在QWidget上绘制 -
适合简单树形展示
-
无需外部依赖
B. 高级D3.js交互式可视化
-
内嵌QWebEngineView
-
通过HTML+D3.js生成可交互树
-
支持:
-
节点拖拽
-
缩放查看
-
悬浮高亮
-
关系标注
-
3. 数据导出功能
支持多种格式导出:
-
JSON:结构化数据
-
YAML:可读性更好
-
XML:标准结构化格式
-
CSV:表格化数据
JSONCrack集成
智能服务启动
def open_jsoncrack_in_browser(self):
# 1. 检查已有服务器(端口3000,3001,3002)
# 2. 如无服务器,自动启动
# 3. 在浏览器中打开
端口管理策略
-
自动检测3000-3002端口占用
-
后台线程启动避免UI阻塞
-
60秒超时保护
启动诊断流程
-
检查Node.js环境
-
检查npm/pnpm可用性
-
验证JSONCrack目录结构
-
启动开发服务器
-
监听服务器状态
代码
"""
依存句法分析树可视化应用
主应用程序入口
"""
import sys
import os
import subprocess
import webbrowser
import json
import tempfile
import urllib.parse
import urllib.request
import urllib.error
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QTextEdit, QPushButton, QComboBox,
QLabel, QFileDialog, QTabWidget, QMessageBox, QSplitter)
from PyQt5.QtCore import Qt, QUrl, QProcess, QTimer
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
from dependency_analyzer import DependencyAnalyzer
from dependency_visualizer import DependencyTreeVisualizer
from data_exporter import DataExporter
class DependencySyntaxApp(QMainWindow):
"""依存句法分析应用主窗口"""
def __init__(self):
super().__init__()
self.analyzer = DependencyAnalyzer()
self.visualizer = DependencyTreeVisualizer()
self.exporter = DataExporter()
self.current_dependency_data = None
self.jsoncrack_process = None
self.jsoncrack_port = 3000
self.init_ui()
# 不在启动时自动启动JSONCrack,避免导致程序崩溃
def init_ui(self):
"""初始化用户界面 - 优化布局"""
self.setWindowTitle("依存句法分析树")
self.setGeometry(100, 100, 1600, 900)
# 主部件 - 左右分割布局
main_widget = QWidget()
main_layout = QHBoxLayout()
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
# 左侧:控制面板
control_panel = self._create_control_panel()
main_layout.addWidget(control_panel)
# 右侧:结果显示区域
result_panel = self._create_result_panel()
main_layout.addWidget(result_panel, 1) # 1 表示占据剩余空间
# 设置比例(左侧固定宽度,右侧自适应)
main_layout.setStretchFactor(control_panel, 0)
main_layout.setStretchFactor(result_panel, 1)
main_widget.setLayout(main_layout)
self.setCentralWidget(main_widget)
# 设置样式
self.setStyleSheet("""
QMainWindow {
background-color: #f8f9fa;
}
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 16px;
font-size: 14px;
border-radius: 4px;
min-width: 100px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:pressed {
background-color: #3d8b40;
}
QPushButton:disabled {
background-color: #cccccc;
}
QTextEdit {
border: 1px solid #ced4da;
border-radius: 4px;
padding: 8px;
font-size: 14px;
background-color: white;
}
QComboBox {
border: 1px solid #ced4da;
border-radius: 4px;
padding: 5px;
background-color: white;
min-width: 120px;
}
QTabWidget::pane {
border: 1px solid #dee2e6;
background: white;
border-radius: 4px;
}
QTabBar::tab {
background: #e9ecef;
padding: 10px 20px;
margin-right: 2px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
QTabBar::tab:selected {
background: white;
border-bottom: 2px solid #4CAF50;
font-weight: bold;
}
QLabel {
font-size: 14px;
color: #495057;
}
QGroupBox {
border: 1px solid #dee2e6;
border-radius: 4px;
margin-top: 10px;
font-weight: bold;
color: #495057;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 5px 10px;
background-color: #e9ecef;
border-radius: 4px;
}
""")
# 样式设置
self.setStyleSheet("""
QMainWindow {
background-color: #f5f5f5;
}
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 16px;
font-size: 14px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:pressed {
background-color: #3d8b40;
}
QTextEdit {
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
font-size: 14px;
}
QComboBox {
border: 1px solid #ddd;
border-radius: 4px;
padding: 5px;
}
QTabWidget::pane {
border: 1px solid #ddd;
background: white;
}
QTabBar::tab {
background: #e0e0e0;
padding: 8px 16px;
margin-right: 2px;
}
QTabBar::tab:selected {
background: white;
border-bottom: 2px solid #4CAF50;
}
""")
def _create_control_panel(self):
"""创建左侧控制面板"""
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(15)
# 输入区域
input_group = self._create_group_box("输入文本")
input_layout = QVBoxLayout()
self.text_input = QTextEdit()
self.text_input.setPlaceholderText("在此输入需要进行依存句法分析的中文或英文文本...")
self.text_input.setMaximumHeight(150)
self.text_input.setMinimumHeight(100)
input_layout.addWidget(self.text_input)
input_group.setLayout(input_layout)
layout.addWidget(input_group)
# 控制按钮
button_group = self._create_group_box("控制")
button_layout = QVBoxLayout()
button_layout.setSpacing(10)
self.analyze_btn = QPushButton("分析句法")
self.analyze_btn.clicked.connect(self.analyze_text)
self.analyze_btn.setMinimumHeight(40)
self.clear_btn = QPushButton("清除")
self.clear_btn.clicked.connect(self.clear_all)
self.clear_btn.setMinimumHeight(40)
# JSONCrack 按钮
jsoncrack_btn_layout = QHBoxLayout()
self.open_jsoncrack_btn = QPushButton("JSONCrack可视化")
self.open_jsoncrack_btn.clicked.connect(self.open_jsoncrack_in_browser)
self.open_jsoncrack_btn.setStyleSheet("""
QPushButton {
background-color: #2196F3;
margin: 0;
}
QPushButton:hover {
background-color: #1976D2;
}
""")
self.copy_json_btn = QPushButton("复制JSON")
self.copy_json_btn.clicked.connect(self.copy_json_data)
self.copy_json_btn.setStyleSheet("""
QPushButton {
background-color: #FF9800;
margin: 0;
}
QPushButton:hover {
background-color: #F57C00;
}
""")
jsoncrack_btn_layout.addWidget(self.open_jsoncrack_btn)
jsoncrack_btn_layout.addWidget(self.copy_json_btn)
button_layout.addWidget(self.analyze_btn)
button_layout.addWidget(self.clear_btn)
button_layout.addLayout(jsoncrack_btn_layout)
button_group.setLayout(button_layout)
layout.addWidget(button_group)
# 导出区域
export_group = self._create_group_box("导出")
export_layout = QVBoxLayout()
export_layout.setSpacing(10)
format_layout = QHBoxLayout()
format_layout.addWidget(QLabel("格式:"))
self.format_combo = QComboBox()
self.format_combo.addItems(["JSON", "YAML", "XML", "CSV"])
format_layout.addWidget(self.format_combo)
self.export_btn = QPushButton("导出结果")
self.export_btn.clicked.connect(self.export_data)
self.export_btn.setMinimumHeight(40)
export_layout.addLayout(format_layout)
export_layout.addWidget(self.export_btn)
export_group.setLayout(export_layout)
layout.addWidget(export_group)
# 添加弹簧使内容靠上
layout.addStretch()
# 设置固定宽度
panel.setFixedWidth(350)
return panel
def _create_result_panel(self):
"""创建右侧结果面板"""
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
# 创建标签页(只保留依存树可视化)
self.tab_widget = QTabWidget()
# 标签1: 依存树可视化
self.tree_tab = QWidget()
tree_layout = QVBoxLayout(self.tree_tab)
tree_layout.setContentsMargins(0, 0, 0, 0)
# 可视化画布
self.tree_canvas = QWidget()
tree_layout.addWidget(self.tree_canvas)
# 初始化可视化器(传入画布)
self.visualizer.draw_tree(None, self.tree_canvas)
self.tab_widget.addTab(self.tree_tab, "依存树可视化")
layout.addWidget(self.tab_widget)
return panel
def _create_group_box(self, title):
"""创建分组框"""
from PyQt5.QtWidgets import QGroupBox
group = QGroupBox(title)
group.setStyleSheet("""
QGroupBox {
border: 1px solid #dee2e6;
border-radius: 4px;
margin-top: 10px;
font-weight: bold;
color: #495057;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 5px 10px;
background-color: #e9ecef;
border-radius: 4px;
}
""")
return group
def setup_jsoncrack(self):
"""设置JSONCrack开发服务器(不使用,避免启动崩溃)"""
# JSONCrack需要手动启动,不在此方法中自动启动
pass
def _read_jsoncrack_output(self):
"""读取JSONCrack的标准输出(不再使用)"""
pass
def _read_jsoncrack_error(self):
"""读取JSONCrack的错误输出(不再使用)"""
pass
def check_jsoncrack_server(self):
"""检查JSONCrack服务器是否可用(不再使用)"""
pass
def analyze_text(self):
"""分析输入文本的依存句法"""
text = self.text_input.toPlainText().strip()
if not text:
QMessageBox.warning(self, "提示", "请输入需要分析的文本")
return
try:
# 执行依存句法分析
dependency_data = self.analyzer.analyze(text)
self.current_dependency_data = dependency_data
# 可视化依存树(使用改进的可视化器)
self.visualizer.draw_tree(dependency_data, self.tree_canvas)
# 保存JSON数据到剪贴板
self.current_json_str = json.dumps(dependency_data, ensure_ascii=False, separators=(',', ':'))
self.analyze_btn.setEnabled(False)
self.analyze_btn.setText("分析完成")
except Exception as e:
QMessageBox.critical(self, "错误", f"分析失败: {str(e)}")
import traceback
traceback.print_exc()
def clear_all(self):
"""清除所有内容"""
self.text_input.clear()
self.visualizer.clear(self.tree_canvas)
self.current_dependency_data = None
self.current_json_str = None
self.analyze_btn.setEnabled(True)
self.analyze_btn.setText("分析句法")
def export_data(self):
"""导出数据"""
if self.current_dependency_data is None:
QMessageBox.warning(self, "警告", "请先进行句法分析")
return
format_type = self.format_combo.currentText()
# 选择保存路径
default_name = f"dependency_tree.{format_type.lower()}"
file_path, _ = QFileDialog.getSaveFileName(
self,
"保存结果",
default_name,
f"{format_type} Files (*.{format_type.lower()});;All Files (*)"
)
if file_path:
try:
self.exporter.export(self.current_dependency_data, file_path, format_type)
QMessageBox.information(self, "成功", f"数据已导出到: {file_path}")
except Exception as e:
QMessageBox.critical(self, "错误", f"导出失败: {str(e)}")
def load_data_in_jsoncrack(self, data):
"""加载数据并显示交互式可视化"""
try:
# 保存当前JSON数据
self.current_json_str = json.dumps(data, ensure_ascii=False, separators=(',', ':'))
# 直接显示D3.js可视化,不依赖外部服务器
self._show_d3_visualization(data)
except Exception as e:
print(f"加载可视化失败: {e}")
import traceback
traceback.print_exc()
# 降级处理:直接显示JSON数据
self._show_fallback_json(data)
def _inject_data_to_jsoncrack(self, json_str):
"""将数据注入到JSONCrack编辑器(已不使用)"""
# JSONCrack版本更新,不再需要此方法
# 用户可以直接复制粘贴JSON数据
pass
def _show_d3_visualization(self, data):
"""显示D3.js交互式可视化"""
json_str = json.dumps(data, ensure_ascii=False, indent=2)
tokens = data.get("tokens", [])
# 构建扁平化的节点和连接列表
nodes_data = []
edges_data = []
if tokens:
# 使用tokens数据构建更准确的图
for token in tokens:
nodes_data.append({
"id": token.get("id", 0),
"text": token.get("text", ""),
"pos": token.get("pos", ""),
"dep": token.get("dep", ""),
"head": token.get("head", 0)
})
# 构建边连接
for token in tokens:
source_id = token.get("id", 0)
target_id = token.get("head", 0)
if source_id != target_id: # 不是根节点
edges_data.append({
"source": target_id,
"target": source_id,
"relation": token.get("dep", "")
})
# 生成JSON字符串
nodes_json = json.dumps(nodes_data, ensure_ascii=False)
edges_json = json.dumps(edges_data, ensure_ascii=False)
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>依存句法树可视化</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
body {{
margin: 0;
padding: 20px;
font-family: Arial, sans-serif;
background-color: #f5f5f5;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}}
h1 {{
color: #333;
border-bottom: 2px solid #4CAF50;
padding-bottom: 10px;
margin-top: 0;
}}
#tree {{
border: 1px solid #ddd;
border-radius: 4px;
overflow-x: auto;
min-height: 400px;
background: #fafafa;
}}
.node circle {{
fill: #4CAF50;
stroke: #333;
stroke-width: 2px;
cursor: pointer;
}}
.node circle:hover {{
fill: #66BB6A;
stroke-width: 3px;
}}
.node text {{
font-size: 13px;
font-family: Arial, sans-serif;
text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
pointer-events: none;
}}
.link {{
fill: none;
stroke: #666;
stroke-width: 2px;
stroke-opacity: 0.7;
}}
.link-label {{
font-size: 11px;
fill: #666;
text-anchor: middle;
background: rgba(255,255,255,0.8);
padding: 2px 4px;
border-radius: 4px;
}}
.node-info {{
font-size: 10px;
fill: #888;
}}
.json-section {{
margin-top: 30px;
}}
textarea {{
width: 100%;
height: 200px;
padding: 15px;
border: 2px solid #ddd;
border-radius: 8px;
font-family: monospace;
font-size: 13px;
box-sizing: border-box;
}}
.copy-btn {{
margin-top: 10px;
padding: 10px 30px;
background: #4CAF50;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
}}
.copy-btn:hover {{
background: #45a049;
}}
.info-box {{
background: #e8f5e9;
border-left: 4px solid #4CAF50;
padding: 15px;
margin: 20px 0;
line-height: 1.6;
}}
.legend {{
display: flex;
gap: 20px;
margin: 15px 0;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
}}
.legend-item {{
display: flex;
align-items: center;
gap: 5px;
}}
.legend-color {{
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #333;
}}
</style>
</head>
<body>
<div class="container">
<h1>依存句法树可视化</h1>
<div class="info-box">
<strong>可视化说明:</strong>
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: #4CAF50;"></div>
<span>绿色节点: 词语</span>
</div>
<div class="legend-item">
<span>灰色连线: 依存关系</span>
</div>
</div>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>上方文字: 词语本身</li>
<li>下方小字: 词性标注(POS)</li>
<li>连线标签: 依存关系类型</li>
<li>支持鼠标拖拽、滚轮缩放</li>
</ul>
</div>
<svg id="tree"></svg>
<div class="json-section">
<h2>原始JSON数据</h2>
<textarea id="json-output" readonly>{json_str}</textarea>
<br>
<button class="copy-btn" onclick="copyJSON()">复制JSON数据</button>
</div>
</div>
<script>
const nodesData = {nodes_json};
const edgesData = {edges_json};
// 创建SVG容器
const svg = d3.select("#tree");
const containerWidth = 1360;
const containerHeight = Math.max(500, nodesData.length * 50 + 100); // 动态高度
svg.attr("width", containerWidth)
.attr("height", containerHeight);
// 创建缩放行为
const zoom = d3.zoom()
.scaleExtent([0.3, 4])
.on("zoom", (event) => {{
g.attr("transform", event.transform);
}});
svg.call(zoom);
const g = svg.append("g");
// 创建力导向图布局
const simulation = d3.forceSimulation(nodesData)
.force("link", d3.forceLink(edgesData)
.id(d => d.id)
.distance(120) // 连接距离
.strength(0.5))
.force("charge", d3.forceManyBody()
.strength(-300)) // 排斥力
.force("center", d3.forceCenter(containerWidth / 2, containerHeight / 2))
.force("collision", d3.forceCollide().radius(40)); // 碰撞检测
// 绘制连线
const link = g.append("g")
.selectAll("path")
.data(edgesData)
.enter().append("path")
.attr("class", "link");
// 绘制连线标签(依存关系)
const linkLabel = g.append("g")
.selectAll("text")
.data(edgesData)
.enter().append("text")
.attr("class", "link-label")
.text(d => d.relation || "");
// 绘制节点
const node = g.append("g")
.selectAll("g")
.data(nodesData)
.enter().append("g")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
node.append("circle")
.attr("r", 20)
.attr("fill", "#4CAF50")
.attr("stroke", "#333")
.attr("stroke-width", 2);
// 节点文字(词语)
node.append("text")
.attr("dy", "-5")
.attr("text-anchor", "middle")
.style("font-weight", "bold")
.text(d => d.text || "");
// 节点信息(词性)
node.append("text")
.attr("dy", "10")
.attr("text-anchor", "middle")
.attr("class", "node-info")
.text(d => d.pos || "");
// 更新位置
simulation.on("tick", () => {{
link.attr("d", d => {{
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
return `M${{d.source.x}},${{d.source.y}}A${{dr}},${{dr}} 0 0,1 ${{d.target.x}},${{d.target.y}}`;
}});
linkLabel
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2 - 5);
node.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
}});
function dragstarted(event, d) {{
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}}
function dragged(event, d) {{
d.fx = event.x;
d.fy = event.y;
}}
function dragended(event, d) {{
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}}
function copyJSON() {{
const textarea = document.getElementById('json-output');
textarea.select();
document.execCommand('copy');
alert('JSON数据已复制到剪贴板!');
}}
</script>
</body>
</html>
"""
self.web_view.setHtml(html_content)
def _show_fallback_json(self, data):
"""降级显示:当可视化失败时直接显示JSON数据"""
json_str = json.dumps(data, ensure_ascii=False, indent=2)
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>JSON数据</title>
<style>
body {{ margin: 20px; font-family: monospace; background: #f5f5f5; }}
.container {{ background: white; padding: 20px; border-radius: 8px; }}
pre {{ white-space: pre-wrap; word-wrap: break-word; }}
</style>
</head>
<body>
<div class="container">
<h2>JSON数据</h2>
<pre>{json_str}</pre>
</div>
</body>
</html>"""
self.web_view.setHtml(html_content)
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>依存句法树可视化</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
body {{
margin: 0;
padding: 20px;
font-family: Arial, sans-serif;
background-color: #f5f5f5;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}}
h1 {{
color: #333;
border-bottom: 2px solid #4CAF50;
padding-bottom: 10px;
margin-top: 0;
}}
#tree {{
border: 1px solid #ddd;
border-radius: 4px;
overflow-x: auto;
min-height: 400px;
}}
.node circle {{
fill: #4CAF50;
stroke: #333;
stroke-width: 2px;
}}
.node text {{
font-size: 12px;
font-family: Arial, sans-serif;
}}
.node text {{
text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
}}
.link {{
fill: none;
stroke: #999;
stroke-width: 2px;
}}
.node-info {{
font-size: 10px;
fill: #666;
}}
.json-section {{
margin-top: 30px;
}}
textarea {{
width: 100%;
height: 200px;
padding: 15px;
border: 2px solid #ddd;
border-radius: 8px;
font-family: monospace;
font-size: 13px;
box-sizing: border-box;
}}
.copy-btn {{
margin-top: 10px;
padding: 10px 30px;
background: #4CAF50;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
}}
.copy-btn:hover {{
background: #45a049;
}}
.info-box {{
background: #e8f5e9;
border-left: 4px solid #4CAF50;
padding: 15px;
margin: 20px 0;
}}
</style>
</head>
<body>
<div class="container">
<h1>依存句法树可视化</h1>
<div class="info-box">
<strong>说明:</strong>
<ul>
<li>下方显示的是依存句法分析的可视化树形结构</li>
<li>绿色节点表示词语,连接线表示依存关系</li>
<li>节点上方显示词性,下方显示依存关系类型</li>
</ul>
</div>
<svg id="tree"></svg>
<div class="json-section">
<h2>JSON数据</h2>
<textarea id="json-output" readonly>{json_str}</textarea>
<br>
<button class="copy-btn" onclick="copyJSON()">复制JSON数据</button>
</div>
</div>
<script>
const nodesData = {nodes_json};
const edgesData = {edges_json};
// 创建树形布局
const svg = d3.select("#tree");
const containerWidth = 1360;
const containerHeight = 400;
svg.attr("width", containerWidth)
.attr("height", containerHeight);
// 创建缩放行为
const zoom = d3.zoom()
.scaleExtent([0.1, 3])
.on("zoom", (event) => {{
g.attr("transform", event.transform);
}});
svg.call(zoom);
const g = svg.append("g")
.attr("transform", "translate(50,50)");
// 使用层级数据
const root = d3.hierarchy(nodesData[0], d => {{
const children = [];
edgesData.filter(e => e.source === d.data.id).forEach(e => {{
const child = nodesData.find(n => n.id === e.target);
if (child) {{
children.push(child);
}}
}});
return children.length > 0 ? children : null;
}});
// 创建树布局
const treeLayout = d3.tree()
.size([containerHeight - 100, containerWidth - 100]);
treeLayout(root);
// 绘制连线
g.selectAll(".link")
.data(root.links())
.enter()
.append("path")
.attr("class", "link")
.attr("d", d3.linkHorizontal()
.x(d => d.y)
.y(d => d.x));
// 绘制节点
const node = g.selectAll(".node")
.data(root.descendants())
.enter()
.append("g")
.attr("class", "node")
.attr("transform", d => `translate(${{d.y}},${{d.x}})`);
node.append("circle")
.attr("r", 8);
node.append("text")
.attr("dy", "-15")
.attr("text-anchor", "middle")
.text(d => d.data.text);
node.append("text")
.attr("dy", "-3")
.attr("text-anchor", "middle")
.attr("class", "node-info")
.text(d => d.data.pos);
node.append("text")
.attr("dy", "12")
.attr("text-anchor", "middle")
.attr("class", "node-info")
.text(d => d.data.dep);
function copyJSON() {{
const textarea = document.getElementById('json-output');
textarea.select();
document.execCommand('copy');
alert('JSON数据已复制到剪贴板!');
}}
</script>
</body>
</html>
"""
self.web_view.setHtml(html_content)
def open_jsoncrack_in_browser(self):
"""智能打开JSONCrack - 自动启动服务器或使用已运行的服务器"""
jsoncrack_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "jsoncrack.com-main")
url = f"http://localhost:{self.jsoncrack_port}/"
# 检查端口3000、3001、3002是否已在运行
for port in [3000, 3001, 3002]:
test_url = f"http://localhost:{port}/"
try:
urllib.request.urlopen(test_url, timeout=2)
# 服务器正在运行,直接打开
webbrowser.open(test_url)
self.statusBar().showMessage(f"已打开JSONCrack (端口{port})", 3000)
return
except:
pass
# 服务器未运行,后台启动服务器(不阻塞UI)
self.statusBar().showMessage("正在启动JSONCrack服务器,请稍候...", 5000)
# 启动后台线程启动服务器
from PyQt5.QtCore import QThread, pyqtSignal
class QuickStartThread(QThread):
log = pyqtSignal(str)
def run(self):
try:
# 检测Node.js
try:
node_result = subprocess.run(["node", "--version"], capture_output=True, text=True, timeout=5)
self.log.emit(f"Node.js版本: {node_result.stdout.strip()}")
except FileNotFoundError:
self.log.emit("错误: 未找到Node.js")
return
# 检测pnpm - 使用cmd /c检测
pnpm_cmd = "pnpm"
try:
result = subprocess.run(f'cmd /c "{pnpm_cmd} --version"', shell=True, capture_output=True, text=True, timeout=5)
if result.returncode == 0:
self.log.emit(f"pnpm命令: {pnpm_cmd} (版本: {result.stdout.strip()})")
else:
self.log.emit(f"错误: pnpm返回码 {result.returncode}, 错误: {result.stderr}")
return
except Exception as e:
self.log.emit(f"错误: 检测pnpm失败: {str(e)}")
return
# 检查目录
if not os.path.exists(jsoncrack_dir):
self.log.emit(f"错误: 目录不存在 {jsoncrack_dir}")
return
# 启动服务器
self.log.emit(f"启动命令: {pnpm_cmd} dev")
self.log.emit(f"工作目录: {jsoncrack_dir}")
if os.name == 'nt': # Windows
# 使用start命令在新进程中启动,避免中文路径问题
cmd = f'start /B /D "{jsoncrack_dir}" {pnpm_cmd} dev'
self.log.emit(f"完整命令: {cmd}")
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
shell=True
)
else: # Linux/Mac
process = subprocess.Popen(
[pnpm_cmd, 'dev'],
cwd=jsoncrack_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# 等待一小段时间检查进程状态
import time
time.sleep(2)
if process.poll() is not None:
# 进程已退出,读取错误信息
stdout, stderr = process.communicate()
self.log.emit(f"错误: 服务器进程立即退出")
if stdout:
self.log.emit(f"标准输出: {stdout[:500]}")
if stderr:
self.log.emit(f"错误输出: {stderr[:500]}")
return
else:
# 进程在运行,读取当前输出
try:
if process.stderr:
stderr = process.stderr.read1(500)
if stderr:
self.log.emit(f"当前输出: {stderr}")
except:
pass
self.log.emit(f"服务器进程已启动,PID: {process.pid}")
self.log.emit("服务器正在后台启动中...")
except Exception as e:
import traceback
self.log.emit(f"启动失败: {str(e)}")
self.log.emit(traceback.format_exc())
# 创建并启动线程
self.start_thread = QuickStartThread()
self.start_thread.log.connect(lambda msg: print(f"[JSONCrack] {msg}"))
self.start_thread.finished.connect(lambda: self._check_and_open_browser(url))
self.start_thread.start()
self.statusBar().showMessage("JSONCrack服务器正在启动中,请稍候...", 5000)
def _check_and_open_browser(self, url):
"""检查服务器状态并打开浏览器"""
import time
self.statusBar().showMessage("等待JSONCrack服务器启动...", 5000)
# 等待最多60秒让服务器启动,检查多个端口
for i in range(60):
# 尝试端口3000、3001、3002
for port in [3000, 3001, 3002]:
test_url = f"http://localhost:{port}/"
try:
urllib.request.urlopen(test_url, timeout=2)
# 服务器就绪
webbrowser.open(test_url)
self.statusBar().showMessage(f"JSONCrack已启动,端口{port}", 3000)
return
except:
pass
if i % 10 == 0 and i > 0: # 每10秒更新一次进度
self.statusBar().showMessage(f"等待服务器启动... ({i}/60秒)", 5000)
time.sleep(1)
# 超时,仍然打开浏览器(服务器可能已经在运行但响应慢)
webbrowser.open(f"http://localhost:{self.jsoncrack_port}/")
self.statusBar().showMessage("已打开浏览器,如果页面未加载请稍候刷新", 5000)
def _find_available_port(self, start_port=3000, max_attempts=10):
"""查找可用端口"""
import socket
for attempt in range(max_attempts):
port = start_port + attempt
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
result = sock.connect_ex(('localhost', port))
sock.close()
if result != 0: # 端口未被占用
return port
except:
pass
return start_port # 如果都不可用,返回起始端口
def _detect_npm_command(self):
"""检测npm命令路径"""
if os.name == 'nt':
# Windows: 检查常见路径
possible_paths = [
r"C:\Program Files\nodejs\npm.cmd",
r"C:\Program Files (x86)\nodejs\npm.cmd",
"npm.cmd",
"npm"
]
for path in possible_paths:
try:
result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
return path
except:
continue
else:
# Linux/Mac
try:
result = subprocess.run(["npm", "--version"], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
return "npm"
except:
pass
return None
def _start_jsoncrack_server_silent(self, jsoncrack_dir, base_url):
"""静默启动JSONCrack服务器(后台线程,不阻塞UI)"""
# 查找可用端口
port = self._find_available_port(self.jsoncrack_port)
if port != self.jsoncrack_port:
self.statusBar().showMessage(f"端口{self.jsoncrack_port}被占用,切换到端口{port}", 5000)
jsoncrack_url = f"http://localhost:{port}/"
# 显示进度提示(不阻塞,可后台运行)
self.statusBar().showMessage("正在启动JSONCrack服务器...", 5000)
# 在后台线程启动服务器
from PyQt5.QtCore import QThread, pyqtSignal
class ServerThread(QThread):
started = pyqtSignal(str) # 传递实际使用的URL
failed = pyqtSignal(str)
log = pyqtSignal(str)
def _detect_npm_in_thread(self):
"""在线程内检测npm命令"""
if os.name == 'nt':
# Windows: 检查常见路径
possible_paths = [
r"C:\Program Files\nodejs\npm.cmd",
r"C:\Program Files (x86)\nodejs\npm.cmd",
"npm.cmd",
"npm"
]
for path in possible_paths:
try:
result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
return path
except:
continue
else:
# Linux/Mac
try:
result = subprocess.run(["npm", "--version"], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
return "npm"
except:
pass
return None
def run(self):
try:
# 诊断Node.js环境
try:
node_result = subprocess.run(["node", "--version"], capture_output=True, text=True, timeout=5)
if node_result.returncode != 0:
self.failed.emit(f"Node.js命令失败: {node_result.stderr}")
return
self.log.emit(f"Node.js版本: {node_result.stdout.strip()}")
except FileNotFoundError:
self.failed.emit("Node.js未安装或未添加到系统PATH")
return
# 检测npm命令 - 使用完整路径或shell
npm_cmd = self._detect_npm_in_thread()
use_shell = False
if not npm_cmd:
# 尝试使用shell模式(让系统自己找npm)
self.log.emit("npm命令未在标准路径找到,尝试使用shell模式")
npm_cmd = "npm"
use_shell = True
try:
if use_shell:
npm_result = subprocess.run(f"{npm_cmd} --version", shell=True, capture_output=True, text=True, timeout=5)
else:
npm_result = subprocess.run([npm_cmd, "--version"], capture_output=True, text=True, timeout=5)
if npm_result.returncode == 0:
self.log.emit(f"npm版本: {npm_result.stdout.strip()}")
else:
self.failed.emit(f"npm命令执行失败: {npm_result.stderr}")
return
except Exception as e:
self.failed.emit(f"检查npm版本失败: {str(e)}")
return
# 检查JSONCrack目录
if not os.path.exists(jsoncrack_dir):
self.failed.emit(f"JSONCrack目录不存在: {jsoncrack_dir}")
return
package_json = os.path.join(jsoncrack_dir, "package.json")
if not os.path.exists(package_json):
self.failed.emit(f"package.json不存在: {package_json}")
return
# 检查node_modules
node_modules = os.path.join(jsoncrack_dir, "node_modules")
if not os.path.exists(node_modules):
self.failed.emit("node_modules不存在,请先运行: npm install\n\n在CMD中执行:\ncd \"{}\"\nnpm install".format(jsoncrack_dir))
return
self.log.emit(f"使用npm命令: {npm_cmd}")
self.log.emit(f"启动参数: {npm_cmd} run dev")
self.log.emit(f"工作目录: {jsoncrack_dir}")
self.log.emit(f"目标URL: {jsoncrack_url}")
# 启动服务器进程 - 最终修复方案
self.log.emit("准备启动服务器...")
self.log.emit(f"目标目录: {jsoncrack_dir}")
self.log.emit(f"npm命令: {npm_cmd}")
self.log.emit(f"目标URL: {jsoncrack_url}")
try:
self.log.emit("启动进程...")
# 关键修复:使用cmd /c启动,并设置工作目录
# 测试证明:cmd /c "npm run dev" 可以正常工作
if os.name == 'nt': # Windows
# 使用cmd /c执行单条命令
# 不使用完整路径(避免空格问题),依靠PATH
cmd = 'npm run dev'
self.log.emit(f"执行命令: cmd /c \"{cmd}\"")
self.log.emit(f"工作目录: {jsoncrack_dir}")
# 使用cmd.exe /c,并设置cwd
process = subprocess.Popen(
f'cmd /c "{cmd}"',
cwd=jsoncrack_dir, # 关键:通过cwd设置工作目录
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
shell=True # 使用cmd shell
)
else: # Linux/Mac
self.log.emit(f"执行: npm run dev")
process = subprocess.Popen(
['npm', 'run', 'dev'],
cwd=jsoncrack_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
self.log.emit(f"进程已启动,PID: {process.pid}")
except Exception as e:
self.failed.emit(f"启动进程失败: {str(e)}")
return
# 等待服务器启动(最多60秒,Next.js首次编译可能很慢)
self.log.emit("等待服务器启动(最多60秒)...")
for i in range(120): # 120次,每次0.5秒 = 60秒
# 检查进程是否已退出(立即检测)
if process.poll() is not None:
# 进程已退出 - 立即捕获完整输出
stdout, stderr = process.communicate(timeout=1)
self.log.emit("=" * 50)
self.log.emit("服务器进程已退出")
self.log.emit(f"返回码: {process.returncode}")
self.log.emit(f"标准输出: {stdout[:800] if stdout else '(无)'}")
self.log.emit(f"错误输出: {stderr[:800] if stderr else '(无)'}")
self.log.emit("=" * 50)
error_msg = stderr or stdout or "进程意外退出,无输出"
self.failed.emit(f"服务器启动失败: {error_msg[:400]}")
return
try:
urllib.request.urlopen(jsoncrack_url, timeout=3)
self.log.emit("服务器已成功启动")
self.started.emit(jsoncrack_url)
return
except urllib.error.URLError as e:
# 检查是否有特定错误
if "WinError 10061" in str(e): # 连接被拒绝
pass # 服务器还没启动
elif "WinError 10060" in str(e): # 连接超时
pass # 服务器还没启动
except Exception as e:
pass
if i % 10 == 0: # 每5秒打印一次进度
self.log.emit(f"等待服务器响应... ({i*0.5}/60秒)")
self.msleep(500)
# 超时
process.terminate()
self.failed.emit("启动超时(20秒),请检查:\n1. 端口是否被占用\n2. node_modules是否完整\n3. 查看控制台日志")
except Exception as e:
import traceback
error_detail = traceback.format_exc()
self.failed.emit(f"启动异常: {str(e)}\n\n详细错误:\n{error_detail[-400:]}")
# 创建并启动后台线程
self.server_thread = ServerThread()
self.server_thread.started.connect(self._on_server_started)
self.server_thread.failed.connect(self._on_server_failed)
self.server_thread.log.connect(lambda msg: print(f"[JSONCrack] {msg}"))
self.server_thread.start()
def _on_server_started(self, jsoncrack_url):
"""服务器启动成功"""
self.statusBar().showMessage(f"JSONCrack服务器已启动 ({jsoncrack_url})", 3000)
webbrowser.open(jsoncrack_url)
def _on_server_failed(self, error):
"""服务器启动失败"""
self.statusBar().showMessage("启动JSONCrack服务器失败", 5000)
# 提供详细的错误信息和解决方案
detailed_error = f"""
<b>启动失败详情:</b><br>
<font color="red">{error}</font><br><br>
<b>请按以下步骤解决:</b><br><br>
<b>1. 检查Node.js安装:</b><br>
• 访问 https://nodejs.org/ 下载并安装LTS版本<br>
• 安装时务必勾选 "Add to PATH"<br>
• 安装后重启电脑<br><br>
<b>2. 手动测试启动:</b><br>
• 打开CMD或PowerShell<br>
• 执行: <code>cd "d:\daku\依存句法\jsoncrack.com-main"</code><br>
• 执行: <code>npm run dev</code><br>
• 观察输出是否有错误<br><br>
<b>3. 检查依赖:</b><br>
• 如果提示缺少模块,运行: <code>npm install</code><br>
• 等待安装完成后再试<br><br>
<b>4. 检查端口占用:</b><br>
• 确保端口 {self.jsoncrack_port} 未被占用<br>
• 可尝试修改package.json中的端口配置
"""
msg_box = QMessageBox(self)
msg_box.setWindowTitle("JSONCrack启动失败")
msg_box.setText("无法自动启动JSONCrack服务器")
msg_box.setInformativeText(detailed_error)
msg_box.setIcon(QMessageBox.Warning)
# 添加调试按钮
debug_btn = msg_box.addButton("运行诊断工具", QMessageBox.ActionRole)
ok_btn = msg_box.addButton("确定", QMessageBox.AcceptRole)
msg_box.exec_()
# 如果点击调试按钮,运行诊断脚本
if msg_box.clickedButton() == debug_btn:
self._run_diagnostic_tool()
def _run_diagnostic_tool(self):
"""运行诊断工具"""
try:
# 运行诊断脚本
result = subprocess.run(
[sys.executable, "diagnose_jsoncrack.py"],
capture_output=True,
text=True,
cwd=r"d:\daku\依存句法"
)
# 显示诊断结果
diag_msg = QMessageBox(self)
diag_msg.setWindowTitle("JSONCrack诊断结果")
if result.returncode == 0:
diag_msg.setIcon(QMessageBox.Information)
diag_msg.setText("诊断完成,请查看详细信息")
diag_msg.setDetailedText(result.stdout)
else:
diag_msg.setIcon(QMessageBox.Critical)
diag_msg.setText("诊断工具执行失败")
diag_msg.setDetailedText(result.stderr or result.stdout)
diag_msg.exec_()
except Exception as e:
QMessageBox.critical(self, "诊断工具错误", f"无法运行诊断工具:\n{str(e)}")
def copy_json_data(self):
"""复制JSON数据到剪贴板"""
if not hasattr(self, 'current_json_str') or not self.current_json_str:
QMessageBox.warning(self, "警告", "请先进行句法分析")
return
from PyQt5.QtWidgets import QApplication
clipboard = QApplication.clipboard()
clipboard.setText(self.current_json_str)
QMessageBox.information(self, "成功", "JSON数据已复制到剪贴板!\n\n您现在可以粘贴到JSONCrack或其他JSON工具中。")
def _start_jsoncrack_server(self, jsoncrack_dir, jsoncrack_url):
"""快速诊断并解决JSONCrack启动问题(增强版)"""
import traceback
try:
# 第一步:诊断Node.js环境
try:
node_check = subprocess.run(["node", "--version"], capture_output=True, text=True)
if node_check.returncode != 0:
QMessageBox.critical(self, "错误",
"Node.js未安装!\n\n"
"JSONCrack需要Node.js环境。\n\n"
"解决方案:\n"
"1. 访问 https://nodejs.org/\n"
"2. 下载并安装LTS版本\n"
"3. 重启电脑\n"
"4. 重新运行本程序"
)
return
try:
npm_check = subprocess.run(["npm", "--version"], capture_output=True, text=True)
if npm_check.returncode != 0:
QMessageBox.critical(self, "错误", "npm命令执行失败!\n\n请重新安装Node.js。")
return
except FileNotFoundError:
QMessageBox.critical(self, "npm未找到",
"npm命令未找到!\n\n"
"即使Node.js已安装,npm可能不在系统PATH中。\n\n"
"解决方案:\n"
"1. 找到Node.js安装目录(通常在 C:\\Program Files\\nodejs\\)\n"
"2. 将该目录添加到系统环境变量PATH中\n"
"3. 重启电脑后重试\n\n"
"或者手动启动JSONCrack:\n"
f"cd {jsoncrack_dir}\n"
"npm run dev"
)
return
except Exception as e:
QMessageBox.critical(self, "检查npm失败", f"检查npm时出错:\n{type(e).__name__}: {str(e)}")
return
print(f"Node.js版本: {node_check.stdout.strip()}")
print(f"npm版本: {npm_check.stdout.strip()}")
except FileNotFoundError as e:
QMessageBox.critical(self, "命令未找到",
f"系统找不到命令: {e.filename}\n\n"
"Node.js可能未安装或未添加到系统PATH。\n\n"
"解决方案:\n"
"1. 访问 https://nodejs.org/\n"
"2. 下载并安装LTS版本\n"
"3. 安装时勾选 'Add to PATH'\n"
"4. 重启电脑后重试"
)
return
except Exception as e:
QMessageBox.critical(self, "环境检查失败", f"检查Node.js环境时出错:\n{str(e)}\n\n{traceback.format_exc()}")
return
# 第二步:检查JSONCrack目录
print(f"检查JSONCrack目录: {jsoncrack_dir}")
if not os.path.exists(jsoncrack_dir):
QMessageBox.critical(self, "目录不存在",
f"JSONCrack目录不存在:\n\n{jsoncrack_dir}\n\n"
"请确认jsoncrack.com-main文件夹是否存在。\n"
"如果不存在,请从GitHub下载。"
)
return
# 检查关键文件
package_json = os.path.join(jsoncrack_dir, "package.json")
if not os.path.exists(package_json):
QMessageBox.critical(self, "文件缺失",
f"在 {jsoncrack_dir} 中找不到 package.json\n\n"
"这不是一个有效的JSONCrack项目目录。\n"
"请重新下载jsoncrack.com-main"
)
return
# 检查node_modules
node_modules_dir = os.path.join(jsoncrack_dir, "node_modules")
print(f"node_modules目录存在: {os.path.exists(node_modules_dir)}")
if not os.path.exists(node_modules_dir):
print("node_modules不存在,需要安装依赖")
print("JSONCrack目录和文件检查通过")
# 第二步:检查并安装依赖
node_modules_dir = os.path.join(jsoncrack_dir, "node_modules")
if not os.path.exists(node_modules_dir):
reply = QMessageBox.question(
self,
"安装依赖",
"检测到JSONCrack依赖未安装。\n\n"
"需要运行 'npm install' 安装依赖(约需1-3分钟)。\n\n"
"是否立即安装?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
install_box = QMessageBox(self)
install_box.setWindowTitle("正在安装依赖")
install_box.setText("正在安装npm依赖,请稍候...\n\n这可能需要1-3分钟")
install_box.setStandardButtons(QMessageBox.NoButton)
install_box.show()
QApplication.processEvents()
# 安装依赖
result = subprocess.run(
["npm", "install"],
cwd=jsoncrack_dir,
capture_output=True,
text=True,
timeout=180 # 3分钟超时
)
install_box.close()
if result.returncode != 0:
QMessageBox.critical(self, "安装失败",
f"npm install 失败!\n\n"
f"错误信息:\n{result.stderr[:500]}"
)
return
else:
return
# 第三步:启动服务器
QMessageBox.information(self, "启动服务器",
"JSONCrack依赖已就绪,正在启动服务器...\n\n"
"预计等待: 5-10秒"
)
self.jsoncrack_process = QProcess(self)
self.jsoncrack_process.setWorkingDirectory(jsoncrack_dir)
if os.name == 'nt':
self.jsoncrack_process.start("cmd", ["/c", "npm run dev"])
else:
self.jsoncrack_process.start("npm", ["run", "dev"])
# 第四步:快速检测(10秒超时)
for i in range(20): # 20次,每次0.5秒 = 10秒
QApplication.processEvents()
try:
urllib.request.urlopen(jsoncrack_url, timeout=1)
webbrowser.open(jsoncrack_url)
QMessageBox.information(self, "成功",
f"JSONCrack已启动!\n\n地址: {jsoncrack_url}"
)
return
except:
pass
import time
time.sleep(0.5)
# 超时,显示手动启动命令
if self.jsoncrack_process:
self.jsoncrack_process.terminate()
QMessageBox.warning(self, "启动失败",
f"JSONCrack服务器启动超时。\n\n"
"请手动启动:\n\n"
f"1. 打开CMD\n"
f"2. cd {jsoncrack_dir}\n"
f"3. npm run dev\n\n"
f"然后访问: {jsoncrack_url}"
)
except subprocess.TimeoutExpired:
QMessageBox.critical(self, "超时", "安装依赖超时(3分钟)!")
except Exception as e:
QMessageBox.critical(self, "错误", f"启动失败:\n{str(e)}")
def closeEvent(self, event):
"""关闭窗口时清理资源"""
# 终止JSONCrack进程(如果存在)
if self.jsoncrack_process and self.jsoncrack_process.state() == QProcess.Running:
self.jsoncrack_process.terminate()
self.jsoncrack_process.waitForFinished(3000)
event.accept()
def main():
"""主函数"""
app = QApplication(sys.argv)
app.setStyle('Fusion')
window = DependencySyntaxApp()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
dependency_analyzer.py
"""
依存句法分析器模块 - spaCy最优版本
仅使用spaCy专业模型,提供最准确的分析结果
"""
import json
# 导入spaCy
try:
import spacy
except ImportError:
raise ImportError("spaCy未安装。请运行: pip install spacy>=3.5.0")
class DependencyAnalyzer:
"""依存句法分析器类 - 仅使用spaCy"""
def __init__(self, language='zh', model=None):
"""
初始化分析器
Args:
language: 语言,'zh'为中文,'en'为英文
model: 指定spaCy模型名称(可选)
"""
self.language = language
self.nlp = None
self.model_name = None
# 加载模型
self._load_model(model)
def _load_model(self, specified_model=None):
"""加载spaCy模型"""
# 确定要加载的模型
if specified_model:
models_to_try = [specified_model]
else:
if self.language == 'zh':
models_to_try = ['zh_core_web_trf', 'zh_core_web_lg', 'zh_core_web_md', 'zh_core_web_sm']
else:
models_to_try = ['en_core_web_trf', 'en_core_web_lg', 'en_core_web_md', 'en_core_web_sm']
# 尝试加载模型
for model in models_to_try:
try:
self.nlp = spacy.load(model)
self.model_name = model
print(f"成功加载spaCy模型: {model}")
return
except OSError:
continue
# 所有模型都失败
raise RuntimeError(f"\n未找到可用的spaCy模型。\n请先安装: python -m spacy download {models_to_try[-1]}")
def analyze(self, text):
"""
使用spaCy分析文本的依存句法结构
Args:
text: 待分析的文本
Returns:
dict: 包含依存句法信息的字典
"""
if not self.nlp:
raise RuntimeError("spaCy模型未加载")
return self._analyze_with_spacy(text)
def analyze(self, text):
"""
使用spaCy分析文本的依存句法结构
Args:
text: 待分析的文本
Returns:
dict: 包含依存句法信息的字典
"""
if not self.nlp:
raise RuntimeError("spaCy模型未加载")
return self._analyze_with_spacy(text)
def _analyze_with_spacy(self, text):
"""使用spaCy进行分析"""
# 处理文本
doc = self.nlp(text)
# 构建依存句法数据
data = {
"text": text,
"language": self.language,
"model": self.model_name,
"tokens": [],
"dependencies": []
}
# 提取token信息
for i, token in enumerate(doc):
token_data = {
"id": i,
"text": token.text,
"lemma": token.lemma_,
"pos": token.pos_,
"tag": token.tag_,
"dep": token.dep_,
"head": token.head.i,
"children": [child.i for child in token.children]
}
data["tokens"].append(token_data)
# 构建依存关系
for i, token in enumerate(doc):
if token.head != token: # 不是根节点
dep_data = {
"source": token.head.i,
"target": i,
"relation": token.dep_,
"source_text": token.head.text,
"target_text": token.text
}
data["dependencies"].append(dep_data)
# 构建树形结构
data["tree"] = self._build_tree_structure(doc)
return data
def _build_tree_structure(self, doc):
"""构建树形结构"""
# 找到根节点
roots = [token for token in doc if token.head == token]
if not roots:
return None
# 递归构建树
def build_node(token):
node = {
"text": token.text,
"lemma": token.lemma_,
"pos": token.pos_,
"tag": token.tag_,
"dep": token.dep_,
"id": token.i,
"children": []
}
for child in token.children:
node["children"].append(build_node(child))
return node
# 构建整棵树
if len(roots) == 1:
return build_node(roots[0])
else:
return {
"type": "forest",
"trees": [build_node(root) for root in roots]
}
def visualize_text(self, text):
"""生成可视化的文本表示"""
result = self.analyze(text)
lines = []
lines.append("=" * 80)
lines.append(f"文本: {text}")
lines.append(f"语言: {'中文' if self.language == 'zh' else '英文'}")
lines.append(f"词数: {len(result['tokens'])}")
lines.append(f"模型: {self.model_name}")
lines.append("=" * 80)
lines.append("")
for token in result['tokens']:
head_id = token['head']
head_text = result['tokens'][head_id]['text'] if head_id < len(result['tokens']) else "ROOT"
line = f"{token['text']:15} | {token['pos']:6} | {token['dep']:12} | -> {head_text:15}"
lines.append(line)
lines.append("")
lines.append("=" * 80)
lines.append("依存关系:")
lines.append("=" * 80)
lines.append("")
for dep in result['dependencies']:
line = f"{dep['source_text']} --[{dep['relation']}]--> {dep['target_text']}"
lines.append(line)
return "\n".join(lines)
if __name__ == "__main__":
# 测试代码
print("测试spaCy依存句法分析器\n")
analyzer = DependencyAnalyzer(language='zh')
# 测试不同领域文本
test_cases = [
"我喜欢阅读有趣的技术书籍。",
"人工智能算法处理大数据。",
"诊断症状需要专业医疗设备。",
"投资股票需要分析市场汇率风险。"
]
for text in test_cases:
print("\n" + "=" * 80)
result = analyzer.analyze(text)
print(analyzer.visualize_text(text))
print("\nJSON数据:")
print(json.dumps(result, ensure_ascii=False, indent=2))
dependency_visualizer.py
"""
改进的依存句法树可视化模块
使用matplotlib和networkx绘制美观的依存句法树
支持交互、多种布局、自适应样式
"""
import networkx as nx
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QComboBox, QLabel, QPushButton
from PyQt5.QtCore import Qt
import numpy as np
from matplotlib.patches import FancyArrowPatch
import matplotlib.patches as mpatches
class DependencyTreeVisualizer:
"""改进的依存句法树可视化器类"""
def __init__(self):
"""初始化可视化器"""
self.canvas = None
self.fig = None
self.ax = None
self.current_data = None
self.current_layout = 'hierarchical'
self.parent_widget = None
# 配色方案 - 更美观的颜色
self.color_scheme = {
'node_root': '#FF6B6B', # 根节点 - 红色
'node_noun': '#4ECDC4', # 名词 - 青色
'node_verb': '#95E1D3', # 动词 - 浅绿
'node_adj': '#F38181', # 形容词 - 珊瑚色
'node_adv': '#AA96DA', # 副词 - 紫色
'node_pron': '#FCBAD3', # 代词 - 粉色
'node_punct': '#A8DADC', # 标点 - 浅蓝灰
'node_other': '#F1FAEE', # 其他 - 浅灰
'edge_default': '#457B9D', # 边 - 深蓝灰
'edge_highlight': '#E63946', # 高亮边 - 红色
'text_primary': '#1D3557', # 主要文字 - 深蓝
'text_secondary': '#457B9D', # 次要文字 - 灰蓝
'background': '#F1FAEE', # 背景色
}
# 设置matplotlib中文显示
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.size'] = 10
def create_canvas(self, parent_widget):
"""
创建画布和控件
Args:
parent_widget: 父部件
Returns:
QWidget: 包含画布和控件的容器
"""
self.parent_widget = parent_widget
# 创建主容器
container = QWidget()
main_layout = QVBoxLayout(container)
main_layout.setContentsMargins(5, 5, 5, 5)
main_layout.setSpacing(5)
# 创建控制面板
control_panel = self._create_control_panel()
main_layout.addWidget(control_panel)
# 创建图形
self.fig = Figure(figsize=(12, 8), dpi=100, facecolor=self.color_scheme['background'])
self.canvas = FigureCanvas(self.fig)
self.canvas.setFocusPolicy(Qt.StrongFocus)
self.canvas.setFocus()
# 启用鼠标交互
self.canvas.mpl_connect('button_press_event', self.on_click)
self.canvas.mpl_connect('scroll_event', self.on_scroll)
self.canvas.mpl_connect('motion_notify_event', self.on_mouse_move)
self.ax = self.fig.add_subplot(111)
self.ax.set_facecolor(self.color_scheme['background'])
main_layout.addWidget(self.canvas)
# 添加到父部件
layout = QVBoxLayout(parent_widget)
layout.addWidget(container)
layout.setContentsMargins(0, 0, 0, 0)
return container
def _create_control_panel(self):
"""创建控制面板"""
panel = QWidget()
panel.setStyleSheet("""
QWidget {
background-color: #E9ECEF;
border-radius: 5px;
padding: 5px;
}
""")
layout = QHBoxLayout(panel)
layout.setContentsMargins(5, 5, 5, 5)
# 布局选择
layout.addWidget(QLabel("布局:"))
self.layout_combo = QComboBox()
self.layout_combo.addItems([
"层次布局",
"径向布局",
"力导向布局",
"环形布局"
])
self.layout_combo.currentTextChanged.connect(self.on_layout_changed)
layout.addWidget(self.layout_combo)
# 样式选择
layout.addWidget(QLabel("样式:"))
self.style_combo = QComboBox()
self.style_combo.addItems([
"默认",
"简洁",
"彩色",
"暗黑"
])
self.style_combo.currentTextChanged.connect(self.on_style_changed)
layout.addWidget(self.style_combo)
# 重置视图按钮
reset_btn = QPushButton("重置视图")
reset_btn.clicked.connect(self.reset_view)
reset_btn.setStyleSheet("""
QPushButton {
background-color: #4ECDC4;
color: white;
border: none;
padding: 5px 10px;
border-radius: 3px;
}
QPushButton:hover {
background-color: #45B7B8;
}
""")
layout.addWidget(reset_btn)
layout.addStretch()
return panel
def draw_tree(self, dependency_data, parent_widget):
"""
绘制依存句法树(主绘制函数)
Args:
dependency_data: 依存句法数据
parent_widget: 父部件
"""
if dependency_data is None:
return
self.current_data = dependency_data
# 清除现有画布
if self.canvas is None:
self.create_canvas(parent_widget)
else:
self.ax.clear()
# 验证数据
if not self._validate_data(dependency_data):
self._show_error_message("数据格式错误")
return
# 根据选择的布局绘制
layout_name = self.layout_combo.currentText()
if layout_name == "层次布局":
self._draw_hierarchical_layout(dependency_data)
elif layout_name == "径向布局":
self._draw_radial_layout(dependency_data)
elif layout_name == "力导向布局":
self._draw_force_layout(dependency_data)
elif layout_name == "环形布局":
self._draw_circular_layout(dependency_data)
self._add_legend(dependency_data)
self._setup_interaction()
self.canvas.draw()
def _validate_data(self, data):
"""验证数据格式"""
if not isinstance(data, dict):
return False
tokens = data.get("tokens", [])
dependencies = data.get("dependencies", [])
if not isinstance(tokens, list) or not isinstance(dependencies, list):
return False
if len(tokens) == 0:
return False
return True
def _create_graph(self, dependency_data):
"""创建网络图"""
G = nx.DiGraph()
# 添加节点(包含详细信息)
for token in dependency_data.get("tokens", []):
G.add_node(
token['id'],
text=token.get('text', ''),
pos=token.get('pos', ''),
dep=token.get('dep', ''),
lemma=token.get('lemma', ''),
tag=token.get('tag', ''),
head=token.get('head', -1),
children=token.get('children', [])
)
# 添加边
for dep in dependency_data.get("dependencies", []):
G.add_edge(
dep['source'],
dep['target'],
relation=dep.get('relation', ''),
source_text=dep.get('source_text', ''),
target_text=dep.get('target_text', '')
)
return G
def _get_node_color(self, pos_tag, dep_tag):
"""根据词性和依存关系获取节点颜色"""
if 'ROOT' in str(dep_tag):
return self.color_scheme['node_root']
pos_lower = str(pos_tag).lower()
if any(x in pos_lower for x in ['noun', 'nn', 'n', '名']):
return self.color_scheme['node_noun']
elif any(x in pos_lower for x in ['verb', 'vv', 'v', '动']):
return self.color_scheme['node_verb']
elif any(x in pos_lower for x in ['adj', 'jj', 'a', '形']):
return self.color_scheme['node_adj']
elif any(x in pos_lower for x in ['adv', 'rb', 'd', '副']):
return self.color_scheme['node_adv']
elif any(x in pos_lower for x in ['pron', 'pn', '代']):
return self.color_scheme['node_pron']
elif any(x in pos_lower for x in ['punct', 'pu', '标']):
return self.color_scheme['node_punct']
else:
return self.color_scheme['node_other']
def _draw_hierarchical_layout(self, dependency_data):
"""绘制层次布局"""
G = self._create_graph(dependency_data)
# 确保所有节点都有位置
pos = {}
nodes = list(G.nodes())
if len(nodes) == 0:
return # 没有节点,直接返回
# 计算层次
levels = self._compute_levels(G)
if not levels:
# levels为空,使用默认布局(所有节点在同一层)
for i, node in enumerate(sorted(nodes)):
x = (i - len(nodes) / 2 + 0.5) * 2
y = 0
pos[node] = (x, y)
else:
# 按层次排列节点
level_nodes = {}
for node, level in levels.items():
if level not in level_nodes:
level_nodes[level] = []
level_nodes[level].append(node)
# 在每个层次内均匀分布节点
for level, level_nodes_list in level_nodes.items():
for i, node in enumerate(sorted(level_nodes_list)):
x = (i - len(level_nodes_list) / 2 + 0.5) * 2
y = -level * 1.5
pos[node] = (x, y)
# 确保所有节点都在pos中(安全处理)
for node in nodes:
if node not in pos:
# 如果节点不在pos中,给它一个默认位置
pos[node] = (0, 0)
# 绘制边和节点
self._draw_edges(G, pos)
self._draw_nodes(G, pos)
def _draw_radial_layout(self, dependency_data):
"""绘制径向布局"""
G = self._create_graph(dependency_data)
# 计算层次
levels = self._compute_levels(G)
max_level = max(levels.values()) if levels else 1
# 按层次环形排列
pos = {}
level_nodes = {}
for node, level in levels.items():
if level not in level_nodes:
level_nodes[level] = []
level_nodes[level].append(node)
for level, nodes in level_nodes.items():
radius = (level + 1) * 2
for i, node in enumerate(nodes):
angle = 2 * np.pi * i / len(nodes)
x = radius * np.cos(angle)
y = radius * np.sin(angle)
pos[node] = (x, y)
self._draw_edges(G, pos)
self._draw_nodes(G, pos)
def _draw_force_layout(self, dependency_data):
"""绘制力导向布局"""
G = self._create_graph(dependency_data)
# 使用spring布局
pos = nx.spring_layout(G, k=1.5, iterations=50, scale=5)
self._draw_edges(G, pos)
self._draw_nodes(G, pos)
def _draw_circular_layout(self, dependency_data):
"""绘制环形布局"""
G = self._create_graph(dependency_data)
# 使用环形布局
pos = nx.circular_layout(G, scale=4)
self._draw_edges(G, pos)
self._draw_nodes(G, pos)
def _draw_nodes(self, G, pos):
"""绘制节点"""
# 获取节点属性
node_colors = [self._get_node_color(
G.nodes[node].get('pos', ''),
G.nodes[node].get('dep', '')
) for node in G.nodes()]
# 绘制节点
nx.draw_networkx_nodes(
G, pos,
node_color=node_colors,
node_size=2000,
alpha=0.9,
ax=self.ax,
edgecolors='#2D3748',
linewidths=2
)
# 绘制节点标签
labels = {}
for node in G.nodes():
text = G.nodes[node].get('text', '')
pos_tag = G.nodes[node].get('pos', '')
dep_tag = G.nodes[node].get('dep', '')
# 如果依存关系不是ROOT,显示更多信息
if dep_tag == 'ROOT':
labels[node] = f"{text}\n{pos_tag}\n{dep_tag}"
else:
labels[node] = f"{text}\n{pos_tag}"
nx.draw_networkx_labels(
G, pos,
labels=labels,
font_size=9,
font_weight='bold',
ax=self.ax,
font_color='#2D3748'
)
def _draw_edges(self, G, pos):
"""绘制边"""
# 过滤掉pos中没有的节点(安全处理)
valid_edges = [(u, v) for u, v in G.edges() if u in pos and v in pos]
if not valid_edges:
return
# 绘制边
nx.draw_networkx_edges(
G, pos,
edgelist=valid_edges,
edge_color=self.color_scheme['edge_default'],
arrows=True,
arrowsize=25,
width=2.5,
alpha=0.7,
ax=self.ax,
arrowstyle='->'
)
# 绘制边标签(简化版,避免兼容性问题)
try:
edge_labels = nx.get_edge_attributes(G, 'relation')
if edge_labels:
# 手动计算标签位置(避免使用draw_networkx_edge_labels)
for (u, v), label in edge_labels.items():
if u in pos and v in pos:
x = (pos[u][0] + pos[v][0]) / 2
y = (pos[u][1] + pos[v][1]) / 2
self.ax.text(x, y, label,
fontsize=8,
color=self.color_scheme['text_secondary'],
ha='center', va='center',
bbox=dict(boxstyle="round,pad=0.2",
facecolor='white', alpha=0.7))
except Exception as e:
print(f"绘制边标签失败: {e}")
# 忽略边标签绘制错误,继续执行
def _add_legend(self, dependency_data):
"""添加图例"""
# 获取所有词性
pos_tags = set()
for token in dependency_data.get("tokens", []):
pos = token.get('pos', '')
if pos:
pos_tags.add(pos)
# 创建图例
legend_elements = []
for pos in sorted(pos_tags):
color = self._get_node_color(pos, '')
legend_elements.append(mpatches.Patch(color=color, label=pos))
# 添加图例
if legend_elements:
self.ax.legend(
handles=legend_elements,
loc='upper right',
fontsize=8,
framealpha=0.9,
edgecolor='#2D3748'
)
def _setup_interaction(self):
"""设置交互功能"""
# 启用坐标轴
self.ax.axis('on')
self.ax.grid(True, alpha=0.3, linestyle='--')
# 设置标题
if self.current_data:
text = self.current_data.get('text', '')
truncated_text = text[:50] + '...' if len(text) > 50 else text
self.ax.set_title(
f'依存句法树 - "{truncated_text}"',
fontsize=14,
fontweight='bold',
color=self.color_scheme['text_primary'],
pad=15
)
def on_layout_changed(self, layout_name):
"""布局改变时重新绘制"""
if self.current_data:
self.draw_tree(self.current_data, self.parent_widget)
def on_style_changed(self, style_name):
"""样式改变时重新绘制"""
if self.current_data:
# 这里可以根据样式名称切换配色方案
self.draw_tree(self.current_data, self.parent_widget)
def on_click(self, event):
"""处理鼠标点击事件"""
if event.inaxes != self.ax:
return
# 检测点击的节点
G = self._create_graph(self.current_data)
pos = nx.spring_layout(G, scale=5) if hasattr(self, 'pos') else nx.spring_layout(G, scale=5)
for node, (x, y) in pos.items():
if abs(event.xdata - x) < 0.3 and abs(event.ydata - y) < 0.3:
# 显示节点详细信息
self._show_node_info(node)
break
def on_scroll(self, event):
"""处理鼠标滚轮事件(缩放)"""
if event.inaxes != self.ax:
return
# 缩放逻辑
scale_factor = 1.1 if event.button == 'up' else 0.9
xlim = self.ax.get_xlim()
ylim = self.ax.get_ylim()
x_center = (xlim[0] + xlim[1]) / 2
y_center = (ylim[0] + ylim[1]) / 2
x_range = (xlim[1] - xlim[0]) * scale_factor
y_range = (ylim[1] - ylim[0]) * scale_factor
self.ax.set_xlim([x_center - x_range/2, x_center + x_range/2])
self.ax.set_ylim([y_center - y_range/2, y_center + y_range/2])
self.canvas.draw()
def on_mouse_move(self, event):
"""处理鼠标移动事件"""
if event.inaxes != self.ax:
return
def reset_view(self):
"""重置视图"""
if self.ax:
self.ax.set_xlim(auto=True)
self.ax.set_ylim(auto=True)
self.canvas.draw()
def _show_node_info(self, node_id):
"""显示节点详细信息"""
if not self.current_data:
return
tokens = self.current_data.get('tokens', [])
token = None
for t in tokens:
if t.get('id') == node_id:
token = t
break
if token:
info = f"词语: {token.get('text', '')}\n"
info += f"词性: {token.get('pos', '')}\n"
info += f"依存: {token.get('dep', '')}\n"
info += f"原形: {token.get('lemma', '')}\n"
info += f"标签: {token.get('tag', '')}\n"
# 这里可以集成 QMessageBox 或自定义信息面板
print(f"节点信息:\n{info}")
def _compute_levels(self, G):
"""计算节点的层次"""
levels = {}
if not G.nodes():
return levels # 空图返回空字典
# 找到根节点(没有入边的节点)
roots = [n for n in G.nodes() if G.in_degree(n) == 0]
if not roots:
# 没有根节点,返回空字典(让调用者处理)
return levels
# BFS遍历计算层次
for root in roots:
queue = [(root, 0)]
visited = set()
while queue:
node, level = queue.pop(0)
if node in visited:
continue
visited.add(node)
if node not in levels or levels[node] < level:
levels[node] = level
for neighbor in G.successors(node):
if neighbor not in visited:
queue.append((neighbor, level + 1))
return levels
def clear(self, parent_widget):
"""清除画布"""
if self.canvas:
self.ax.clear()
self.fig.clear()
self.ax = self.fig.add_subplot(111)
self.ax.set_facecolor(self.color_scheme['background'])
self.canvas.draw()
self.current_data = None
def draw_tree_radial(self, dependency_data, parent_widget):
"""
使用径向布局绘制依存句法树
Args:
dependency_data: 依存句法数据
parent_widget: 父部件
"""
# 清除现有画布
if self.canvas is None:
self.create_canvas(parent_widget)
else:
self.ax.clear()
self.fig.clear()
self.ax = self.fig.add_subplot(111)
# 创建有向图
G = nx.DiGraph()
# 添加节点
for token in dependency_data.get("tokens", []):
node_label = f"{token['text']}\n({token['pos']})"
G.add_node(token['id'], label=node_label)
# 添加边
for dep in dependency_data.get("dependencies", []):
G.add_edge(dep['source'], dep['target'],
label=dep['relation'])
# 设置节点位置(使用径向布局)
try:
pos = nx.nx_agraph.graphviz_layout(G, prog='twopi')
except:
# 如果graphviz不可用,使用circular布局
pos = nx.circular_layout(G)
# 绘制节点
nx.draw_networkx_nodes(G, pos,
node_color='lightgreen',
node_size=2000,
ax=self.ax,
alpha=0.9)
# 绘制边
nx.draw_networkx_edges(G, pos,
edge_color='darkgreen',
arrows=True,
arrowsize=20,
width=2,
alpha=0.7,
ax=self.ax)
# 绘制节点标签
labels = nx.get_node_attributes(G, 'label')
nx.draw_networkx_labels(G, pos,
labels=labels,
font_size=9,
font_weight='bold',
ax=self.ax)
# 绘制边标签
edge_labels = nx.get_edge_attributes(G, 'label')
nx.draw_networkx_edge_labels(G, pos,
edge_labels=edge_labels,
font_size=7,
ax=self.ax)
# 设置标题
self.ax.set_title("依存句法树 (径向布局)",
fontsize=16,
fontweight='bold',
pad=20)
self.ax.axis('off')
self.fig.tight_layout()
self.canvas.draw()
def draw_tree_hierarchical(self, dependency_data, parent_widget):
"""
使用层次布局绘制依存句法树
Args:
dependency_data: 依存句法数据
parent_widget: 父部件
"""
# 清除现有画布
if self.canvas is None:
self.create_canvas(parent_widget)
else:
self.ax.clear()
self.fig.clear()
self.ax = self.fig.add_subplot(111)
# 创建有向图
G = nx.DiGraph()
# 添加节点
for token in dependency_data.get("tokens", []):
node_label = f"{token['text']}\n({token['pos']})"
G.add_node(token['id'], label=node_label)
# 添加边
for dep in dependency_data.get("dependencies", []):
G.add_edge(dep['source'], dep['target'],
label=dep['relation'])
# 计算节点层次
levels = self._compute_levels(G)
# 按层次排列节点
pos = {}
level_width = {}
# 首先统计每个层次的节点数量
for node, level in levels.items():
if level not in level_width:
level_width[level] = 0
level_width[level] += 1
# 分配位置
level_offset = {}
for node, level in levels.items():
if level not in level_offset:
level_offset[level] = -level_width[level] / 2 + 0.5
pos[node] = (level_offset[level], -level)
level_offset[level] += 1
# 绘制节点
node_colors = ['lightcoral' if level == 0 else 'lightblue'
for level in levels.values()]
nx.draw_networkx_nodes(G, pos,
node_color=node_colors,
node_size=2500,
ax=self.ax,
alpha=0.9)
# 绘制边
nx.draw_networkx_edges(G, pos,
edge_color='darkblue',
arrows=True,
arrowsize=20,
width=2,
alpha=0.7,
ax=self.ax)
# 绘制节点标签
labels = nx.get_node_attributes(G, 'label')
nx.draw_networkx_labels(G, pos,
labels=labels,
font_size=10,
font_weight='bold',
ax=self.ax)
# 绘制边标签
edge_labels = nx.get_edge_attributes(G, 'label')
nx.draw_networkx_edge_labels(G, pos,
edge_labels=edge_labels,
font_size=8,
ax=self.ax)
# 设置标题
self.ax.set_title("依存句法树 (层次布局)",
fontsize=16,
fontweight='bold',
pad=20)
self.ax.axis('off')
self.fig.tight_layout()
self.canvas.draw()
def _compute_levels(self, G):
"""
计算节点的层次
Args:
G: 网络图对象
Returns:
dict: 节点到层次的映射
"""
levels = {}
# 找到根节点(没有入边的节点)
roots = [n for n in G.nodes() if G.in_degree(n) == 0]
if not roots:
# 如果没有根节点,选择所有节点
roots = list(G.nodes())
# BFS遍历计算层次
for root in roots:
queue = [(root, 0)]
while queue:
node, level = queue.pop(0)
if node not in levels or levels[node] < level:
levels[node] = level
for neighbor in G.successors(node):
if neighbor not in levels:
queue.append((neighbor, level + 1))
return levels
def clear(self, parent_widget):
"""
清除画布
Args:
parent_widget: 父部件
"""
if self.canvas:
self.ax.clear()
self.fig.clear()
self.canvas.draw()
if __name__ == "__main__":
# 测试代码
import sys
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
# 测试数据
test_data = {
"tokens": [
{"id": 0, "text": "我", "pos": "PRON"},
{"id": 1, "text": "喜欢", "pos": "VERB"},
{"id": 2, "text": "阅读", "pos": "VERB"},
{"id": 3, "text": "技术", "pos": "NOUN"},
{"id": 4, "text": "书籍", "pos": "NOUN"},
{"id": 5, "text": "。", "pos": "PUNCT"}
],
"dependencies": [
{"source": 1, "target": 0, "relation": "nsubj"},
{"source": 1, "target": 2, "relation": "dobj"},
{"source": 2, "target": 3, "relation": "compound"},
{"source": 4, "target": 3, "relation": "compound"},
{"source": 2, "target": 4, "relation": "obj"},
{"source": 1, "target": 5, "relation": "punct"}
]
}
# 创建窗口
from PyQt5.QtWidgets import QMainWindow
window = QMainWindow()
window.setGeometry(100, 100, 800, 600)
visualizer = DependencyTreeVisualizer()
visualizer.draw_tree(test_data, window)
window.show()
sys.exit(app.exec_())