Files
paste-framework/paste/util/svg.py
T
2026-06-02 16:26:10 +08:00

705 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
from typing import Optional
import svgwrite
from svgwrite.container import Group
from svgwrite.path import Path
from svgwrite.shapes import Rect
from svgwrite.text import Text
from paste.util import ustr
class TextRect(Group):
"""
可显示文本的矩形。
"""
def __init__(self, text, insert, text_extra: dict = None, rect_extra: dict = None, **extra):
# 父类初始化
super().__init__(**extra)
self.text = text
"""
要显示的文本内容。
"""
self.extra = extra
"""
组合扩展信息。
"""
self.rectExtra = rect_extra if rect_extra is not None else {}
"""
外框扩展信息。
"""
self.textExtra = text_extra if text_extra is not None else {}
"""
文本扩展信息。
"""
self.rectInsert = insert
"""
整体位置参数,即外框的位置参数。
"""
# 初始化文本尺寸
_fs = self.font_size
self.textInsert = self.text_pos
"""
文本位置参数。
"""
# 文本初始化
self.textElement = Text(self.text, insert=self.textInsert, **self.textExtra)
# 矩形初始化
self.rectElement = Rect(insert=self.rectInsert, size=self.rect_size, **self.rectExtra)
# 加入元素
self.add(self.rectElement)
self.add(self.textElement)
@property
def font_size(self):
"""
从样式中识别字体大小,单位用像素,缺省 14px。
:return: 字体大小
"""
_font_size = self.textExtra.get('font-size', self.extra.get('font-size', f"{14}px"))
self.textExtra['font-size'] = _font_size
_size = re.sub(r'\D', '', _font_size.strip())
return int(_size)
@property
def text_width(self):
"""
文本宽度(近似)。
"""
total = len(self.text)
q_count = ustr.str_q_count(self.text)
return q_count * self.font_size + (total - q_count) * self.font_size * 0.5
@property
def text_height(self):
"""
文本高度(近似)。
"""
return self.font_size * 1.2
@property
def rect_width(self):
"""
外框宽度。
"""
return self.text_width + self.font_size * 1.5
@property
def rect_height(self):
"""
外框高度。
"""
return self.text_height * 1.9
@property
def rect_size(self):
"""
外框尺寸。
"""
return self.rect_width, self.rect_height
@property
def text_pos(self):
"""
文本位置。
"""
return \
self.rectInsert[0] + (self.rect_width - self.text_width) * 0.5, \
self.rectInsert[1] + self.text_height * 1.25
def reposition(self, position: tuple):
"""
重新定位。
:param position: 位置坐标
"""
self.rectInsert = position
self.rectElement.attribs['x'] = self.rectInsert[0]
self.rectElement.attribs['y'] = self.rectInsert[1]
self.textInsert = self.text_pos
self.textElement.attribs['x'] = self.textInsert[0]
self.textElement.attribs['y'] = self.textInsert[1]
def point_bottom(self):
"""
底部点。
"""
return self.rectInsert[0] + self.rect_width / 2, self.rectInsert[1] + self.rect_size[1]
def point_top(self):
"""
顶部点。
"""
return self.rectInsert[0] + self.rect_width / 2, self.rectInsert[1]
def point_left(self):
"""
左侧点。
"""
return self.rectInsert[0], self.rectInsert[1] + self.rect_height / 2
def point_right(self):
"""
右侧点。
"""
return self.rectInsert[0] + self.rect_width, self.rectInsert[1] + self.rect_height / 2
@classmethod
def horizontal_path(cls, start: tuple, end: tuple, **extra):
"""
生成水平方向连接线。
:param start: 起点坐标
:param end: 终点坐标
:param extra: 扩展参数
:return: 路径对象
"""
_p_control = [
(start[0] + end[0]) * 0.5,
start[1]
]
_p_center = [
(start[0] + end[0]) * 0.5,
(start[1] + end[1]) * 0.5
]
_path = Path(**extra)
_path.push(['M', start])
_path.push(['Q', _p_control + _p_center])
_path.push(['T', end])
return _path
@classmethod
def vertical_path(cls, start: tuple, end: tuple, **extra):
"""
生成垂直方向连接线。
:param start: 起点坐标
:param end: 终点坐标
:param extra: 扩展参数
:return: 路径对象
"""
_p_control = [
start[0],
(start[1] + end[1]) * 0.5
]
_p_center = [
(start[0] + end[0]) * 0.5,
(start[1] + end[1]) * 0.5
]
_path = Path(**extra)
_path.push(['M', start])
_path.push(['Q', _p_control + _p_center])
_path.push(['T', end])
return _path
def choose_point(self, sibling: 'TextRect'):
"""
选择与目标文本框的连线点。
返回起点(tuple)在自生文本框上,终点(tuple)在目标文本框上。
:param sibling: 目标文本框
:return: 起点、终点、是否水平连线
"""
_start = self.point_bottom()
_end = sibling.point_top()
_is_horizontal = True
if self.point_bottom()[1] > sibling.point_top()[1]:
if self.point_right()[0] < sibling.point_left()[0]:
_start = self.point_right()
_end = sibling.point_left()
_is_horizontal = False
elif self.point_left()[0] > sibling.point_right()[0]:
_start = self.point_left()
_end = sibling.point_right()
_is_horizontal = False
else:
_start = self.point_top()
_end = sibling.point_bottom()
_is_horizontal = True
elif self.point_top()[1] < sibling.point_bottom()[1]:
if self.point_right()[0] < sibling.point_left()[0]:
_start = self.point_right()
_end = sibling.point_left()
_is_horizontal = False
elif self.point_left()[0] > sibling.point_right()[0]:
_start = self.point_left()
_end = sibling.point_right()
_is_horizontal = False
else:
_start = self.point_bottom()
_end = sibling.point_top()
_is_horizontal = True
else:
if self.point_right()[0] < sibling.point_left()[0]:
_start = self.point_right()
_end = sibling.point_left()
_is_horizontal = False
elif self.point_left()[0] > sibling.point_right()[0]:
_start = self.point_left()
_end = sibling.point_right()
_is_horizontal = False
else:
_start = self.point_bottom()
_end = sibling.point_top()
_is_horizontal = True
return _start, _end, _is_horizontal
def connect(self, sibling: 'TextRect', **extra):
"""
取得连接路径。
:param sibling: 目标文本框
:param extra: 连线扩展参数
:return: 连接路径
"""
_start, _end, _is_horizontal = self.choose_point(sibling)
if _is_horizontal:
return self.vertical_path(_start, _end, **extra)
else:
return self.horizontal_path(_start, _end, **extra)
class RelationGraph:
"""
SVG 关系图。
根据 title 名称和 row_list 列表数据输出 svg 格式的关系图谱。
"""
def __init__(self, filename: str = 'noname.svg'):
self.filename = filename
self.width = 800
"""
画布宽度。
"""
self.height = 600
"""
画布高度。
"""
self.vhSpace = 170
"""
内容垂直浮动空间。
"""
self.lrSpace = 100
"""
内容左右留白空间。
"""
self.titleTextExtra = {
'font-size': '16px', 'fill': 'rgb(255, 255, 255)'
}
"""
标题文本样式。
"""
self.titleRectExtra = {
'rx': 10, 'ry': 10, 'fill': 'rgb(233, 72, 41)', 'fill-opacity': 1, 'stroke': 'rgb(233, 72, 41)'
}
"""
标题外框样式
"""
self.textExtra = {
'font-size': '14px', 'fill': 'rgb(255, 255, 255)'
}
"""
普通文本样式。
"""
self.rectExtra = {
'rx': 10, 'ry': 10, 'fill': 'rgb(65, 130, 164)', 'fill-opacity': 1, 'stroke': 'rgb(65, 130, 164)'
}
"""
普通文本外框样式。
"""
self.pathExtra = {
'fill': 'none', 'stroke': 'rgb(65, 130, 164)'
}
"""
连线样式。
"""
self.drawing = svgwrite.Drawing(filename=self.filename)
"""
主绘图对象。
"""
self.attribs = self.drawing.attribs
"""
图像样式。
"""
self.save = self.drawing.save
"""
保存文件方法。
"""
self.attribs.update({
'width': self.width, 'height': self.height
})
self.titleTr: Optional[TextRect] = None
"""
标题文本框对象。
"""
def draw(self, title: str, row_list: list[dict]):
"""
绘制图形。
:param row_list: 数据对象列表,必须包含 unit_name, unit_uscid, enterprise_id 三个字段
:param title: 标题文本
:return: 自身对象
"""
# 重定设图像参数
self.attribs.update(self.attribs)
# 创建标题文本框
self.titleTr = TextRect(
text=title, insert=(0, 0), text_extra=self.titleTextExtra, rect_extra=self.titleRectExtra, **{
'debug': False
}
)
self.titleTr.reposition((
(self.width - self.titleTr.rect_width) * 0.5, (self.height - self.titleTr.rect_height) * 0.5 - 20
))
self.drawing.add(self.titleTr)
_tr_list: list[TextRect] = []
for _i, _row in enumerate(row_list):
# 遍历数据,初始创建所有的文本框,得到文本框尺寸信息
# 同时保留所有需要输出的数据
_text = f"{_row['short_name']} ({_row['count']})"
_tr = TextRect(
text=_text, insert=(0, 0), rect_extra=self.rectExtra, **self.textExtra, **{
'debug': False,
'data-name': _row['unit_name'],
'data-uscid': _row['unit_uscid'],
'data-enterprise-id': _row['enterprise_id'],
}
)
_tr_list.append(_tr)
_harf = int(len(_tr_list) / 2) if int(len(_tr_list) % 2) == 0 else int(len(_tr_list) / 2) + 1
_top_list = []
_lft_list = _tr_list[:_harf]
_rit_list = _tr_list[_harf:]
_btm_list = []
if len(_tr_list) >= 12:
_top_list = _lft_list[:2]
_lft_list = _lft_list[2:]
if len(_tr_list) >= 14:
_btm_list = _rit_list[-2:]
_rit_list = _rit_list[:-2]
# 遍历所有顶部文本框,重新定位
for _i, _tr in enumerate(_top_list):
if _i == 0:
_position = (
self.titleTr.point_top()[0] - _tr.rect_width - 15,
self.titleTr.point_top()[1] - self.vhSpace - _tr.rect_height - 15
)
else:
_position = (
self.titleTr.point_top()[0] + 15,
self.titleTr.point_top()[1] - self.vhSpace - _tr.rect_height - 15
)
_tr.reposition(_position)
# 遍历所有底部文本框,重新定位
for _i, _tr in enumerate(_btm_list):
if _i == 0:
_position = (
self.titleTr.point_bottom()[0] - _tr.rect_width - 15,
self.titleTr.point_bottom()[1] + self.vhSpace + _tr.rect_height + 15
)
else:
_position = (
self.titleTr.point_bottom()[0] + 15,
self.titleTr.point_bottom()[1] + self.vhSpace + _tr.rect_height + 15
)
_tr.reposition(_position)
_top = self.titleTr.point_top()[1] - self.vhSpace
# 遍历所有左则文本框,重新定位
for _tr in _lft_list:
_w = _tr.rect_width
_h = _tr.rect_height
_space = self.titleTr.point_bottom()[1] - self.titleTr.point_top()[1] + self.vhSpace * 2 + _h
_margin = 0
if len(_lft_list) > 1:
_margin = (_space - len(_lft_list) * _h) / (len(_lft_list) - 1)
_left = self.titleTr.point_left()[0] - _w - self.lrSpace
_position = (_left, _top)
_tr.reposition(_position)
if _tr.point_left()[0] < 20:
_left = 20
_position = (_left, _top)
_tr.reposition(_position)
_top += _h + _margin
_top = self.titleTr.point_top()[1] - self.vhSpace
# 遍历所有右侧文本框,重新定位
for _tr in _rit_list:
_w = _tr.rect_width
_h = _tr.rect_height
_space = self.titleTr.point_bottom()[1] - self.titleTr.point_top()[1] + self.vhSpace * 2 + _h
_margin = 0
if len(_rit_list) > 1:
_margin = (_space - len(_rit_list) * _h) / (len(_rit_list) - 1)
_left = self.titleTr.point_right()[0] + self.lrSpace
_position = (_left, _top)
_tr.reposition(_position)
if _tr.point_right()[0] > self.width - 20:
_left = self.width - _tr.rect_width - 20
_position = (_left, _top)
_tr.reposition(_position)
_top += _h + _margin
for _tr in _tr_list:
self.drawing.add(self.titleTr.connect(_tr, **self.pathExtra))
for _tr in _tr_list:
self.drawing.add(_tr)
class EnterpriseGraph:
"""
SVG 企业汇总信息图。
根据 title 名称和 row_list 列表数据输出 svg 格式的关系图谱。
"""
def __init__(self, filename: str = 'noname.svg'):
self.filename = filename
self.width = 800
"""
画布宽度。
"""
self.height = 300
"""
画布高度。
"""
self.vhSpace = 50
"""
内容垂直浮动空间。
"""
self.lrSpace = 100
"""
内容左右留白空间。
"""
self.titleTextExtra = {
'font-size': '16px', 'fill': 'rgb(255, 255, 255)'
}
"""
标题文本样式。
"""
self.titleRectExtra = {
'rx': 10, 'ry': 10, 'fill': 'rgb(233, 72, 41)', 'fill-opacity': 1, 'stroke': 'rgb(233, 72, 41)'
}
"""
标题外框样式
"""
self.textExtra = {
'font-size': '14px', 'fill': 'rgb(255, 255, 255)'
}
"""
普通文本样式。
"""
self.rectExtra = {
'rx': 10, 'ry': 10, 'fill': 'rgb(65, 130, 164)', 'fill-opacity': 1, 'stroke': 'rgb(65, 130, 164)'
}
"""
普通文本外框样式。
"""
self.pathExtra = {
'fill': 'none', 'stroke': 'rgb(65, 130, 164)'
}
"""
连线样式。
"""
self.drawing = svgwrite.Drawing(filename=self.filename)
"""
主绘图对象。
"""
self.attribs = self.drawing.attribs
"""
图像样式。
"""
self.save = self.drawing.save
"""
保存文件方法。
"""
self.attribs.update({
'width': self.width, 'height': self.height
})
self.titleTr: Optional[TextRect] = None
"""
标题文本框对象。
"""
def draw(self, title: str, data_item: dict):
"""
绘制图形。
:param data_item: 数据项字典,中文名称:数据值
:param title: 标题文本
:return: 自身对象
"""
# 重定设图像参数
self.attribs.update(self.attribs)
# 创建标题文本框
self.titleTr = TextRect(
text=title, insert=(0, 0), text_extra=self.titleTextExtra, rect_extra=self.titleRectExtra, **{
'debug': False
}
)
self.titleTr.reposition((
(self.width - self.titleTr.rect_width) * 0.5, (self.height - self.titleTr.rect_height) * 0.5 - 20
))
self.drawing.add(self.titleTr)
_tr_list: list[TextRect] = []
for _key, _val in data_item.items():
# 遍历数据,初始创建所有的文本框,得到文本框尺寸信息
# 同时保留所有需要输出的数据
_text = f"{_key}{_val}"
_tr = TextRect(
text=_text, insert=(0, 0), rect_extra=self.rectExtra, **self.textExtra, **{
'debug': False,
}
)
_tr_list.append(_tr)
_harf = int(len(_tr_list) / 2) if int(len(_tr_list) % 2) == 0 else int(len(_tr_list) / 2) + 1
_top_list = []
_lft_list = _tr_list[:_harf]
_rit_list = _tr_list[_harf:]
_btm_list = []
if len(_tr_list) >= 12:
_top_list = _lft_list[:2]
_lft_list = _lft_list[2:]
if len(_tr_list) >= 14:
_btm_list = _rit_list[-2:]
_rit_list = _rit_list[:-2]
# 遍历所有顶部文本框,重新定位
for _key, _tr in enumerate(_top_list):
if _key == 0:
_position = (
self.titleTr.point_top()[0] - _tr.rect_width - 15,
self.titleTr.point_top()[1] - self.vhSpace - _tr.rect_height - 15
)
else:
_position = (
self.titleTr.point_top()[0] + 15,
self.titleTr.point_top()[1] - self.vhSpace - _tr.rect_height - 15
)
_tr.reposition(_position)
# 遍历所有底部文本框,重新定位
for _key, _tr in enumerate(_btm_list):
if _key == 0:
_position = (
self.titleTr.point_bottom()[0] - _tr.rect_width - 15,
self.titleTr.point_bottom()[1] + self.vhSpace + _tr.rect_height + 15
)
else:
_position = (
self.titleTr.point_bottom()[0] + 15,
self.titleTr.point_bottom()[1] + self.vhSpace + _tr.rect_height + 15
)
_tr.reposition(_position)
_top = self.titleTr.point_top()[1] - self.vhSpace
# 遍历所有左则文本框,重新定位
for _tr in _lft_list:
_w = _tr.rect_width
_h = _tr.rect_height
_space = self.titleTr.point_bottom()[1] - self.titleTr.point_top()[1] + self.vhSpace * 2 + _h
_margin = 0
if len(_lft_list) > 1:
_margin = (_space - len(_lft_list) * _h) / (len(_lft_list) - 1)
_left = self.titleTr.point_left()[0] - _w - self.lrSpace
_position = (_left, _top)
_tr.reposition(_position)
if _tr.point_left()[0] < 20:
_left = 20
_position = (_left, _top)
_tr.reposition(_position)
_top += _h + _margin
_top = self.titleTr.point_top()[1] - self.vhSpace
# 遍历所有右侧文本框,重新定位
for _tr in _rit_list:
_w = _tr.rect_width
_h = _tr.rect_height
_space = self.titleTr.point_bottom()[1] - self.titleTr.point_top()[1] + self.vhSpace * 2 + _h
_margin = 0
if len(_rit_list) > 1:
_margin = (_space - len(_rit_list) * _h) / (len(_rit_list) - 1)
_left = self.titleTr.point_right()[0] + self.lrSpace
_position = (_left, _top)
_tr.reposition(_position)
if _tr.point_right()[0] > self.width - 20:
_left = self.width - _tr.rect_width - 20
_position = (_left, _top)
_tr.reposition(_position)
_top += _h + _margin
for _tr in _tr_list:
self.drawing.add(self.titleTr.connect(_tr, **self.pathExtra))
for _tr in _tr_list:
self.drawing.add(_tr)