马一丁

PDF Enhancement Generation

@@ -68,6 +68,17 @@ class ChartToSVGConverter: @@ -68,6 +68,17 @@ class ChartToSVGConverter:
68 'var(--color-success)': '#50C878', # 翠绿色(从#28A745改为更明亮) 68 'var(--color-success)': '#50C878', # 翠绿色(从#28A745改为更明亮)
69 'var(--re-success-color)': '#50C878', # 翠绿色 69 'var(--re-success-color)': '#50C878', # 翠绿色
70 'var(--re-success-color-translucent)': (0.314, 0.784, 0.471, 0.08), # 绿色极浅透明 rgba(80, 200, 120, 0.08) 70 'var(--re-success-color-translucent)': (0.314, 0.784, 0.471, 0.08), # 绿色极浅透明 rgba(80, 200, 120, 0.08)
  71 + 'var(--color-accent-positive)': '#50C878',
  72 + 'var(--color-accent-negative)': '#E85D75',
  73 + 'var(--color-text-secondary)': '#6B7280',
  74 + 'var(--accentPositive)': '#50C878',
  75 + 'var(--accentNegative)': '#E85D75',
  76 + 'var(--sentiment-positive, #28A745)': '#28A745',
  77 + 'var(--sentiment-negative, #E53E3E)': '#E53E3E',
  78 + 'var(--sentiment-neutral, #FFC107)': '#FFC107',
  79 + 'var(--sentiment-positive)': '#28A745',
  80 + 'var(--sentiment-negative)': '#E53E3E',
  81 + 'var(--sentiment-neutral)': '#FFC107',
71 'var(--color-primary)': '#3498DB', # 天蓝色 82 'var(--color-primary)': '#3498DB', # 天蓝色
72 'var(--color-secondary)': '#95A5A6', # 浅灰色 83 'var(--color-secondary)': '#95A5A6', # 浅灰色
73 } 84 }
@@ -225,6 +236,13 @@ class ChartToSVGConverter: @@ -225,6 +236,13 @@ class ChartToSVGConverter:
225 # 【增强】处理CSS变量,例如 var(--color-accent) 236 # 【增强】处理CSS变量,例如 var(--color-accent)
226 # 使用预定义的颜色映射表替代CSS变量,确保不同变量有不同的颜色 237 # 使用预定义的颜色映射表替代CSS变量,确保不同变量有不同的颜色
227 if color.startswith('var('): 238 if color.startswith('var('):
  239 + # 解析 var(--token, fallback) 形式
  240 + fb_match = re.match(r'^var\(\s*--[^,)+]+,\s*([^)]+)\)', color)
  241 + if fb_match:
  242 + fb_raw = fb_match.group(1).strip()
  243 + fb_color = self._parse_color(fb_raw)
  244 + if fb_color:
  245 + return fb_color
