Toggle navigation
Toggle navigation
This project
Loading...
Sign in
万朱浩
/
Venue-Ops
Go to a project
Toggle navigation
Projects
Groups
Snippets
Help
Toggle navigation pinning
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Builds
Commits
Authored by
马一丁
2025-11-27 03:35:55 +0800
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
23356631f4e0ec64e5c1886980ffe89662163d2e
23356631
1 parent
2a5d984a
Optimize the Rendering of Inline Formulas, Subscripts and Superscripts, Bubble C…
…harts, and Horizontal Bars
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
226 additions
and
57 deletions
ReportEngine/renderers/chart_to_svg.py
ReportEngine/renderers/html_renderer.py
ReportEngine/renderers/pdf_renderer.py
ReportEngine/renderers/chart_to_svg.py
View file @
2335663
...
...
@@ -160,6 +160,19 @@ class ChartToSVGConverter:
if
props
.
get
(
'type'
):
chart_type
=
props
[
'type'
]
# Chart.js v4已移除horizontalBar类型,这里自动降级为bar并设置横向坐标
horizontal_bar
=
False
if
chart_type
and
str
(
chart_type
)
.
lower
()
==
'horizontalbar'
:
chart_type
=
'bar'
horizontal_bar
=
True
# 支持通过indexAxis: 'y' 强制横向柱状图
if
isinstance
(
props
,
dict
):
options
=
props
.
get
(
'options'
)
or
{}
index_axis
=
(
options
.
get
(
'indexAxis'
)
or
props
.
get
(
'indexAxis'
)
or
''
)
.
lower
()
if
index_axis
==
'y'
:
horizontal_bar
=
True
# 提取数据
data
=
widget_data
.
get
(
'data'
,
{})
if
not
data
:
...
...
@@ -172,10 +185,16 @@ class ChartToSVGConverter:
logger
.
debug
(
"检测到词云图表,跳过chart_to_svg转换"
)
return
None
render_method
=
getattr
(
self
,
f
'_render_{chart_type}'
,
None
)
if
not
render_method
:
logger
.
warning
(
f
"不支持的图表类型: {chart_type}"
)
return
None
# 分派渲染方法,特殊处理横向柱状图
if
chart_type
==
'bar'
:
return
self
.
_render_bar
(
data
,
props
,
width
,
height
,
dpi
,
horizontal
=
horizontal_bar
)
elif
chart_type
==
'bubble'
:
return
self
.
_render_bubble
(
data
,
props
,
width
,
height
,
dpi
)
else
:
render_method
=
getattr
(
self
,
f
'_render_{chart_type}'
,
None
)
if
not
render_method
:
logger
.
warning
(
f
"不支持的图表类型: {chart_type}"
)
return
None
# 创建图表并转换为SVG
return
render_method
(
data
,
props
,
width
,
height
,
dpi
)
...
...
@@ -687,9 +706,10 @@ class ChartToSVGConverter:
props
:
Dict
[
str
,
Any
],
width
:
int
,
height
:
int
,
dpi
:
int
dpi
:
int
,
horizontal
:
bool
=
False
)
->
Optional
[
str
]:
"""渲染柱状图"""
"""渲染柱状图
(支持横向barh)
"""
try
:
labels
=
data
.
get
(
'labels'
,
[])
datasets
=
data
.
get
(
'datasets'
,
[])
...
...
@@ -703,42 +723,145 @@ class ChartToSVGConverter:
colors
=
self
.
_get_colors
(
datasets
)
# 计算柱子位置
x
=
np
.
arange
(
len
(
labels
))
positions
=
np
.
arange
(
len
(
labels
))
width_bar
=
0.8
/
len
(
datasets
)
if
len
(
datasets
)
>
1
else
0.6
#
绘制每个数据系列
#
横向/纵向绘制
for
i
,
dataset
in
enumerate
(
datasets
):
dataset_data
=
dataset
.
get
(
'data'
,
[])
label
=
dataset
.
get
(
'label'
,
f
'系列{i+1}'
)
color
=
colors
[
i
]
offset
=
(
i
-
len
(
datasets
)
/
2
+
0.5
)
*
width_bar
ax
.
bar
(
x
+
offset
,
dataset_data
,
width_bar
,
if
horizontal
:
ax
.
barh
(
positions
+
offset
,
dataset_data
,
height
=
width_bar
,
label
=
label
,
color
=
color
,
alpha
=
0.8
,
edgecolor
=
'white'
,
linewidth
=
0.5
)
else
:
ax
.
bar
(
positions
+
offset
,
dataset_data
,
width_bar
,
label
=
label
,
color
=
color
,
alpha
=
0.8
,
edgecolor
=
'white'
,
linewidth
=
0.5
)
# 轴标签/网格
if
horizontal
:
ax
.
set_yticks
(
positions
)
ax
.
set_yticklabels
(
labels
)
ax
.
invert_yaxis
()
# 与Chart.js横向排列保持一致
ax
.
grid
(
True
,
alpha
=
0.3
,
linestyle
=
'--'
,
axis
=
'x'
)
else
:
ax
.
set_xticks
(
positions
)
ax
.
set_xticklabels
(
labels
,
rotation
=
45
,
ha
=
'right'
)
ax
.
grid
(
True
,
alpha
=
0.3
,
linestyle
=
'--'
,
axis
=
'y'
)
# 显示图例
if
len
(
datasets
)
>
1
:
ax
.
legend
(
loc
=
'best'
,
framealpha
=
0.9
)
return
self
.
_figure_to_svg
(
fig
)
except
Exception
as
e
:
logger
.
error
(
f
"渲染柱状图失败: {e}"
)
return
None
def
_render_bubble
(
self
,
data
:
Dict
[
str
,
Any
],
props
:
Dict
[
str
,
Any
],
width
:
int
,
height
:
int
,
dpi
:
int
)
->
Optional
[
str
]:
"""渲染气泡图"""
try
:
datasets
=
data
.
get
(
'datasets'
,
[])
if
not
datasets
:
return
None
title
=
props
.
get
(
'title'
)
fig
,
ax
=
self
.
_create_figure
(
width
,
height
,
dpi
,
title
)
colors
=
self
.
_get_colors
(
datasets
)
def
_safe_radius
(
raw
)
->
float
:
try
:
val
=
float
(
raw
)
return
max
(
val
,
0.5
)
except
Exception
:
return
1.0
all_x
:
list
[
float
]
=
[]
all_y
:
list
[
float
]
=
[]
max_r
:
float
=
0.0
for
i
,
dataset
in
enumerate
(
datasets
):
points
=
dataset
.
get
(
'data'
,
[])
label
=
dataset
.
get
(
'label'
,
f
'系列{i+1}'
)
color
=
colors
[
i
]
if
points
and
isinstance
(
points
[
0
],
dict
):
xs
=
[
p
.
get
(
'x'
,
0
)
for
p
in
points
]
ys
=
[
p
.
get
(
'y'
,
0
)
for
p
in
points
]
rs
=
[
_safe_radius
(
p
.
get
(
'r'
,
1
))
for
p
in
points
]
else
:
xs
=
list
(
range
(
len
(
points
)))
ys
=
points
rs
=
[
1.0
for
_
in
points
]
all_x
.
extend
(
xs
)
all_y
.
extend
(
ys
)
if
rs
:
max_r
=
max
(
max_r
,
max
(
rs
))
# 适度放大半径,近似Chart.js像素尺寸(动态尺度,避免过大遮挡)
size_scale
=
8.0
if
max_r
<=
20
else
6.5
sizes
=
[(
r
*
size_scale
)
**
2
for
r
in
rs
]
ax
.
scatter
(
xs
,
ys
,
s
=
sizes
,
label
=
label
,
color
=
color
,
alpha
=
0.8
,
edgecolor
=
'white'
,
linewidth
=
0.5
alpha
=
0.45
,
edgecolors
=
'white'
,
linewidth
=
0.6
)
# 设置x轴标签
ax
.
set_xticks
(
x
)
ax
.
set_xticklabels
(
labels
,
rotation
=
45
,
ha
=
'right'
)
# 显示图例
if
len
(
datasets
)
>
1
:
ax
.
legend
(
loc
=
'best'
,
framealpha
=
0.9
)
# 网格
ax
.
grid
(
True
,
alpha
=
0.3
,
linestyle
=
'--'
,
axis
=
'y'
)
# 适度留白,避免大气泡被裁切
if
all_x
and
all_y
:
x_min
,
x_max
=
min
(
all_x
),
max
(
all_x
)
y_min
,
y_max
=
min
(
all_y
),
max
(
all_y
)
x_span
=
max
(
x_max
-
x_min
,
1e-6
)
y_span
=
max
(
y_max
-
y_min
,
1e-6
)
pad_x
=
max
(
x_span
*
0.12
,
max_r
*
1.2
)
pad_y
=
max
(
y_span
*
0.12
,
max_r
*
1.2
)
ax
.
set_xlim
(
x_min
-
pad_x
,
x_max
+
pad_x
)
ax
.
set_ylim
(
y_min
-
pad_y
,
y_max
+
pad_y
)
# 额外安全边距
ax
.
margins
(
x
=
0.05
,
y
=
0.05
)
ax
.
grid
(
True
,
alpha
=
0.3
,
linestyle
=
'--'
)
return
self
.
_figure_to_svg
(
fig
)
except
Exception
as
e
:
logger
.
error
(
f
"渲染
柱状图失败: {e}"
)
logger
.
error
(
f
"渲染
气泡图失败: {e}"
,
exc_info
=
True
)
return
None
def
_render_pie
(
...
...
ReportEngine/renderers/html_renderer.py
View file @
2335663
...
...
@@ -1263,7 +1263,9 @@ class HTMLRenderer:
def
_render_math
(
self
,
block
:
Dict
[
str
,
Any
])
->
str
:
"""渲染数学公式,占位符交给外部MathJax或后处理"""
latex
=
self
.
_escape_html
(
block
.
get
(
"latex"
,
""
))
return
f
'<div class="math-block">$$ {latex} $$</div>'
math_id
=
self
.
_escape_attr
(
block
.
get
(
"mathId"
,
""
))
if
block
.
get
(
"mathId"
)
else
""
id_attr
=
f
' data-math-id="{math_id}"'
if
math_id
else
""
return
f
'<div class="math-block"{id_attr}>$$ {latex} $$</div>'
def
_render_figure
(
self
,
block
:
Dict
[
str
,
Any
])
->
str
:
"""根据新规范默认不渲染外部图片,改为友好提示"""
...
...
@@ -2012,7 +2014,9 @@ class HTMLRenderer:
latex
=
math_mark
.
get
(
"value"
)
if
not
isinstance
(
latex
,
str
)
or
not
latex
.
strip
():
latex
=
text_value
return
f
'<span class="math-inline">
\\
( {self._escape_html(latex)}
\\
)</span>'
math_id
=
self
.
_escape_attr
(
run
.
get
(
"mathId"
,
""
))
if
run
.
get
(
"mathId"
)
else
""
id_attr
=
f
' data-math-id="{math_id}"'
if
math_id
else
""
return
f
'<span class="math-inline"{id_attr}>
\\
( {self._escape_html(latex)}
\\
)</span>'
text
=
self
.
_escape_html
(
text_value
)
styles
:
List
[
str
]
=
[]
prefix
:
List
[
str
]
=
[]
...
...
ReportEngine/renderers/pdf_renderer.py
View file @
2335663
...
...
@@ -535,6 +535,33 @@ class PDFRenderer:
if
block_counter
is
None
:
block_counter
=
[
0
]
def
_extract_inline_math_from_inlines
(
inlines
:
list
):
"""从段落内联节点中提取数学公式"""
if
not
isinstance
(
inlines
,
list
):
return
for
run
in
inlines
:
if
not
isinstance
(
run
,
dict
):
continue
marks
=
run
.
get
(
'marks'
)
or
[]
math_mark
=
next
((
m
for
m
in
marks
if
m
.
get
(
'type'
)
==
'math'
),
None
)
if
not
math_mark
:
continue
latex
=
(
math_mark
.
get
(
'value'
)
or
run
.
get
(
'text'
)
or
''
)
.
strip
()
if
not
latex
:
continue
block_counter
[
0
]
+=
1
math_id
=
f
"math-inline-{block_counter[0]}"
try
:
svg_content
=
self
.
math_converter
.
convert_inline_to_svg
(
latex
)
if
svg_content
:
svg_map
[
math_id
]
=
svg_content
run
[
'mathId'
]
=
math_id
logger
.
debug
(
f
"公式 {math_id} 转换为SVG成功"
)
else
:
logger
.
warning
(
f
"公式 {math_id} 转换为SVG失败: {latex[:50]}..."
)
except
Exception
as
exc
:
logger
.
error
(
f
"转换内联公式 {latex[:50]}... 时出错: {exc}"
)
for
block
in
blocks
:
if
not
isinstance
(
block
,
dict
):
continue
...
...
@@ -547,7 +574,6 @@ class PDFRenderer:
if
latex
:
block_counter
[
0
]
+=
1
math_id
=
f
"math-block-{block_counter[0]}"
try
:
svg_content
=
self
.
math_converter
.
convert_display_to_svg
(
latex
)
if
svg_content
:
...
...
@@ -559,6 +585,11 @@ class PDFRenderer:
logger
.
warning
(
f
"公式 {math_id} 转换为SVG失败: {latex[:50]}..."
)
except
Exception
as
e
:
logger
.
error
(
f
"转换公式 {latex[:50]}... 时出错: {e}"
)
else
:
# 提取段落、表格等内部的内联公式
inlines
=
block
.
get
(
'inlines'
)
if
inlines
:
_extract_inline_math_from_inlines
(
inlines
)
# 递归处理嵌套的blocks
nested_blocks
=
block
.
get
(
'blocks'
)
...
...
@@ -614,9 +645,8 @@ class PDFRenderer:
# 创建SVG容器HTML
svg_html
=
f
'<div class="chart-svg-container">{svg_content}</div>'
# 查找包含此widgetId的配置脚本
# 格式: <script type="application/json" id="chart-config-N">{"widgetId":"widget_id",...}</script>
config_pattern
=
rf
'<script[^>]+id="([^"]+)"[^>]*>
\
s*
\
{{[^}}]*"widgetId"
\
s*:
\
s*"{re.escape(widget_id)}"[^}}]*
\
}}'
# 查找包含此widgetId的配置脚本(限制在同一个</script>内,避免跨标签误配)
config_pattern
=
rf
'<script[^>]+id="([^"]+)"[^>]*>(?:(?!</script>).)*?"widgetId"
\
s*:
\
s*"{re.escape(widget_id)}"(?:(?!</script>).)*?</script>'
match
=
re
.
search
(
config_pattern
,
html
,
re
.
DOTALL
)
if
match
:
...
...
@@ -627,8 +657,11 @@ class PDFRenderer:
canvas_pattern
=
rf
'<canvas[^>]+data-config-id="{re.escape(config_id)}"[^>]*></canvas>'
# 【修复】替换canvas为SVG,使用lambda避免反斜杠转义问题
html
=
re
.
sub
(
canvas_pattern
,
lambda
m
:
svg_html
,
html
)
logger
.
debug
(
f
"已替换图表 {widget_id} 的canvas为SVG"
)
html
,
replaced
=
re
.
subn
(
canvas_pattern
,
lambda
m
:
svg_html
,
html
,
count
=
1
)
if
replaced
:
logger
.
debug
(
f
"已替换图表 {widget_id} 的canvas为SVG"
)
else
:
logger
.
warning
(
f
"未找到图表 {widget_id} 的canvas进行替换"
)
# 将对应fallback标记为隐藏,避免PDF中出现重复表格
fallback_pattern
=
rf
'<div class="chart-fallback"([^>]*data-widget-id="{re.escape(widget_id)}"[^>]*)>'
...
...
@@ -661,7 +694,7 @@ class PDFRenderer:
f
'</div>'
)
config_pattern
=
rf
'<script[^>]+id="([^"]+)"[^>]*>
\
s*
\
{{[^}}]*"widgetId"
\
s*:
\
s*"{re.escape(widget_id)}"[^}}]*
\
}}
'
config_pattern
=
rf
'<script[^>]+id="([^"]+)"[^>]*>
(?:(?!</script>).)*?"widgetId"
\
s*:
\
s*"{re.escape(widget_id)}"(?:(?!</script>).)*?</script>
'
match
=
re
.
search
(
config_pattern
,
html
,
re
.
DOTALL
)
if
not
match
:
logger
.
debug
(
f
"未找到词云 {widget_id} 的配置脚本,跳过注入"
)
...
...
@@ -670,8 +703,11 @@ class PDFRenderer:
config_id
=
match
.
group
(
1
)
canvas_pattern
=
rf
'<canvas[^>]+data-config-id="{re.escape(config_id)}"[^>]*></canvas>'
html
=
re
.
sub
(
canvas_pattern
,
lambda
m
:
img_html
,
html
)
logger
.
debug
(
f
"已替换词云 {widget_id} 的canvas为PNG图片"
)
html
,
replaced
=
re
.
subn
(
canvas_pattern
,
lambda
m
:
img_html
,
html
,
count
=
1
)
if
replaced
:
logger
.
debug
(
f
"已替换词云 {widget_id} 的canvas为PNG图片"
)
else
:
logger
.
warning
(
f
"未找到词云 {widget_id} 的canvas进行替换"
)
fallback_pattern
=
rf
'<div class="chart-fallback"([^>]*data-widget-id="{re.escape(widget_id)}"[^>]*)>'
...
...
@@ -701,32 +737,40 @@ class PDFRenderer:
import
re
#
为每个math block查找对应的div并替换为SVG
#
优先替换内联公式,再替换块级公式,保持顺序一致
for
math_id
,
svg_content
in
svg_map
.
items
():
# 清理SVG内容(移除XML声明,因为SVG将嵌入HTML)
svg_content
=
re
.
sub
(
r'<
\
?xml[^>]+
\
?>'
,
''
,
svg_content
)
svg_content
=
re
.
sub
(
r'<!DOCTYPE[^>]+>'
,
''
,
svg_content
)
svg_content
=
svg_content
.
strip
()
# 创建SVG容器HTML
svg_html
=
f
'<div class="math-svg-container">{svg_content}</div>'
# 查找对应的math-block div
# 格式: <div class="math-block">$$ latex $$</div>
# 我们需要找到包含特定LaTeX内容的div
# 但由于我们在转换时已经给block添加了mathId,我们可以用另一种方式
svg_block_html
=
f
'<div class="math-svg-container">{svg_content}</div>'
svg_inline_html
=
f
'<span class="math-svg-inline">{svg_content}</span>'
# 方案:在HTML渲染器中为math-block添加data-math-id属性
# 但这需要修改HTMLRenderer,暂时我们使用更简单的方法:
# 按顺序替换所有math-block
# 暂时使用简单的替换方案
# 找到第一个math-block div并替换
math_block_pattern
=
r'<div class="math-block">
\
$
\
$[^$]*
\
$
\
$</div>'
# 【修复】使用lambda函数避免re.sub将SVG内容中的反斜杠解释为转义序列
# lambda函数中的返回值会被当作字面字符串,不会进行转义处理
html
=
re
.
sub
(
math_block_pattern
,
lambda
m
:
svg_html
,
html
,
count
=
1
)
logger
.
debug
(
f
"已替换公式 {math_id} 为SVG"
)
replaced
=
False
# 优先按 data-math-id 精确替换
inline_pattern
=
rf
'<span class="math-inline"[^>]*data-math-id="{re.escape(math_id)}"[^>]*>.*?</span>'
if
re
.
search
(
inline_pattern
,
html
,
re
.
DOTALL
):
html
=
re
.
sub
(
inline_pattern
,
lambda
m
:
svg_inline_html
,
html
,
count
=
1
)
replaced
=
True
else
:
block_pattern
=
rf
'<div class="math-block"[^>]*data-math-id="{re.escape(math_id)}"[^>]*>.*?</div>'
if
re
.
search
(
block_pattern
,
html
,
re
.
DOTALL
):
html
=
re
.
sub
(
block_pattern
,
lambda
m
:
svg_block_html
,
html
,
count
=
1
)
replaced
=
True
# 如果没有找到特定ID,按出现顺序兜底替换
if
not
replaced
:
html
,
sub_inline
=
re
.
subn
(
r'<span class="math-inline">[^<]*</span>'
,
lambda
m
:
svg_inline_html
,
html
,
count
=
1
)
if
sub_inline
:
replaced
=
True
else
:
html
,
sub_block
=
re
.
subn
(
r'<div class="math-block">
\
$
\
$[^$]*
\
$
\
$</div>'
,
lambda
m
:
svg_block_html
,
html
,
count
=
1
)
if
sub_block
:
replaced
=
True
if
replaced
:
logger
.
debug
(
f
"已替换公式 {math_id} 为SVG"
)
return
html
...
...
@@ -787,10 +831,8 @@ class PDFRenderer:
logger
.
info
(
"开始转换数学公式为SVG矢量图形..."
)
math_svg_map
=
self
.
_convert_math_to_svg
(
preprocessed_ir
)
# 使用HTML渲染器生成基础HTML(使用原始IR,因为HTMLRenderer会自己修复)
# 注意:这里仍使用原始document_ir,因为HTMLRenderer内部会进行相同的修复
# 这确保了HTML和SVG使用相同的修复逻辑
html
=
self
.
html_renderer
.
render
(
document_ir
)
# 使用HTML渲染器生成基础HTML(使用预处理后的IR,以便复用mathId等标记)
html
=
self
.
html_renderer
.
render
(
preprocessed_ir
)
# 注入图表SVG
if
svg_map
:
...
...
Please
register
or
login
to post a comment