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)