228 # 尝试从映射表中查找对应的颜色 246 # 尝试从映射表中查找对应的颜色
229 mapped_color = self.CSS_VAR_COLOR_MAP.get(color) 247 mapped_color = self.CSS_VAR_COLOR_MAP.get(color)
230 if mapped_color: 248 if mapped_color:
@@ -406,7 +424,7 @@ class ChartToSVGConverter: @@ -406,7 +424,7 @@ class ChartToSVGConverter:
406 424
407 # 获取配置 425 # 获取配置
408 y_axis_id = dataset.get('yAxisID', 'y') 426 y_axis_id = dataset.get('yAxisID', 'y')
409 - fill = dataset.get('fill', False) 427 + fill = True # 强制开启填充,便于对比
410 tension = dataset.get('tension', 0) # 0表示直线,0.4表示平滑曲线 428 tension = dataset.get('tension', 0) # 0表示直线,0.4表示平滑曲线
411 border_color = self._parse_color(dataset.get('borderColor', color)) 429 border_color = self._parse_color(dataset.get('borderColor', color))
412 background_color = self._parse_color(dataset.get('backgroundColor', color)) 430 background_color = self._parse_color(dataset.get('backgroundColor', color))
@@ -449,7 +467,7 @@ class ChartToSVGConverter: @@ -449,7 +467,7 @@ class ChartToSVGConverter:
449 color=border_color, linewidth=2, markersize=6) 467 color=border_color, linewidth=2, markersize=6)
450 468
451 if fill: 469 if fill:
452 - ax.fill_between(x_data, y_data, alpha=0.08, color=background_color) 470 + ax.fill_between(x_data, y_data, alpha=0.2, color=background_color)
453 471
454 for pos, y_val, text in zip(x_data, y_data, annotations): 472 for pos, y_val, text in zip(x_data, y_data, annotations):
455 if text: 473 if text:
@@ -478,18 +496,18 @@ class ChartToSVGConverter: @@ -478,18 +496,18 @@ class ChartToSVGConverter:
478 496
479 # 如果需要填充(使用极低透明度避免遮挡) 497 # 如果需要填充(使用极低透明度避免遮挡)
480 if fill: 498 if fill:
481 - ax.fill_between(x_smooth, y_smooth, alpha=0.08, color=background_color) 499 + ax.fill_between(x_smooth, y_smooth, alpha=0.2, color=background_color)
482 except: 500 except:
483 # 如果平滑失败,使用普通折线 501 # 如果平滑失败,使用普通折线
484 line, = ax.plot(x_data, dataset_data, marker='o', label=label, 502 line, = ax.plot(x_data, dataset_data, marker='o', label=label,
485 color=border_color, linewidth=2, markersize=6) 503 color=border_color, linewidth=2, markersize=6)
486 if fill: 504 if fill:
487 - ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) 505 + ax.fill_between(x_data, dataset_data, alpha=0.2, color=background_color)
488 else: 506 else:
489 line, = ax.plot(x_data, dataset_data, marker='o', label=label, 507 line, = ax.plot(x_data, dataset_data, marker='o', label=label,
490 color=border_color, linewidth=2, markersize=6) 508 color=border_color, linewidth=2, markersize=6)
491 if fill: 509 if fill:
492 - ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) 510 + ax.fill_between(x_data, dataset_data, alpha=0.2, color=background_color)
493 else: 511 else:
494 # 直线连接(tension=0或scipy不可用) 512 # 直线连接(tension=0或scipy不可用)
495 line, = ax.plot(x_data, dataset_data, marker='o', label=label, 513 line, = ax.plot(x_data, dataset_data, marker='o', label=label,
@@ -497,7 +515,7 @@ class ChartToSVGConverter: @@ -497,7 +515,7 @@ class ChartToSVGConverter:
497 515
498 # 如果需要填充(使用极低透明度避免遮挡) 516 # 如果需要填充(使用极低透明度避免遮挡)
499 if fill: 517 if fill:
500 - ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) 518 + ax.fill_between(x_data, dataset_data, alpha=0.2, color=background_color)
501 519
502 # 记录这条线属于哪个轴 520 # 记录这条线属于哪个轴
503 axis_lines[y_axis_id].append(line) 521 axis_lines[y_axis_id].append(line)
@@ -1345,7 +1345,8 @@ class HTMLRenderer: @@ -1345,7 +1345,8 @@ class HTMLRenderer:
1345 if self._should_skip_overview_kpi(block): 1345 if self._should_skip_overview_kpi(block):
1346 return "" 1346 return ""
1347 cards = "" 1347 cards = ""
1348 - for item in block.get("items", []): 1348 + items = block.get("items", [])
  1349 + for item in items:
1349 delta = item.get("delta") 1350 delta = item.get("delta")
1350 delta_tone = item.get("deltaTone") or "neutral" 1351 delta_tone = item.get("deltaTone") or "neutral"
1351 delta_html = f'<span class="delta {delta_tone}">{self._escape_html(delta)}</span>' if delta else "" 1352 delta_html = f'<span class="delta {delta_tone}">{self._escape_html(delta)}</span>' if delta else ""
@@ -1356,7 +1357,8 @@ class HTMLRenderer: @@ -1356,7 +1357,8 @@ class HTMLRenderer:
1356 {delta_html} 1357 {delta_html}
1357 </div> 1358 </div>
1358 """ 1359 """
1359 - return f'<div class="kpi-grid">{cards}</div>' 1360 + count_attr = f' data-kpi-count="{len(items)}"' if items else ""
  1361 + return f'<div class="kpi-grid"{count_attr}>{cards}</div>'
1360 1362
1361 def _merge_dicts( 1363 def _merge_dicts(
1362 self, base: Dict[str, Any] | None, override: Dict[str, Any] | None 1364 self, base: Dict[str, Any] | None, override: Dict[str, Any] | None
@@ -2632,21 +2634,41 @@ table th {{ @@ -2632,21 +2634,41 @@ table th {{
2632 margin: 20px 0; 2634 margin: 20px 0;
2633 }} 2635 }}
2634 .kpi-card {{ 2636 .kpi-card {{
  2637 + display: flex;
  2638 + flex-direction: column;
  2639 + gap: 8px;
2635 padding: 16px; 2640 padding: 16px;
2636 border-radius: 12px; 2641 border-radius: 12px;
2637 background: rgba(0,0,0,0.02); 2642 background: rgba(0,0,0,0.02);
2638 border: 1px solid var(--border-color); 2643 border: 1px solid var(--border-color);
  2644 + align-items: flex-start;
2639 }} 2645 }}
2640 .kpi-value {{ 2646 .kpi-value {{
2641 font-size: 2rem; 2647 font-size: 2rem;
2642 font-weight: 700; 2648 font-weight: 700;
  2649 + display: flex;
  2650 + flex-wrap: wrap;
  2651 + gap: 4px 6px;
  2652 + line-height: 1.25;
  2653 + word-break: break-word;
  2654 + overflow-wrap: break-word;
2643 }} 2655 }}
2644 .kpi-label {{ 2656 .kpi-label {{
2645 color: var(--secondary-color); 2657 color: var(--secondary-color);
  2658 + line-height: 1.35;
  2659 + word-break: break-word;
  2660 + overflow-wrap: break-word;
  2661 + max-width: 100%;
2646 }} 2662 }}
2647 .delta.up {{ color: #27ae60; }} 2663 .delta.up {{ color: #27ae60; }}
2648 .delta.down {{ color: #e74c3c; }} 2664 .delta.down {{ color: #e74c3c; }}
2649 .delta.neutral {{ color: var(--secondary-color); }} 2665 .delta.neutral {{ color: var(--secondary-color); }}
  2666 +.delta {{
  2667 + display: block;
  2668 + line-height: 1.3;
  2669 + word-break: break-word;
  2670 + overflow-wrap: break-word;
  2671 +}}
2650 .chart-card {{ 2672 .chart-card {{
2651 margin: 30px 0; 2673 margin: 30px 0;
2652 padding: 20px; 2674 padding: 20px;
@@ -2879,6 +2901,17 @@ const CSS_VAR_COLOR_MAP = { @@ -2879,6 +2901,17 @@ const CSS_VAR_COLOR_MAP = {
2879 'var(--color-success)': '#50C878', 2901 'var(--color-success)': '#50C878',
2880 'var(--re-success-color)': '#50C878', 2902 'var(--re-success-color)': '#50C878',
2881 'var(--re-success-color-translucent)': 'rgba(80, 200, 120, 0.08)', 2903 'var(--re-success-color-translucent)': 'rgba(80, 200, 120, 0.08)',
  2904 + 'var(--color-accent-positive)': '#50C878',
  2905 + 'var(--color-accent-negative)': '#E85D75',
  2906 + 'var(--color-text-secondary)': '#6B7280',
  2907 + 'var(--accentPositive)': '#50C878',
  2908 + 'var(--accentNegative)': '#E85D75',
  2909 + 'var(--sentiment-positive, #28A745)': '#28A745',
  2910 + 'var(--sentiment-negative, #E53E3E)': '#E53E3E',
  2911 + 'var(--sentiment-neutral, #FFC107)': '#FFC107',
  2912 + 'var(--sentiment-positive)': '#28A745',
  2913 + 'var(--sentiment-negative)': '#E53E3E',
  2914 + 'var(--sentiment-neutral)': '#FFC107',
2882 'var(--color-primary)': '#3498DB', 2915 'var(--color-primary)': '#3498DB',
2883 'var(--color-secondary)': '#95A5A6' 2916 'var(--color-secondary)': '#95A5A6'
2884 }; 2917 };
@@ -2893,6 +2926,13 @@ function normalizeColorToken(color) { @@ -2893,6 +2926,13 @@ function normalizeColorToken(color) {
2893 if (typeof color !== 'string') return color; 2926 if (typeof color !== 'string') return color;
2894 const trimmed = color.trim(); 2927 const trimmed = color.trim();
2895 if (!trimmed) return null; 2928 if (!trimmed) return null;
  2929 + // 支持 var(--token, fallback) 形式,优先解析fallback
  2930 + const varWithFallback = trimmed.match(/^var\(\s*--[^,)+]+,\s*([^)]+)\)/i);
  2931 + if (varWithFallback && varWithFallback[1]) {
  2932 + const fallback = varWithFallback[1].trim();
  2933 + const normalizedFallback = normalizeColorToken(fallback);
  2934 + if (normalizedFallback) return normalizedFallback;
  2935 + }
2896 if (CSS_VAR_COLOR_MAP[trimmed]) { 2936 if (CSS_VAR_COLOR_MAP[trimmed]) {
2897 return CSS_VAR_COLOR_MAP[trimmed]; 2937 return CSS_VAR_COLOR_MAP[trimmed];
2898 } 2938 }
@@ -2979,6 +3019,9 @@ function normalizeDatasetColors(payload, chartType) { @@ -2979,6 +3019,9 @@ function normalizeDatasetColors(payload, chartType) {
2979 3019
2980 data.datasets.forEach((dataset, idx) => { 3020 data.datasets.forEach((dataset, idx) => {
2981 if (!isPlainObject(dataset)) return; 3021 if (!isPlainObject(dataset)) return;
  3022 + if (type === 'line') {
  3023 + dataset.fill = true; // 对折线图强制开启填充,便于区域对比
  3024 + }
2982 const paletteColor = normalizeColorToken(DEFAULT_CHART_COLORS[idx % DEFAULT_CHART_COLORS.length]); 3025 const paletteColor = normalizeColorToken(DEFAULT_CHART_COLORS[idx % DEFAULT_CHART_COLORS.length]);
2983 const borderInput = dataset.borderColor; 3026 const borderInput = dataset.borderColor;
2984 const backgroundInput = dataset.backgroundColor; 3027 const backgroundInput = dataset.backgroundColor;
@@ -3013,7 +3056,7 @@ function normalizeDatasetColors(payload, chartType) { @@ -3013,7 +3056,7 @@ function normalizeDatasetColors(payload, chartType) {
3013 } 3056 }
3014 3057
3015 const typeAlpha = type === 'line' 3058 const typeAlpha = type === 'line'
3016 - ? (dataset.fill ? 0.08 : 0.12) 3059 + ? (dataset.fill ? 0.25 : 0.18)
3017 : type === 'radar' 3060 : type === 'radar'
3018 ? 0.25 3061 ? 0.25
3019 : type === 'scatter' || type === 'bubble' 3062 : type === 'scatter' || type === 'bubble'
@@ -67,12 +67,23 @@ class ChartLayout: @@ -67,12 +67,23 @@ class ChartLayout:
67 @dataclass 67 @dataclass
68 class GridLayout: 68 class GridLayout:
69 """网格布局配置""" 69 """网格布局配置"""
70 - columns: int = 2 # 每行列数 70 + columns: int = 3 # 每行列数(正文默认三列)
71 gap: int = 20 # 间距 71 gap: int = 20 # 间距
72 responsive_breakpoint: int = 768 # 响应式断点(宽度) 72 responsive_breakpoint: int = 768 # 响应式断点(宽度)
73 73
74 74
75 @dataclass 75 @dataclass
  76 +class DataBlockLayout:
  77 + """数据块(色块、KPI、表格等)的缩放配置"""
  78 + overview_text_scale: float = 0.93 # 文章总览数据块文字缩放(轻微缩小)
  79 + overview_kpi_scale: float = 0.88 # 总览KPI缩放
  80 + body_text_scale: float = 0.8 # 正文数据块文字缩放(大幅缩小)
  81 + body_kpi_scale: float = 0.76 # 正文KPI缩放
  82 + min_overview_font: int = 12 # 总览最小字号
  83 + min_body_font: int = 11 # 正文最小字号
  84 +
  85 +
  86 +@dataclass
76 class PageLayout: 87 class PageLayout:
77 """页面整体布局配置""" 88 """页面整体布局配置"""
78 font_size_base: int = 14 # 基础字号 89 font_size_base: int = 14 # 基础字号
@@ -96,6 +107,7 @@ class PDFLayoutConfig: @@ -96,6 +107,7 @@ class PDFLayoutConfig:
96 table: TableLayout 107 table: TableLayout
97 chart: ChartLayout 108 chart: ChartLayout
98 grid: GridLayout 109 grid: GridLayout
  110 + data_block: DataBlockLayout
99 111
100 # 优化策略配置 112 # 优化策略配置
101 auto_adjust_font_size: bool = True # 自动调整字号 113 auto_adjust_font_size: bool = True # 自动调整字号
@@ -112,6 +124,7 @@ class PDFLayoutConfig: @@ -112,6 +124,7 @@ class PDFLayoutConfig:
112 'table': asdict(self.table), 124 'table': asdict(self.table),
113 'chart': asdict(self.chart), 125 'chart': asdict(self.chart),
114 'grid': asdict(self.grid), 126 'grid': asdict(self.grid),
  127 + 'data_block': asdict(self.data_block),
115 'auto_adjust_font_size': self.auto_adjust_font_size, 128 'auto_adjust_font_size': self.auto_adjust_font_size,
116 'auto_adjust_grid_columns': self.auto_adjust_grid_columns, 129 'auto_adjust_grid_columns': self.auto_adjust_grid_columns,
117 'prevent_orphan_headers': self.prevent_orphan_headers, 130 'prevent_orphan_headers': self.prevent_orphan_headers,
@@ -128,6 +141,7 @@ class PDFLayoutConfig: @@ -128,6 +141,7 @@ class PDFLayoutConfig:
128 table=TableLayout(**data['table']), 141 table=TableLayout(**data['table']),
129 chart=ChartLayout(**data['chart']), 142 chart=ChartLayout(**data['chart']),
130 grid=GridLayout(**data['grid']), 143 grid=GridLayout(**data['grid']),
  144 + data_block=DataBlockLayout(**data.get('data_block', {})),
131 auto_adjust_font_size=data.get('auto_adjust_font_size', True), 145 auto_adjust_font_size=data.get('auto_adjust_font_size', True),
132 auto_adjust_grid_columns=data.get('auto_adjust_grid_columns', True), 146 auto_adjust_grid_columns=data.get('auto_adjust_grid_columns', True),
133 prevent_orphan_headers=data.get('prevent_orphan_headers', True), 147 prevent_orphan_headers=data.get('prevent_orphan_headers', True),
@@ -174,6 +188,7 @@ class PDFLayoutOptimizer: @@ -174,6 +188,7 @@ class PDFLayoutOptimizer:
174 table=TableLayout(), 188 table=TableLayout(),
175 chart=ChartLayout(), 189 chart=ChartLayout(),
176 grid=GridLayout(), 190 grid=GridLayout(),
  191 + data_block=DataBlockLayout(),
177 ) 192 )
178 193
179 def optimize_for_document(self, document_ir: Dict[str, Any]) -> PDFLayoutConfig: 194 def optimize_for_document(self, document_ir: Dict[str, Any]) -> PDFLayoutConfig:
@@ -469,6 +484,7 @@ class PDFLayoutOptimizer: @@ -469,6 +484,7 @@ class PDFLayoutOptimizer:
469 table=TableLayout(**asdict(self.config.table)), 484 table=TableLayout(**asdict(self.config.table)),
470 chart=ChartLayout(**asdict(self.config.chart)), 485 chart=ChartLayout(**asdict(self.config.chart)),
471 grid=GridLayout(**asdict(self.config.grid)), 486 grid=GridLayout(**asdict(self.config.grid)),
  487 + data_block=DataBlockLayout(**asdict(self.config.data_block)),
472 auto_adjust_font_size=self.config.auto_adjust_font_size, 488 auto_adjust_font_size=self.config.auto_adjust_font_size,
473 auto_adjust_grid_columns=self.config.auto_adjust_grid_columns, 489 auto_adjust_grid_columns=self.config.auto_adjust_grid_columns,
474 prevent_orphan_headers=self.config.prevent_orphan_headers, 490 prevent_orphan_headers=self.config.prevent_orphan_headers,
@@ -531,30 +547,88 @@ class PDFLayoutOptimizer: @@ -531,30 +547,88 @@ class PDFLayoutOptimizer:
531 f"预防性调整字号为{config.kpi_card.font_size_value}px" 547 f"预防性调整字号为{config.kpi_card.font_size_value}px"
532 ) 548 )
533 549
534 - # 根据KPI数量调整网格布局和间距 550 + # 收紧KPI字号上限,为正文数据块缩放留出空间
  551 + base = config.page.font_size_base
  552 + kpi_value_cap = max(base + 6, 20)
  553 + kpi_label_cap = max(base - 1, 12)
  554 + kpi_change_cap = max(base, 12)
  555 +
  556 + original_value = config.kpi_card.font_size_value
  557 + original_label = config.kpi_card.font_size_label
  558 + original_change = config.kpi_card.font_size_change
  559 +
  560 + config.kpi_card.font_size_value = min(original_value, kpi_value_cap)
  561 + config.kpi_card.font_size_value = max(config.kpi_card.font_size_value, base + 1)
  562 + config.kpi_card.font_size_label = min(original_label, kpi_label_cap)
  563 + config.kpi_card.font_size_label = max(config.kpi_card.font_size_label, 12)
  564 + config.kpi_card.font_size_change = min(original_change, kpi_change_cap)
  565 + config.kpi_card.font_size_change = max(config.kpi_card.font_size_change, 12)
  566 + self.optimization_log.append(
  567 + f"KPI字号上限收紧:数值{original_value}px→{config.kpi_card.font_size_value}px,"
  568 + f"标签{original_label}px→{config.kpi_card.font_size_label}px,"
  569 + f"变动{original_change}px→{config.kpi_card.font_size_change}px"
  570 + )
  571 +
  572 + total_blocks = (stats['kpi_count'] + stats['table_count'] +
  573 + stats['chart_count'] + stats['callout_count'])
  574 +
  575 + # 分开收紧文章总览与正文数据块的文字
  576 + if stats['hero_kpi_count'] >= 3 or stats['max_hero_kpi_value_length'] > 6:
  577 + prev = config.data_block.overview_kpi_scale
  578 + config.data_block.overview_kpi_scale = min(prev, 0.86)
  579 + if config.data_block.overview_kpi_scale != prev:
  580 + self.optimization_log.append(
  581 + f"文章总览KPI较密集,缩放系数 {prev:.2f}→{config.data_block.overview_kpi_scale:.2f}"
  582 + )
  583 +
  584 + if stats['has_long_text'] or stats['max_table_columns'] > 6:
  585 + prev_text = config.data_block.body_text_scale
  586 + prev_kpi = config.data_block.body_kpi_scale
  587 + config.data_block.body_text_scale = min(prev_text, 0.78)
  588 + config.data_block.body_kpi_scale = min(prev_kpi, 0.74)
  589 + self.optimization_log.append(
  590 + f"正文数据块紧缩:长文本/宽表触发,文字缩放至{config.data_block.body_text_scale*100:.0f}%,"
  591 + f"KPI缩放至{config.data_block.body_kpi_scale*100:.0f}%"
  592 + )
  593 + elif total_blocks > 16:
  594 + prev_text = config.data_block.body_text_scale
  595 + prev_kpi = config.data_block.body_kpi_scale
  596 + config.data_block.body_text_scale = min(prev_text, 0.80)
  597 + config.data_block.body_kpi_scale = min(prev_kpi, 0.75)
  598 + self.optimization_log.append(
  599 + f"正文数据块缩放:内容块较多({total_blocks}个),文字缩放至{config.data_block.body_text_scale*100:.0f}%,"
  600 + f"KPI缩放至{config.data_block.body_kpi_scale*100:.0f}%"
  601 + )
  602 + elif total_blocks > 10:
  603 + prev_text = config.data_block.body_text_scale
  604 + config.data_block.body_text_scale = min(prev_text, 0.82)
  605 + if config.data_block.body_text_scale != prev_text:
  606 + self.optimization_log.append(
  607 + f"正文数据块轻量缩放({total_blocks}个块),文字缩放系数 {prev_text:.2f}→{config.data_block.body_text_scale:.2f}"
  608 + )
  609 +
  610 + # 根据KPI数量调整间距但保持正文默认三列装订
  611 + config.grid.columns = 3
535 if stats['kpi_count'] > 6: 612 if stats['kpi_count'] > 6:
536 - config.grid.columns = 3  
537 config.kpi_card.min_height = 100 613 config.kpi_card.min_height = 100
538 config.kpi_card.padding = 14 # 缩小padding以节省空间 614 config.kpi_card.padding = 14 # 缩小padding以节省空间
539 config.grid.gap = 16 # 减小间距 615 config.grid.gap = 16 # 减小间距
540 self.optimization_log.append( 616 self.optimization_log.append(
541 f"KPI卡片较多({stats['kpi_count']}个)," 617 f"KPI卡片较多({stats['kpi_count']}个),"
542 - f"调整为3列布局并缩小内边距和间距" 618 + f"保持三列布局并缩小内边距和间距"
543 ) 619 )
544 elif stats['kpi_count'] > 4: 620 elif stats['kpi_count'] > 4:
545 - config.grid.columns = 2  
546 config.kpi_card.padding = 16 621 config.kpi_card.padding = 16
547 config.grid.gap = 18 622 config.grid.gap = 18
548 self.optimization_log.append( 623 self.optimization_log.append(
549 - f"KPI卡片适中({stats['kpi_count']}个),使用2列布局" 624 + f"KPI卡片适中({stats['kpi_count']}个),保持三列布局并适度调整间距"
550 ) 625 )
551 elif stats['kpi_count'] <= 2: 626 elif stats['kpi_count'] <= 2:
552 - config.grid.columns = 1  
553 config.kpi_card.padding = 22 # 较少卡片时增加padding 627 config.kpi_card.padding = 22 # 较少卡片时增加padding
554 config.grid.gap = 20 628 config.grid.gap = 20
555 self.optimization_log.append( 629 self.optimization_log.append(
556 f"KPI卡片较少({stats['kpi_count']}个)," 630 f"KPI卡片较少({stats['kpi_count']}个),"
557 - f"使用1列布局并增加内边距" 631 + f"保持三列布局并增加内边距"
558 ) 632 )
559 633
560 # 根据表格列数调整字号和间距 634 # 根据表格列数调整字号和间距
@@ -601,8 +675,6 @@ class PDFLayoutOptimizer: @@ -601,8 +675,6 @@ class PDFLayoutOptimizer:
601 ) 675 )
602 676
603 # 如果内容较多,减小整体字号 677 # 如果内容较多,减小整体字号
604 - total_blocks = (stats['kpi_count'] + stats['table_count'] +  
605 - stats['chart_count'] + stats['callout_count'])  
606 if total_blocks > 20: 678 if total_blocks > 20:
607 config.page.font_size_base = 13 679 config.page.font_size_base = 13
608 config.page.font_size_h2 = 22 680 config.page.font_size_h2 = 22
@@ -693,6 +765,32 @@ class PDFLayoutOptimizer: @@ -693,6 +765,32 @@ class PDFLayoutOptimizer:
693 str: CSS样式字符串 765 str: CSS样式字符串
694 """ 766 """
695 cfg = self.config 767 cfg = self.config
  768 + db = cfg.data_block
  769 +
  770 + def _scaled(value: float, scale: float, minimum: int) -> int:
  771 + """按比例缩放并下限保护,避免数据块文字过大或过小"""
  772 + try:
  773 + return max(int(round(value * scale)), minimum)
  774 + except Exception:
  775 + return minimum
  776 +
  777 + # 文章总览数据块字体
  778 + overview_summary_font = _scaled(cfg.page.font_size_base, db.overview_text_scale, db.min_overview_font)
  779 + overview_badge_font = _scaled(max(cfg.page.font_size_base - 2, db.min_overview_font), db.overview_text_scale, db.min_overview_font)
  780 + overview_kpi_value = _scaled(cfg.kpi_card.font_size_value, db.overview_kpi_scale, db.min_overview_font + 1)
  781 + overview_kpi_label = _scaled(cfg.kpi_card.font_size_label, db.overview_kpi_scale, db.min_overview_font)
  782 + overview_kpi_delta = _scaled(cfg.kpi_card.font_size_change, db.overview_kpi_scale, db.min_overview_font)
  783 +
  784 + # 正文数据块字体
  785 + body_kpi_value = _scaled(cfg.kpi_card.font_size_value, db.body_kpi_scale, db.min_body_font + 1)
  786 + body_kpi_label = _scaled(cfg.kpi_card.font_size_label, db.body_kpi_scale, db.min_body_font)
  787 + body_kpi_delta = _scaled(cfg.kpi_card.font_size_change, db.body_kpi_scale, db.min_body_font)
  788 + body_callout_title = _scaled(cfg.callout.font_size_title, db.body_text_scale, db.min_body_font + 1)
  789 + body_callout_content = _scaled(cfg.callout.font_size_content, db.body_text_scale, db.min_body_font)
  790 + body_table_header = _scaled(cfg.table.font_size_header, db.body_text_scale, db.min_body_font)
  791 + body_table_body = _scaled(cfg.table.font_size_body, db.body_text_scale, db.min_body_font)
  792 + body_chart_title = _scaled(cfg.chart.font_size_title, db.body_text_scale, db.min_body_font + 1)
  793 + body_badge_font = _scaled(max(cfg.page.font_size_base - 2, db.min_body_font), db.body_text_scale, db.min_body_font)
696 794
697 css = f""" 795 css = f"""
698 /* PDF布局优化样式 - 由PDFLayoutOptimizer自动生成 */ 796 /* PDF布局优化样式 - 由PDFLayoutOptimizer自动生成 */
@@ -734,12 +832,100 @@ p {{ @@ -734,12 +832,100 @@ p {{
734 margin-bottom: {cfg.page.section_spacing}px; 832 margin-bottom: {cfg.page.section_spacing}px;
735 }} 833 }}
736 834
737 -/* KPI卡片优化 - 防止溢出 */ 835 +/* KPI卡片优化 - WeasyPrint不支持CSS Grid,使用Flex实现等宽排布 */
738 .kpi-grid {{ 836 .kpi-grid {{
739 - display: grid;  
740 - grid-template-columns: repeat({cfg.grid.columns}, 1fr); 837 + display: flex !important;
  838 + flex-wrap: wrap;
741 gap: {cfg.grid.gap}px; 839 gap: {cfg.grid.gap}px;
742 margin: 20px 0; 840 margin: 20px 0;
  841 + align-items: stretch;
  842 + page-break-inside: avoid !important;
  843 + break-inside: avoid !important;
  844 + page-break-after: avoid !important;
  845 + page-break-before: avoid !important;
  846 + break-before: avoid !important;
  847 + break-after: avoid !important;
  848 +}}
  849 +
  850 +.kpi-grid .kpi-card {{
  851 + box-sizing: border-box;
  852 + flex: 0 1 calc(33.333% - {cfg.grid.gap}px) !important;
  853 + max-width: calc(33.333% - {cfg.grid.gap}px) !important;
  854 +}}
  855 +
  856 +/* 单条/双条/三条的特殊列数 */
  857 +.chapter .kpi-grid[data-kpi-count="1"] .kpi-card {{
  858 + flex-basis: 100% !important;
  859 + max-width: 100% !important;
  860 +}}
  861 +.chapter .kpi-grid[data-kpi-count="2"] .kpi-card {{
  862 + flex-basis: calc(50% - {cfg.grid.gap}px) !important;
  863 + max-width: calc(50% - {cfg.grid.gap}px) !important;
  864 +}}
  865 +.chapter .kpi-grid[data-kpi-count="3"] .kpi-card {{
  866 + flex-basis: calc(33.333% - {cfg.grid.gap}px) !important;
  867 + max-width: calc(33.333% - {cfg.grid.gap}px) !important;
  868 +}}
  869 +
  870 +/* 四条时采用2x2排布 */
  871 +.chapter .kpi-grid[data-kpi-count="4"] .kpi-card {{
  872 + flex-basis: calc(50% - {cfg.grid.gap}px) !important;
  873 + max-width: calc(50% - {cfg.grid.gap}px) !important;
  874 +}}
  875 +.chapter .kpi-grid[data-kpi-count="4"] {{
  876 + page-break-before: auto !important;
  877 + break-before: auto !important;
  878 + page-break-inside: avoid !important;
  879 + margin-top: 8px !important;
  880 +}}
  881 +
  882 +/* hr 与紧随的KPI/正文保持同页,减少多余空白 */
  883 +hr {{
  884 + page-break-before: avoid !important;
  885 + page-break-after: avoid !important;
  886 + break-before: avoid !important;
  887 + break-after: avoid !important;
  888 + margin: 12px 0 !important;
  889 +}}
  890 +
  891 +/* 五条及以上默认三列(6个自动两行3+3) */
  892 +.chapter .kpi-grid[data-kpi-count="5"] .kpi-card,
  893 +.chapter .kpi-grid[data-kpi-count="6"] .kpi-card,
  894 +.chapter .kpi-grid[data-kpi-count="7"] .kpi-card,
  895 +.chapter .kpi-grid[data-kpi-count="8"] .kpi-card,
  896 +.chapter .kpi-grid[data-kpi-count="9"] .kpi-card,
  897 +.chapter .kpi-grid[data-kpi-count="10"] .kpi-card,
  898 +.chapter .kpi-grid[data-kpi-count="11"] .kpi-card,
  899 +.chapter .kpi-grid[data-kpi-count="12"] .kpi-card,
  900 +.chapter .kpi-grid[data-kpi-count="13"] .kpi-card,
  901 +.chapter .kpi-grid[data-kpi-count="14"] .kpi-card,
  902 +.chapter .kpi-grid[data-kpi-count="15"] .kpi-card,
  903 +.chapter .kpi-grid[data-kpi-count="16"] .kpi-card {{
  904 + flex-basis: calc(33.333% - {cfg.grid.gap}px) !important;
  905 + max-width: calc(33.333% - {cfg.grid.gap}px) !important;
  906 +}}
  907 +
  908 +/* 5个时最后两张拉宽为两列 */
  909 +.chapter .kpi-grid[data-kpi-count="5"] .kpi-card:nth-last-child(-n+2) {{
  910 + flex-basis: calc(50% - {cfg.grid.gap}px) !important;
  911 + max-width: calc(50% - {cfg.grid.gap}px) !important;
  912 +}}
  913 +
  914 +/* 余数为2时,最后两张平分全宽 */
  915 +.chapter .kpi-grid[data-kpi-count="8"] .kpi-card:nth-last-child(-n+2),
  916 +.chapter .kpi-grid[data-kpi-count="11"] .kpi-card:nth-last-child(-n+2),
  917 +.chapter .kpi-grid[data-kpi-count="14"] .kpi-card:nth-last-child(-n+2) {{
  918 + flex-basis: calc(50% - {cfg.grid.gap}px) !important;
  919 + max-width: calc(50% - {cfg.grid.gap}px) !important;
  920 +}}
  921 +
  922 +/* 余数为1时,最后一张占满全宽 */
  923 +.chapter .kpi-grid[data-kpi-count="7"] .kpi-card:last-child,
  924 +.chapter .kpi-grid[data-kpi-count="10"] .kpi-card:last-child,
  925 +.chapter .kpi-grid[data-kpi-count="13"] .kpi-card:last-child,
  926 +.chapter .kpi-grid[data-kpi-count="16"] .kpi-card:last-child {{
  927 + flex-basis: 100% !important;
  928 + max-width: 100% !important;
743 }} 929 }}
744 930
745 .kpi-card {{ 931 .kpi-card {{
@@ -747,35 +933,39 @@ p {{ @@ -747,35 +933,39 @@ p {{
747 min-height: {cfg.kpi_card.min_height}px; 933 min-height: {cfg.kpi_card.min_height}px;
748 break-inside: avoid; 934 break-inside: avoid;
749 page-break-inside: avoid; 935 page-break-inside: avoid;
750 - /* 防止溢出的关键设置 */  
751 - overflow: hidden;  
752 box-sizing: border-box; 936 box-sizing: border-box;
753 max-width: 100%; 937 max-width: 100%;
  938 + height: auto;
  939 + display: flex;
  940 + flex-direction: column;
  941 + gap: 8px;
754 }} 942 }}
755 943
756 -.kpi-card .value {{  
757 - font-size: {cfg.kpi_card.font_size_value}px !important;  
758 - line-height: 1.2;  
759 - /* 强制换行和溢出控制 */ 944 +.kpi-card .kpi-value {{
  945 + font-size: {body_kpi_value}px !important;
  946 + line-height: 1.25;
760 word-break: break-word; 947 word-break: break-word;
761 overflow-wrap: break-word; 948 overflow-wrap: break-word;
762 hyphens: auto; 949 hyphens: auto;
763 max-width: 100%; 950 max-width: 100%;
764 - overflow: hidden;  
765 - text-overflow: ellipsis;  
766 -}}  
767 -  
768 -.kpi-card .label {{  
769 - font-size: {cfg.kpi_card.font_size_label}px !important;  
770 - /* 防止标签溢出 */ 951 + display: flex;
  952 + flex-wrap: wrap;
  953 + align-items: baseline;
  954 + gap: 4px 6px;
  955 +.kpi-card .kpi-label {{
  956 + font-size: {body_kpi_label}px !important;
771 word-break: break-word; 957 word-break: break-word;
772 overflow-wrap: break-word; 958 overflow-wrap: break-word;
773 max-width: 100%; 959 max-width: 100%;
  960 + line-height: 1.35;
774 }} 961 }}
775 962
776 -.kpi-card .change {{  
777 - font-size: {cfg.kpi_card.font_size_change}px !important; 963 +.kpi-card .change,
  964 +.kpi-card .delta {{
  965 + font-size: {body_kpi_delta}px !important;
778 word-break: break-word; 966 word-break: break-word;
  967 + overflow-wrap: break-word;
  968 + line-height: 1.3;
779 }} 969 }}
780 970
781 /* 提示框优化 - 防止溢出 */ 971 /* 提示框优化 - 防止溢出 */
@@ -783,6 +973,7 @@ p {{ @@ -783,6 +973,7 @@ p {{
783 padding: {cfg.callout.padding}px !important; 973 padding: {cfg.callout.padding}px !important;
784 margin: 20px 0; 974 margin: 20px 0;
785 line-height: {cfg.callout.line_height}; 975 line-height: {cfg.callout.line_height};
  976 + font-size: {body_callout_content}px !important;
786 break-inside: avoid; 977 break-inside: avoid;
787 page-break-inside: avoid; 978 page-break-inside: avoid;
788 /* 防止溢出 */ 979 /* 防止溢出 */
@@ -792,19 +983,31 @@ p {{ @@ -792,19 +983,31 @@ p {{
792 }} 983 }}
793 984
794 .callout-title {{ 985 .callout-title {{
795 - font-size: {cfg.callout.font_size_title}px !important; 986 + font-size: {body_callout_title}px !important;
796 margin-bottom: 10px; 987 margin-bottom: 10px;
797 word-break: break-word; 988 word-break: break-word;
798 line-height: 1.4; 989 line-height: 1.4;
799 }} 990 }}
800 991
801 .callout-content {{ 992 .callout-content {{
802 - font-size: {cfg.callout.font_size_content}px !important; 993 + font-size: {body_callout_content}px !important;
803 word-break: break-word; 994 word-break: break-word;
804 overflow-wrap: break-word; 995 overflow-wrap: break-word;
805 line-height: {cfg.callout.line_height}; 996 line-height: {cfg.callout.line_height};
806 }} 997 }}
807 998
  999 +.callout strong {{
  1000 + font-size: {body_callout_title}px !important;
  1001 +}}
  1002 +
  1003 +.callout p,
  1004 +.callout li,
  1005 +.callout table,
  1006 +.callout td,
  1007 +.callout th {{
  1008 + font-size: {body_callout_content}px !important;
  1009 +}}
  1010 +
808 /* 确保 callout 内部最后一个元素不会溢出底部 */ 1011 /* 确保 callout 内部最后一个元素不会溢出底部 */
809 .callout > *:last-child, 1012 .callout > *:last-child,
810 .callout > *:last-child > *:last-child {{ 1013 .callout > *:last-child > *:last-child {{
@@ -824,7 +1027,7 @@ table {{ @@ -824,7 +1027,7 @@ table {{
824 }} 1027 }}
825 1028
826 th {{ 1029 th {{
827 - font-size: {cfg.table.font_size_header}px !important; 1030 + font-size: {body_table_header}px !important;
828 padding: {cfg.table.cell_padding}px !important; 1031 padding: {cfg.table.cell_padding}px !important;
829 /* 表头文字控制 */ 1032 /* 表头文字控制 */
830 word-break: break-word; 1033 word-break: break-word;
@@ -834,7 +1037,7 @@ th {{ @@ -834,7 +1037,7 @@ th {{
834 }} 1037 }}
835 1038
836 td {{ 1039 td {{
837 - font-size: {cfg.table.font_size_body}px !important; 1040 + font-size: {body_table_body}px !important;
838 padding: {cfg.table.cell_padding}px !important; 1041 padding: {cfg.table.cell_padding}px !important;
839 max-width: {cfg.table.max_cell_width}px; 1042 max-width: {cfg.table.max_cell_width}px;
840 /* 强制换行,防止溢出 */ 1043 /* 强制换行,防止溢出 */
@@ -859,7 +1062,7 @@ td {{ @@ -859,7 +1062,7 @@ td {{
859 }} 1062 }}
860 1063
861 .chart-title {{ 1064 .chart-title {{
862 - font-size: {cfg.chart.font_size_title}px !important; 1065 + font-size: {body_chart_title}px !important;
863 word-break: break-word; 1066 word-break: break-word;
864 }} 1067 }}
865 1068
@@ -928,19 +1131,26 @@ td {{ @@ -928,19 +1131,26 @@ td {{
928 .hero-side {{ 1131 .hero-side {{
929 flex: 3; /* 右侧占30% */ 1132 flex: 3; /* 右侧占30% */
930 min-width: 0; 1133 min-width: 0;
  1134 + min-height: 0;
931 display: flex; 1135 display: flex;
932 - flex-direction: column; 1136 + flex-wrap: wrap;
933 gap: {max(cfg.grid.gap - 2, 10)}px; 1137 gap: {max(cfg.grid.gap - 2, 10)}px;
934 overflow: hidden; 1138 overflow: hidden;
935 box-sizing: border-box; 1139 box-sizing: border-box;
  1140 + width: 100%;
936 }} 1141 }}
937 1142
938 /* Hero区域的KPI卡片 - 横向拉长,每行显示一个内容 */ 1143 /* Hero区域的KPI卡片 - 横向拉长,每行显示一个内容 */
939 .hero-kpi {{ 1144 .hero-kpi {{
  1145 + background: #ffffff;
  1146 + border-radius: 16px !important;
  1147 + border: 1px solid rgba(0, 0, 0, 0.06);
  1148 + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
  1149 + flex: 0 1 calc(50% - {max(cfg.grid.gap - 2, 10)}px);
  1150 + max-width: calc(50% - {max(cfg.grid.gap - 2, 10)}px);
940 padding: 12px 18px !important; /* 增加横向padding */ 1151 padding: 12px 18px !important; /* 增加横向padding */
941 overflow: hidden; 1152 overflow: hidden;
942 box-sizing: border-box; 1153 box-sizing: border-box;
943 - max-width: 100%;  
944 min-height: 85px; /* 增加高度以容纳三行 */ 1154 min-height: 85px; /* 增加高度以容纳三行 */
945 display: flex; 1155 display: flex;
946 flex-direction: column; 1156 flex-direction: column;
@@ -948,7 +1158,7 @@ td {{ @@ -948,7 +1158,7 @@ td {{
948 }} 1158 }}
949 1159
950 .hero-kpi .label {{ 1160 .hero-kpi .label {{
951 - font-size: {max(cfg.kpi_card.font_size_label - 3, 9)}px !important; /* 减小标签字号 */ 1161 + font-size: {overview_kpi_label}px !important; /* 适度减小标签字号 */
952 word-break: break-word; 1162 word-break: break-word;
953 max-width: 100%; 1163 max-width: 100%;
954 line-height: 1.2; 1164 line-height: 1.2;
@@ -959,7 +1169,7 @@ td {{ @@ -959,7 +1169,7 @@ td {{
959 }} 1169 }}
960 1170
961 .hero-kpi .value {{ 1171 .hero-kpi .value {{
962 - font-size: {max(cfg.kpi_card.font_size_value - 12, 14)}px !important; /* 减小数值字号 */ 1172 + font-size: {overview_kpi_value}px !important; /* 适度减小数值字号 */
963 word-break: break-word; 1173 word-break: break-word;
964 overflow-wrap: break-word; 1174 overflow-wrap: break-word;
965 max-width: 100%; 1175 max-width: 100%;
@@ -972,7 +1182,7 @@ td {{ @@ -972,7 +1182,7 @@ td {{
972 }} 1182 }}
973 1183
974 .hero-kpi .delta {{ 1184 .hero-kpi .delta {{
975 - font-size: {max(cfg.kpi_card.font_size_change - 3, 9)}px !important; /* 减小变化值字号 */ 1185 + font-size: {overview_kpi_delta}px !important; /* 适度减小变化值字号 */
976 word-break: break-word; 1186 word-break: break-word;
977 margin-top: 3px; 1187 margin-top: 3px;
978 display: block; /* 独占一行 */ 1188 display: block; /* 独占一行 */
@@ -984,7 +1194,7 @@ td {{ @@ -984,7 +1194,7 @@ td {{
984 1194
985 /* Hero summary文本 */ 1195 /* Hero summary文本 */
986 .hero-summary {{ 1196 .hero-summary {{
987 - font-size: {cfg.page.font_size_base}px !important; 1197 + font-size: {overview_summary_font}px !important;
988 line-height: 1.65; 1198 line-height: 1.65;
989 margin-top: 0; 1199 margin-top: 0;
990 margin-bottom: 18px; /* 增加底部边距,与badges保持一致 */ 1200 margin-bottom: 18px; /* 增加底部边距,与badges保持一致 */
@@ -1014,7 +1224,7 @@ td {{ @@ -1014,7 +1224,7 @@ td {{
1014 1224
1015 /* hero highlights中的badge - 拉长加宽的椭圆形背景,与上方文本对齐 */ 1225 /* hero highlights中的badge - 拉长加宽的椭圆形背景,与上方文本对齐 */
1016 .hero-highlights .badge {{ 1226 .hero-highlights .badge {{
1017 - font-size: {max(cfg.callout.font_size_content - 3, 10)}px !important; 1227 + font-size: {overview_badge_font}px !important;
1018 padding: 10px 20px !important; /* 增加padding,更好的视觉效果 */ 1228 padding: 10px 20px !important; /* 增加padding,更好的视觉效果 */
1019 max-width: 100%; 1229 max-width: 100%;
1020 width: 98%; /* 占满宽度,与summary文本对齐 */ 1230 width: 98%; /* 占满宽度,与summary文本对齐 */
@@ -1105,7 +1315,7 @@ main > .chapter:first-of-type {{ @@ -1105,7 +1315,7 @@ main > .chapter:first-of-type {{
1105 white-space: normal; 1315 white-space: normal;
1106 /* 限制badge的最大尺寸 */ 1316 /* 限制badge的最大尺寸 */
1107 padding: 4px 12px !important; 1317 padding: 4px 12px !important;
1108 - font-size: {max(cfg.page.font_size_base - 2, 12)}px !important; 1318 + font-size: {body_badge_font}px !important;
1109 line-height: 1.4 !important; 1319 line-height: 1.4 !important;
1110 /* 防止badge异常过大 */ 1320 /* 防止badge异常过大 */
1111 word-break: break-word; 1321 word-break: break-word;
@@ -1147,4 +1357,5 @@ __all__ = [ @@ -1147,4 +1357,5 @@ __all__ = [
1147 'TableLayout', 1357 'TableLayout',
1148 'ChartLayout', 1358 'ChartLayout',
1149 'GridLayout', 1359 'GridLayout',
  1360 + 'DataBlockLayout',
1150 ] 1361 ]