From e9b7a917227bd205059bb6998d55c53616e0187f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E4=B8=80=E4=B8=81?= <1769123563@qq.com> Date: Tue, 25 Nov 2025 01:41:30 +0800 Subject: [PATCH] PDF Enhancement Generation --- ReportEngine/renderers/chart_to_svg.py | 30 +- ReportEngine/renderers/html_renderer.py | 49 ++- .../renderers/pdf_layout_optimizer.py | 293 +++++++++++++++--- 3 files changed, 322 insertions(+), 50 deletions(-) diff --git a/ReportEngine/renderers/chart_to_svg.py b/ReportEngine/renderers/chart_to_svg.py index 6dd4031..f7dc302 100644 --- a/ReportEngine/renderers/chart_to_svg.py +++ b/ReportEngine/renderers/chart_to_svg.py @@ -68,6 +68,17 @@ class ChartToSVGConverter: 'var(--color-success)': '#50C878', # 翠绿色(从#28A745改为更明亮) 'var(--re-success-color)': '#50C878', # 翠绿色 'var(--re-success-color-translucent)': (0.314, 0.784, 0.471, 0.08), # 绿色极浅透明 rgba(80, 200, 120, 0.08) + 'var(--color-accent-positive)': '#50C878', + 'var(--color-accent-negative)': '#E85D75', + 'var(--color-text-secondary)': '#6B7280', + 'var(--accentPositive)': '#50C878', + 'var(--accentNegative)': '#E85D75', + 'var(--sentiment-positive, #28A745)': '#28A745', + 'var(--sentiment-negative, #E53E3E)': '#E53E3E', + 'var(--sentiment-neutral, #FFC107)': '#FFC107', + 'var(--sentiment-positive)': '#28A745', + 'var(--sentiment-negative)': '#E53E3E', + 'var(--sentiment-neutral)': '#FFC107', 'var(--color-primary)': '#3498DB', # 天蓝色 'var(--color-secondary)': '#95A5A6', # 浅灰色 } @@ -225,6 +236,13 @@ class ChartToSVGConverter: # 【增强】处理CSS变量,例如 var(--color-accent) # 使用预定义的颜色映射表替代CSS变量,确保不同变量有不同的颜色 if color.startswith('var('): + # 解析 var(--token, fallback) 形式 + fb_match = re.match(r'^var\(\s*--[^,)+]+,\s*([^)]+)\)', color) + if fb_match: + fb_raw = fb_match.group(1).strip() + fb_color = self._parse_color(fb_raw) + if fb_color: + return fb_color # 尝试从映射表中查找对应的颜色 mapped_color = self.CSS_VAR_COLOR_MAP.get(color) if mapped_color: @@ -406,7 +424,7 @@ class ChartToSVGConverter: # 获取配置 y_axis_id = dataset.get('yAxisID', 'y') - fill = dataset.get('fill', False) + fill = True # 强制开启填充,便于对比 tension = dataset.get('tension', 0) # 0表示直线,0.4表示平滑曲线 border_color = self._parse_color(dataset.get('borderColor', color)) background_color = self._parse_color(dataset.get('backgroundColor', color)) @@ -449,7 +467,7 @@ class ChartToSVGConverter: color=border_color, linewidth=2, markersize=6) if fill: - ax.fill_between(x_data, y_data, alpha=0.08, color=background_color) + ax.fill_between(x_data, y_data, alpha=0.2, color=background_color) for pos, y_val, text in zip(x_data, y_data, annotations): if text: @@ -478,18 +496,18 @@ class ChartToSVGConverter: # 如果需要填充(使用极低透明度避免遮挡) if fill: - ax.fill_between(x_smooth, y_smooth, alpha=0.08, color=background_color) + ax.fill_between(x_smooth, y_smooth, alpha=0.2, color=background_color) except: # 如果平滑失败,使用普通折线 line, = ax.plot(x_data, dataset_data, marker='o', label=label, color=border_color, linewidth=2, markersize=6) if fill: - ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) + ax.fill_between(x_data, dataset_data, alpha=0.2, color=background_color) else: line, = ax.plot(x_data, dataset_data, marker='o', label=label, color=border_color, linewidth=2, markersize=6) if fill: - ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) + ax.fill_between(x_data, dataset_data, alpha=0.2, color=background_color) else: # 直线连接(tension=0或scipy不可用) line, = ax.plot(x_data, dataset_data, marker='o', label=label, @@ -497,7 +515,7 @@ class ChartToSVGConverter: # 如果需要填充(使用极低透明度避免遮挡) if fill: - ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) + ax.fill_between(x_data, dataset_data, alpha=0.2, color=background_color) # 记录这条线属于哪个轴 axis_lines[y_axis_id].append(line) diff --git a/ReportEngine/renderers/html_renderer.py b/ReportEngine/renderers/html_renderer.py index 13d126c..b5cf2be 100644 --- a/ReportEngine/renderers/html_renderer.py +++ b/ReportEngine/renderers/html_renderer.py @@ -1345,7 +1345,8 @@ class HTMLRenderer: if self._should_skip_overview_kpi(block): return "" cards = "" - for item in block.get("items", []): + items = block.get("items", []) + for item in items: delta = item.get("delta") delta_tone = item.get("deltaTone") or "neutral" delta_html = f'{self._escape_html(delta)}' if delta else "" @@ -1356,7 +1357,8 @@ class HTMLRenderer: {delta_html} """ - return f'
{cards}
' + count_attr = f' data-kpi-count="{len(items)}"' if items else "" + return f'
{cards}
' def _merge_dicts( self, base: Dict[str, Any] | None, override: Dict[str, Any] | None @@ -2632,21 +2634,41 @@ table th {{ margin: 20px 0; }} .kpi-card {{ + display: flex; + flex-direction: column; + gap: 8px; padding: 16px; border-radius: 12px; background: rgba(0,0,0,0.02); border: 1px solid var(--border-color); + align-items: flex-start; }} .kpi-value {{ font-size: 2rem; font-weight: 700; + display: flex; + flex-wrap: wrap; + gap: 4px 6px; + line-height: 1.25; + word-break: break-word; + overflow-wrap: break-word; }} .kpi-label {{ color: var(--secondary-color); + line-height: 1.35; + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; }} .delta.up {{ color: #27ae60; }} .delta.down {{ color: #e74c3c; }} .delta.neutral {{ color: var(--secondary-color); }} +.delta {{ + display: block; + line-height: 1.3; + word-break: break-word; + overflow-wrap: break-word; +}} .chart-card {{ margin: 30px 0; padding: 20px; @@ -2879,6 +2901,17 @@ const CSS_VAR_COLOR_MAP = { 'var(--color-success)': '#50C878', 'var(--re-success-color)': '#50C878', 'var(--re-success-color-translucent)': 'rgba(80, 200, 120, 0.08)', + 'var(--color-accent-positive)': '#50C878', + 'var(--color-accent-negative)': '#E85D75', + 'var(--color-text-secondary)': '#6B7280', + 'var(--accentPositive)': '#50C878', + 'var(--accentNegative)': '#E85D75', + 'var(--sentiment-positive, #28A745)': '#28A745', + 'var(--sentiment-negative, #E53E3E)': '#E53E3E', + 'var(--sentiment-neutral, #FFC107)': '#FFC107', + 'var(--sentiment-positive)': '#28A745', + 'var(--sentiment-negative)': '#E53E3E', + 'var(--sentiment-neutral)': '#FFC107', 'var(--color-primary)': '#3498DB', 'var(--color-secondary)': '#95A5A6' }; @@ -2893,6 +2926,13 @@ function normalizeColorToken(color) { if (typeof color !== 'string') return color; const trimmed = color.trim(); if (!trimmed) return null; + // 支持 var(--token, fallback) 形式,优先解析fallback + const varWithFallback = trimmed.match(/^var\(\s*--[^,)+]+,\s*([^)]+)\)/i); + if (varWithFallback && varWithFallback[1]) { + const fallback = varWithFallback[1].trim(); + const normalizedFallback = normalizeColorToken(fallback); + if (normalizedFallback) return normalizedFallback; + } if (CSS_VAR_COLOR_MAP[trimmed]) { return CSS_VAR_COLOR_MAP[trimmed]; } @@ -2979,6 +3019,9 @@ function normalizeDatasetColors(payload, chartType) { data.datasets.forEach((dataset, idx) => { if (!isPlainObject(dataset)) return; + if (type === 'line') { + dataset.fill = true; // 对折线图强制开启填充,便于区域对比 + } const paletteColor = normalizeColorToken(DEFAULT_CHART_COLORS[idx % DEFAULT_CHART_COLORS.length]); const borderInput = dataset.borderColor; const backgroundInput = dataset.backgroundColor; @@ -3013,7 +3056,7 @@ function normalizeDatasetColors(payload, chartType) { } const typeAlpha = type === 'line' - ? (dataset.fill ? 0.08 : 0.12) + ? (dataset.fill ? 0.25 : 0.18) : type === 'radar' ? 0.25 : type === 'scatter' || type === 'bubble' diff --git a/ReportEngine/renderers/pdf_layout_optimizer.py b/ReportEngine/renderers/pdf_layout_optimizer.py index 0e56ed8..e1dd96a 100644 --- a/ReportEngine/renderers/pdf_layout_optimizer.py +++ b/ReportEngine/renderers/pdf_layout_optimizer.py @@ -67,11 +67,22 @@ class ChartLayout: @dataclass class GridLayout: """网格布局配置""" - columns: int = 2 # 每行列数 + columns: int = 3 # 每行列数(正文默认三列) gap: int = 20 # 间距 responsive_breakpoint: int = 768 # 响应式断点(宽度) +@dataclass +class DataBlockLayout: + """数据块(色块、KPI、表格等)的缩放配置""" + overview_text_scale: float = 0.93 # 文章总览数据块文字缩放(轻微缩小) + overview_kpi_scale: float = 0.88 # 总览KPI缩放 + body_text_scale: float = 0.8 # 正文数据块文字缩放(大幅缩小) + body_kpi_scale: float = 0.76 # 正文KPI缩放 + min_overview_font: int = 12 # 总览最小字号 + min_body_font: int = 11 # 正文最小字号 + + @dataclass class PageLayout: """页面整体布局配置""" @@ -96,6 +107,7 @@ class PDFLayoutConfig: table: TableLayout chart: ChartLayout grid: GridLayout + data_block: DataBlockLayout # 优化策略配置 auto_adjust_font_size: bool = True # 自动调整字号 @@ -112,6 +124,7 @@ class PDFLayoutConfig: 'table': asdict(self.table), 'chart': asdict(self.chart), 'grid': asdict(self.grid), + 'data_block': asdict(self.data_block), 'auto_adjust_font_size': self.auto_adjust_font_size, 'auto_adjust_grid_columns': self.auto_adjust_grid_columns, 'prevent_orphan_headers': self.prevent_orphan_headers, @@ -128,6 +141,7 @@ class PDFLayoutConfig: table=TableLayout(**data['table']), chart=ChartLayout(**data['chart']), grid=GridLayout(**data['grid']), + data_block=DataBlockLayout(**data.get('data_block', {})), auto_adjust_font_size=data.get('auto_adjust_font_size', True), auto_adjust_grid_columns=data.get('auto_adjust_grid_columns', True), prevent_orphan_headers=data.get('prevent_orphan_headers', True), @@ -174,6 +188,7 @@ class PDFLayoutOptimizer: table=TableLayout(), chart=ChartLayout(), grid=GridLayout(), + data_block=DataBlockLayout(), ) def optimize_for_document(self, document_ir: Dict[str, Any]) -> PDFLayoutConfig: @@ -469,6 +484,7 @@ class PDFLayoutOptimizer: table=TableLayout(**asdict(self.config.table)), chart=ChartLayout(**asdict(self.config.chart)), grid=GridLayout(**asdict(self.config.grid)), + data_block=DataBlockLayout(**asdict(self.config.data_block)), auto_adjust_font_size=self.config.auto_adjust_font_size, auto_adjust_grid_columns=self.config.auto_adjust_grid_columns, prevent_orphan_headers=self.config.prevent_orphan_headers, @@ -531,30 +547,88 @@ class PDFLayoutOptimizer: f"预防性调整字号为{config.kpi_card.font_size_value}px" ) - # 根据KPI数量调整网格布局和间距 + # 收紧KPI字号上限,为正文数据块缩放留出空间 + base = config.page.font_size_base + kpi_value_cap = max(base + 6, 20) + kpi_label_cap = max(base - 1, 12) + kpi_change_cap = max(base, 12) + + original_value = config.kpi_card.font_size_value + original_label = config.kpi_card.font_size_label + original_change = config.kpi_card.font_size_change + + config.kpi_card.font_size_value = min(original_value, kpi_value_cap) + config.kpi_card.font_size_value = max(config.kpi_card.font_size_value, base + 1) + config.kpi_card.font_size_label = min(original_label, kpi_label_cap) + config.kpi_card.font_size_label = max(config.kpi_card.font_size_label, 12) + config.kpi_card.font_size_change = min(original_change, kpi_change_cap) + config.kpi_card.font_size_change = max(config.kpi_card.font_size_change, 12) + self.optimization_log.append( + f"KPI字号上限收紧:数值{original_value}px→{config.kpi_card.font_size_value}px," + f"标签{original_label}px→{config.kpi_card.font_size_label}px," + f"变动{original_change}px→{config.kpi_card.font_size_change}px" + ) + + total_blocks = (stats['kpi_count'] + stats['table_count'] + + stats['chart_count'] + stats['callout_count']) + + # 分开收紧文章总览与正文数据块的文字 + if stats['hero_kpi_count'] >= 3 or stats['max_hero_kpi_value_length'] > 6: + prev = config.data_block.overview_kpi_scale + config.data_block.overview_kpi_scale = min(prev, 0.86) + if config.data_block.overview_kpi_scale != prev: + self.optimization_log.append( + f"文章总览KPI较密集,缩放系数 {prev:.2f}→{config.data_block.overview_kpi_scale:.2f}" + ) + + if stats['has_long_text'] or stats['max_table_columns'] > 6: + prev_text = config.data_block.body_text_scale + prev_kpi = config.data_block.body_kpi_scale + config.data_block.body_text_scale = min(prev_text, 0.78) + config.data_block.body_kpi_scale = min(prev_kpi, 0.74) + self.optimization_log.append( + f"正文数据块紧缩:长文本/宽表触发,文字缩放至{config.data_block.body_text_scale*100:.0f}%," + f"KPI缩放至{config.data_block.body_kpi_scale*100:.0f}%" + ) + elif total_blocks > 16: + prev_text = config.data_block.body_text_scale + prev_kpi = config.data_block.body_kpi_scale + config.data_block.body_text_scale = min(prev_text, 0.80) + config.data_block.body_kpi_scale = min(prev_kpi, 0.75) + self.optimization_log.append( + f"正文数据块缩放:内容块较多({total_blocks}个),文字缩放至{config.data_block.body_text_scale*100:.0f}%," + f"KPI缩放至{config.data_block.body_kpi_scale*100:.0f}%" + ) + elif total_blocks > 10: + prev_text = config.data_block.body_text_scale + config.data_block.body_text_scale = min(prev_text, 0.82) + if config.data_block.body_text_scale != prev_text: + self.optimization_log.append( + f"正文数据块轻量缩放({total_blocks}个块),文字缩放系数 {prev_text:.2f}→{config.data_block.body_text_scale:.2f}" + ) + + # 根据KPI数量调整间距但保持正文默认三列装订 + config.grid.columns = 3 if stats['kpi_count'] > 6: - config.grid.columns = 3 config.kpi_card.min_height = 100 config.kpi_card.padding = 14 # 缩小padding以节省空间 config.grid.gap = 16 # 减小间距 self.optimization_log.append( f"KPI卡片较多({stats['kpi_count']}个)," - f"调整为3列布局并缩小内边距和间距" + f"保持三列布局并缩小内边距和间距" ) elif stats['kpi_count'] > 4: - config.grid.columns = 2 config.kpi_card.padding = 16 config.grid.gap = 18 self.optimization_log.append( - f"KPI卡片适中({stats['kpi_count']}个),使用2列布局" + f"KPI卡片适中({stats['kpi_count']}个),保持三列布局并适度调整间距" ) elif stats['kpi_count'] <= 2: - config.grid.columns = 1 config.kpi_card.padding = 22 # 较少卡片时增加padding config.grid.gap = 20 self.optimization_log.append( f"KPI卡片较少({stats['kpi_count']}个)," - f"使用1列布局并增加内边距" + f"保持三列布局并增加内边距" ) # 根据表格列数调整字号和间距 @@ -601,8 +675,6 @@ class PDFLayoutOptimizer: ) # 如果内容较多,减小整体字号 - total_blocks = (stats['kpi_count'] + stats['table_count'] + - stats['chart_count'] + stats['callout_count']) if total_blocks > 20: config.page.font_size_base = 13 config.page.font_size_h2 = 22 @@ -693,6 +765,32 @@ class PDFLayoutOptimizer: str: CSS样式字符串 """ cfg = self.config + db = cfg.data_block + + def _scaled(value: float, scale: float, minimum: int) -> int: + """按比例缩放并下限保护,避免数据块文字过大或过小""" + try: + return max(int(round(value * scale)), minimum) + except Exception: + return minimum + + # 文章总览数据块字体 + overview_summary_font = _scaled(cfg.page.font_size_base, db.overview_text_scale, db.min_overview_font) + overview_badge_font = _scaled(max(cfg.page.font_size_base - 2, db.min_overview_font), db.overview_text_scale, db.min_overview_font) + overview_kpi_value = _scaled(cfg.kpi_card.font_size_value, db.overview_kpi_scale, db.min_overview_font + 1) + overview_kpi_label = _scaled(cfg.kpi_card.font_size_label, db.overview_kpi_scale, db.min_overview_font) + overview_kpi_delta = _scaled(cfg.kpi_card.font_size_change, db.overview_kpi_scale, db.min_overview_font) + + # 正文数据块字体 + body_kpi_value = _scaled(cfg.kpi_card.font_size_value, db.body_kpi_scale, db.min_body_font + 1) + body_kpi_label = _scaled(cfg.kpi_card.font_size_label, db.body_kpi_scale, db.min_body_font) + body_kpi_delta = _scaled(cfg.kpi_card.font_size_change, db.body_kpi_scale, db.min_body_font) + body_callout_title = _scaled(cfg.callout.font_size_title, db.body_text_scale, db.min_body_font + 1) + body_callout_content = _scaled(cfg.callout.font_size_content, db.body_text_scale, db.min_body_font) + body_table_header = _scaled(cfg.table.font_size_header, db.body_text_scale, db.min_body_font) + body_table_body = _scaled(cfg.table.font_size_body, db.body_text_scale, db.min_body_font) + body_chart_title = _scaled(cfg.chart.font_size_title, db.body_text_scale, db.min_body_font + 1) + body_badge_font = _scaled(max(cfg.page.font_size_base - 2, db.min_body_font), db.body_text_scale, db.min_body_font) css = f""" /* PDF布局优化样式 - 由PDFLayoutOptimizer自动生成 */ @@ -734,12 +832,100 @@ p {{ margin-bottom: {cfg.page.section_spacing}px; }} -/* KPI卡片优化 - 防止溢出 */ +/* KPI卡片优化 - WeasyPrint不支持CSS Grid,使用Flex实现等宽排布 */ .kpi-grid {{ - display: grid; - grid-template-columns: repeat({cfg.grid.columns}, 1fr); + display: flex !important; + flex-wrap: wrap; gap: {cfg.grid.gap}px; margin: 20px 0; + align-items: stretch; + page-break-inside: avoid !important; + break-inside: avoid !important; + page-break-after: avoid !important; + page-break-before: avoid !important; + break-before: avoid !important; + break-after: avoid !important; +}} + +.kpi-grid .kpi-card {{ + box-sizing: border-box; + flex: 0 1 calc(33.333% - {cfg.grid.gap}px) !important; + max-width: calc(33.333% - {cfg.grid.gap}px) !important; +}} + +/* 单条/双条/三条的特殊列数 */ +.chapter .kpi-grid[data-kpi-count="1"] .kpi-card {{ + flex-basis: 100% !important; + max-width: 100% !important; +}} +.chapter .kpi-grid[data-kpi-count="2"] .kpi-card {{ + flex-basis: calc(50% - {cfg.grid.gap}px) !important; + max-width: calc(50% - {cfg.grid.gap}px) !important; +}} +.chapter .kpi-grid[data-kpi-count="3"] .kpi-card {{ + flex-basis: calc(33.333% - {cfg.grid.gap}px) !important; + max-width: calc(33.333% - {cfg.grid.gap}px) !important; +}} + +/* 四条时采用2x2排布 */ +.chapter .kpi-grid[data-kpi-count="4"] .kpi-card {{ + flex-basis: calc(50% - {cfg.grid.gap}px) !important; + max-width: calc(50% - {cfg.grid.gap}px) !important; +}} +.chapter .kpi-grid[data-kpi-count="4"] {{ + page-break-before: auto !important; + break-before: auto !important; + page-break-inside: avoid !important; + margin-top: 8px !important; +}} + +/* hr 与紧随的KPI/正文保持同页,减少多余空白 */ +hr {{ + page-break-before: avoid !important; + page-break-after: avoid !important; + break-before: avoid !important; + break-after: avoid !important; + margin: 12px 0 !important; +}} + +/* 五条及以上默认三列(6个自动两行3+3) */ +.chapter .kpi-grid[data-kpi-count="5"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="6"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="7"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="8"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="9"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="10"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="11"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="12"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="13"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="14"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="15"] .kpi-card, +.chapter .kpi-grid[data-kpi-count="16"] .kpi-card {{ + flex-basis: calc(33.333% - {cfg.grid.gap}px) !important; + max-width: calc(33.333% - {cfg.grid.gap}px) !important; +}} + +/* 5个时最后两张拉宽为两列 */ +.chapter .kpi-grid[data-kpi-count="5"] .kpi-card:nth-last-child(-n+2) {{ + flex-basis: calc(50% - {cfg.grid.gap}px) !important; + max-width: calc(50% - {cfg.grid.gap}px) !important; +}} + +/* 余数为2时,最后两张平分全宽 */ +.chapter .kpi-grid[data-kpi-count="8"] .kpi-card:nth-last-child(-n+2), +.chapter .kpi-grid[data-kpi-count="11"] .kpi-card:nth-last-child(-n+2), +.chapter .kpi-grid[data-kpi-count="14"] .kpi-card:nth-last-child(-n+2) {{ + flex-basis: calc(50% - {cfg.grid.gap}px) !important; + max-width: calc(50% - {cfg.grid.gap}px) !important; +}} + +/* 余数为1时,最后一张占满全宽 */ +.chapter .kpi-grid[data-kpi-count="7"] .kpi-card:last-child, +.chapter .kpi-grid[data-kpi-count="10"] .kpi-card:last-child, +.chapter .kpi-grid[data-kpi-count="13"] .kpi-card:last-child, +.chapter .kpi-grid[data-kpi-count="16"] .kpi-card:last-child {{ + flex-basis: 100% !important; + max-width: 100% !important; }} .kpi-card {{ @@ -747,35 +933,39 @@ p {{ min-height: {cfg.kpi_card.min_height}px; break-inside: avoid; page-break-inside: avoid; - /* 防止溢出的关键设置 */ - overflow: hidden; box-sizing: border-box; max-width: 100%; + height: auto; + display: flex; + flex-direction: column; + gap: 8px; }} -.kpi-card .value {{ - font-size: {cfg.kpi_card.font_size_value}px !important; - line-height: 1.2; - /* 强制换行和溢出控制 */ +.kpi-card .kpi-value {{ + font-size: {body_kpi_value}px !important; + line-height: 1.25; word-break: break-word; overflow-wrap: break-word; hyphens: auto; max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; -}} - -.kpi-card .label {{ - font-size: {cfg.kpi_card.font_size_label}px !important; - /* 防止标签溢出 */ + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 6px; +.kpi-card .kpi-label {{ + font-size: {body_kpi_label}px !important; word-break: break-word; overflow-wrap: break-word; max-width: 100%; + line-height: 1.35; }} -.kpi-card .change {{ - font-size: {cfg.kpi_card.font_size_change}px !important; +.kpi-card .change, +.kpi-card .delta {{ + font-size: {body_kpi_delta}px !important; word-break: break-word; + overflow-wrap: break-word; + line-height: 1.3; }} /* 提示框优化 - 防止溢出 */ @@ -783,6 +973,7 @@ p {{ padding: {cfg.callout.padding}px !important; margin: 20px 0; line-height: {cfg.callout.line_height}; + font-size: {body_callout_content}px !important; break-inside: avoid; page-break-inside: avoid; /* 防止溢出 */ @@ -792,19 +983,31 @@ p {{ }} .callout-title {{ - font-size: {cfg.callout.font_size_title}px !important; + font-size: {body_callout_title}px !important; margin-bottom: 10px; word-break: break-word; line-height: 1.4; }} .callout-content {{ - font-size: {cfg.callout.font_size_content}px !important; + font-size: {body_callout_content}px !important; word-break: break-word; overflow-wrap: break-word; line-height: {cfg.callout.line_height}; }} +.callout strong {{ + font-size: {body_callout_title}px !important; +}} + +.callout p, +.callout li, +.callout table, +.callout td, +.callout th {{ + font-size: {body_callout_content}px !important; +}} + /* 确保 callout 内部最后一个元素不会溢出底部 */ .callout > *:last-child, .callout > *:last-child > *:last-child {{ @@ -824,7 +1027,7 @@ table {{ }} th {{ - font-size: {cfg.table.font_size_header}px !important; + font-size: {body_table_header}px !important; padding: {cfg.table.cell_padding}px !important; /* 表头文字控制 */ word-break: break-word; @@ -834,7 +1037,7 @@ th {{ }} td {{ - font-size: {cfg.table.font_size_body}px !important; + font-size: {body_table_body}px !important; padding: {cfg.table.cell_padding}px !important; max-width: {cfg.table.max_cell_width}px; /* 强制换行,防止溢出 */ @@ -859,7 +1062,7 @@ td {{ }} .chart-title {{ - font-size: {cfg.chart.font_size_title}px !important; + font-size: {body_chart_title}px !important; word-break: break-word; }} @@ -928,19 +1131,26 @@ td {{ .hero-side {{ flex: 3; /* 右侧占30% */ min-width: 0; + min-height: 0; display: flex; - flex-direction: column; + flex-wrap: wrap; gap: {max(cfg.grid.gap - 2, 10)}px; overflow: hidden; box-sizing: border-box; + width: 100%; }} /* Hero区域的KPI卡片 - 横向拉长,每行显示一个内容 */ .hero-kpi {{ + background: #ffffff; + border-radius: 16px !important; + border: 1px solid rgba(0, 0, 0, 0.06); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08); + flex: 0 1 calc(50% - {max(cfg.grid.gap - 2, 10)}px); + max-width: calc(50% - {max(cfg.grid.gap - 2, 10)}px); padding: 12px 18px !important; /* 增加横向padding */ overflow: hidden; box-sizing: border-box; - max-width: 100%; min-height: 85px; /* 增加高度以容纳三行 */ display: flex; flex-direction: column; @@ -948,7 +1158,7 @@ td {{ }} .hero-kpi .label {{ - font-size: {max(cfg.kpi_card.font_size_label - 3, 9)}px !important; /* 减小标签字号 */ + font-size: {overview_kpi_label}px !important; /* 适度减小标签字号 */ word-break: break-word; max-width: 100%; line-height: 1.2; @@ -959,7 +1169,7 @@ td {{ }} .hero-kpi .value {{ - font-size: {max(cfg.kpi_card.font_size_value - 12, 14)}px !important; /* 减小数值字号 */ + font-size: {overview_kpi_value}px !important; /* 适度减小数值字号 */ word-break: break-word; overflow-wrap: break-word; max-width: 100%; @@ -972,7 +1182,7 @@ td {{ }} .hero-kpi .delta {{ - font-size: {max(cfg.kpi_card.font_size_change - 3, 9)}px !important; /* 减小变化值字号 */ + font-size: {overview_kpi_delta}px !important; /* 适度减小变化值字号 */ word-break: break-word; margin-top: 3px; display: block; /* 独占一行 */ @@ -984,7 +1194,7 @@ td {{ /* Hero summary文本 */ .hero-summary {{ - font-size: {cfg.page.font_size_base}px !important; + font-size: {overview_summary_font}px !important; line-height: 1.65; margin-top: 0; margin-bottom: 18px; /* 增加底部边距,与badges保持一致 */ @@ -1014,7 +1224,7 @@ td {{ /* hero highlights中的badge - 拉长加宽的椭圆形背景,与上方文本对齐 */ .hero-highlights .badge {{ - font-size: {max(cfg.callout.font_size_content - 3, 10)}px !important; + font-size: {overview_badge_font}px !important; padding: 10px 20px !important; /* 增加padding,更好的视觉效果 */ max-width: 100%; width: 98%; /* 占满宽度,与summary文本对齐 */ @@ -1105,7 +1315,7 @@ main > .chapter:first-of-type {{ white-space: normal; /* 限制badge的最大尺寸 */ padding: 4px 12px !important; - font-size: {max(cfg.page.font_size_base - 2, 12)}px !important; + font-size: {body_badge_font}px !important; line-height: 1.4 !important; /* 防止badge异常过大 */ word-break: break-word; @@ -1147,4 +1357,5 @@ __all__ = [ 'TableLayout', 'ChartLayout', 'GridLayout', + 'DataBlockLayout', ]