Fixed the PDF Rendering Overflow Issue and Updated the Logic for Rendering PDFs
Showing
3 changed files
with
424 additions
and
41 deletions
| @@ -405,12 +405,13 @@ class HTMLRenderer: | @@ -405,12 +405,13 @@ class HTMLRenderer: | ||
| 405 | def _render_body(self) -> str: | 405 | def _render_body(self) -> str: |
| 406 | """ | 406 | """ |
| 407 | 拼装<body>结构,包含头部、导航、章节和脚本。 | 407 | 拼装<body>结构,包含头部、导航、章节和脚本。 |
| 408 | + 新版本:移除独立的cover section,标题合并到hero section中。 | ||
| 408 | 409 | ||
| 409 | 返回: | 410 | 返回: |
| 410 | str: body片段HTML。 | 411 | str: body片段HTML。 |
| 411 | """ | 412 | """ |
| 412 | header = self._render_header() | 413 | header = self._render_header() |
| 413 | - cover = self._render_cover() | 414 | + # cover = self._render_cover() # 不再单独渲染cover |
| 414 | hero = self._render_hero() | 415 | hero = self._render_hero() |
| 415 | toc_section = self._render_toc_section() | 416 | toc_section = self._render_toc_section() |
| 416 | chapters = "".join(self._render_chapter(chapter) for chapter in self.chapters) | 417 | chapters = "".join(self._render_chapter(chapter) for chapter in self.chapters) |
| @@ -433,7 +434,6 @@ class HTMLRenderer: | @@ -433,7 +434,6 @@ class HTMLRenderer: | ||
| 433 | {header} | 434 | {header} |
| 434 | {overlay} | 435 | {overlay} |
| 435 | <main> | 436 | <main> |
| 436 | -{cover} | ||
| 437 | {hero} | 437 | {hero} |
| 438 | {toc_section} | 438 | {toc_section} |
| 439 | {chapters} | 439 | {chapters} |
| @@ -502,6 +502,7 @@ class HTMLRenderer: | @@ -502,6 +502,7 @@ class HTMLRenderer: | ||
| 502 | def _render_hero(self) -> str: | 502 | def _render_hero(self) -> str: |
| 503 | """ | 503 | """ |
| 504 | 根据layout中的hero字段输出摘要/KPI/亮点区。 | 504 | 根据layout中的hero字段输出摘要/KPI/亮点区。 |
| 505 | + 新版本:将标题和总览合并在一起,去掉椭圆背景。 | ||
| 505 | 506 | ||
| 506 | 返回: | 507 | 返回: |
| 507 | str: hero区HTML,若无数据则为空字符串。 | 508 | str: hero区HTML,若无数据则为空字符串。 |
| @@ -509,6 +510,11 @@ class HTMLRenderer: | @@ -509,6 +510,11 @@ class HTMLRenderer: | ||
| 509 | hero = self.metadata.get("hero") or {} | 510 | hero = self.metadata.get("hero") or {} |
| 510 | if not hero: | 511 | if not hero: |
| 511 | return "" | 512 | return "" |
| 513 | + | ||
| 514 | + # 获取标题和副标题 | ||
| 515 | + title = self.metadata.get("title") or "智能舆情报告" | ||
| 516 | + subtitle = self.metadata.get("subtitle") or self.metadata.get("templateName") or "" | ||
| 517 | + | ||
| 512 | summary = hero.get("summary") | 518 | summary = hero.get("summary") |
| 513 | summary_html = f'<p class="hero-summary">{self._escape_html(summary)}</p>' if summary else "" | 519 | summary_html = f'<p class="hero-summary">{self._escape_html(summary)}</p>' if summary else "" |
| 514 | highlights = hero.get("highlights") or [] | 520 | highlights = hero.get("highlights") or [] |
| @@ -535,14 +541,21 @@ class HTMLRenderer: | @@ -535,14 +541,21 @@ class HTMLRenderer: | ||
| 535 | """ | 541 | """ |
| 536 | 542 | ||
| 537 | return f""" | 543 | return f""" |
| 538 | -<section class="hero-section"> | ||
| 539 | - <div class="hero-content"> | ||
| 540 | - {summary_html} | ||
| 541 | - <ul class="hero-highlights">{highlight_html}</ul> | ||
| 542 | - <div class="hero-actions">{actions_html}</div> | 544 | +<section class="hero-section-combined"> |
| 545 | + <div class="hero-header"> | ||
| 546 | + <p class="hero-hint">文章总览</p> | ||
| 547 | + <h1 class="hero-title">{self._escape_html(title)}</h1> | ||
| 548 | + <p class="hero-subtitle">{self._escape_html(subtitle)}</p> | ||
| 543 | </div> | 549 | </div> |
| 544 | - <div class="hero-side"> | ||
| 545 | - {kpi_cards} | 550 | + <div class="hero-body"> |
| 551 | + <div class="hero-content"> | ||
| 552 | + {summary_html} | ||
| 553 | + <ul class="hero-highlights">{highlight_html}</ul> | ||
| 554 | + <div class="hero-actions">{actions_html}</div> | ||
| 555 | + </div> | ||
| 556 | + <div class="hero-side"> | ||
| 557 | + {kpi_cards} | ||
| 558 | + </div> | ||
| 546 | </div> | 559 | </div> |
| 547 | </section> | 560 | </section> |
| 548 | """.strip() | 561 | """.strip() |
| @@ -145,11 +145,13 @@ class PDFLayoutOptimizer: | @@ -145,11 +145,13 @@ class PDFLayoutOptimizer: | ||
| 145 | # 字符宽度估算系数(基于常见中文字体) | 145 | # 字符宽度估算系数(基于常见中文字体) |
| 146 | # 中文字符通常是等宽的,约等于字号的像素值 | 146 | # 中文字符通常是等宽的,约等于字号的像素值 |
| 147 | # 英文和数字约为字号的0.5-0.6倍 | 147 | # 英文和数字约为字号的0.5-0.6倍 |
| 148 | + # 更新:使用更精确的系数以更好地预测溢出 | ||
| 148 | CHAR_WIDTH_FACTOR = { | 149 | CHAR_WIDTH_FACTOR = { |
| 149 | - 'chinese': 1.0, # 中文字符 | ||
| 150 | - 'english': 0.55, # 英文字母 | ||
| 151 | - 'number': 0.6, # 数字 | ||
| 152 | - 'symbol': 0.4, # 符号 | 150 | + 'chinese': 1.05, # 中文字符(略微增加以确保安全边界) |
| 151 | + 'english': 0.58, # 英文字母 | ||
| 152 | + 'number': 0.65, # 数字(数字通常比字母稍宽) | ||
| 153 | + 'symbol': 0.45, # 符号 | ||
| 154 | + 'percent': 0.7, # 百分号等特殊符号 | ||
| 153 | } | 155 | } |
| 154 | 156 | ||
| 155 | def __init__(self, config: Optional[PDFLayoutConfig] = None): | 157 | def __init__(self, config: Optional[PDFLayoutConfig] = None): |
| @@ -208,6 +210,8 @@ class PDFLayoutOptimizer: | @@ -208,6 +210,8 @@ class PDFLayoutOptimizer: | ||
| 208 | - max_kpi_value_length: 最长KPI数值长度 | 210 | - max_kpi_value_length: 最长KPI数值长度 |
| 209 | - max_table_columns: 最多表格列数 | 211 | - max_table_columns: 最多表格列数 |
| 210 | - total_content_length: 总内容长度 | 212 | - total_content_length: 总内容长度 |
| 213 | + - hero_kpi_count: Hero区域的KPI数量 | ||
| 214 | + - max_hero_kpi_value_length: Hero区域最长KPI数值长度 | ||
| 211 | """ | 215 | """ |
| 212 | stats = { | 216 | stats = { |
| 213 | 'kpi_count': 0, | 217 | 'kpi_count': 0, |
| @@ -219,8 +223,23 @@ class PDFLayoutOptimizer: | @@ -219,8 +223,23 @@ class PDFLayoutOptimizer: | ||
| 219 | 'max_table_rows': 0, | 223 | 'max_table_rows': 0, |
| 220 | 'total_content_length': 0, | 224 | 'total_content_length': 0, |
| 221 | 'has_long_text': False, | 225 | 'has_long_text': False, |
| 226 | + 'hero_kpi_count': 0, | ||
| 227 | + 'max_hero_kpi_value_length': 0, | ||
| 222 | } | 228 | } |
| 223 | 229 | ||
| 230 | + # 分析hero区域的KPI | ||
| 231 | + metadata = document_ir.get('metadata', {}) | ||
| 232 | + hero = metadata.get('hero', {}) | ||
| 233 | + if hero: | ||
| 234 | + hero_kpis = hero.get('kpis', []) | ||
| 235 | + stats['hero_kpi_count'] = len(hero_kpis) | ||
| 236 | + for kpi in hero_kpis: | ||
| 237 | + value = str(kpi.get('value', '')) | ||
| 238 | + stats['max_hero_kpi_value_length'] = max( | ||
| 239 | + stats['max_hero_kpi_value_length'], | ||
| 240 | + len(value) | ||
| 241 | + ) | ||
| 242 | + | ||
| 224 | # 优先使用chapters,fallback到sections | 243 | # 优先使用chapters,fallback到sections |
| 225 | chapters = document_ir.get('chapters', []) | 244 | chapters = document_ir.get('chapters', []) |
| 226 | if not chapters: | 245 | if not chapters: |
| @@ -353,6 +372,8 @@ class PDFLayoutOptimizer: | @@ -353,6 +372,8 @@ class PDFLayoutOptimizer: | ||
| 353 | width += font_size * self.CHAR_WIDTH_FACTOR['english'] | 372 | width += font_size * self.CHAR_WIDTH_FACTOR['english'] |
| 354 | elif char.isdigit(): | 373 | elif char.isdigit(): |
| 355 | width += font_size * self.CHAR_WIDTH_FACTOR['number'] | 374 | width += font_size * self.CHAR_WIDTH_FACTOR['number'] |
| 375 | + elif char in '%%': # 百分号 | ||
| 376 | + width += font_size * self.CHAR_WIDTH_FACTOR['percent'] | ||
| 356 | else: | 377 | else: |
| 357 | width += font_size * self.CHAR_WIDTH_FACTOR['symbol'] | 378 | width += font_size * self.CHAR_WIDTH_FACTOR['symbol'] |
| 358 | 379 | ||
| @@ -460,52 +481,77 @@ class PDFLayoutOptimizer: | @@ -460,52 +481,77 @@ class PDFLayoutOptimizer: | ||
| 460 | for issue in overflow_issues: | 481 | for issue in overflow_issues: |
| 461 | logger.warning(f"检测到布局问题: {issue}") | 482 | logger.warning(f"检测到布局问题: {issue}") |
| 462 | 483 | ||
| 463 | - # KPI卡片宽度(像素) | ||
| 464 | - kpi_card_width = (800 - 20) // 2 - 40 # 2列布局 | 484 | + # KPI卡片宽度(像素)- 更保守的计算,留出更多安全边界 |
| 485 | + kpi_card_width = (800 - 20) // 2 - 60 # 2列布局,增加边距以防溢出 | ||
| 486 | + | ||
| 487 | + # 优先处理Hero区域的KPI(如果有的话) | ||
| 488 | + if stats['hero_kpi_count'] > 0 and stats['max_hero_kpi_value_length'] > 0: | ||
| 489 | + # Hero区域的KPI卡片宽度通常更窄 | ||
| 490 | + hero_kpi_width = 250 # Hero侧边栏的典型宽度 | ||
| 491 | + sample_text = '9' * stats['max_hero_kpi_value_length'] + '元' | ||
| 492 | + safe_font_size, needs_adjustment = self._calculate_safe_font_size( | ||
| 493 | + sample_text, | ||
| 494 | + hero_kpi_width, | ||
| 495 | + min_font_size=14, | ||
| 496 | + max_font_size=24 # Hero KPI字号通常较小 | ||
| 497 | + ) | ||
| 498 | + | ||
| 499 | + if needs_adjustment or stats['max_hero_kpi_value_length'] > 6: | ||
| 500 | + # Hero KPI需要更保守的字号 | ||
| 501 | + config.kpi_card.font_size_value = max(14, safe_font_size - 2) | ||
| 502 | + self.optimization_log.append( | ||
| 503 | + f"Hero KPI数值较长({stats['max_hero_kpi_value_length']}字符)," | ||
| 504 | + f"字号调整为{config.kpi_card.font_size_value}px" | ||
| 505 | + ) | ||
| 465 | 506 | ||
| 466 | # 根据KPI数值长度智能调整字号 | 507 | # 根据KPI数值长度智能调整字号 |
| 467 | if stats['max_kpi_value_length'] > 0: | 508 | if stats['max_kpi_value_length'] > 0: |
| 468 | - # 创建示例文本进行测试 | ||
| 469 | - sample_text = '9' * stats['max_kpi_value_length'] | 509 | + # 创建示例文本进行测试 - 使用实际可能的字符组合 |
| 510 | + sample_text = '9' * stats['max_kpi_value_length'] + '亿' # 加上可能的单位 | ||
| 470 | safe_font_size, needs_adjustment = self._calculate_safe_font_size( | 511 | safe_font_size, needs_adjustment = self._calculate_safe_font_size( |
| 471 | sample_text, | 512 | sample_text, |
| 472 | kpi_card_width, | 513 | kpi_card_width, |
| 473 | - min_font_size=18, | ||
| 474 | - max_font_size=32 | 514 | + min_font_size=16, # 降低最小字号以确保不溢出 |
| 515 | + max_font_size=28 # 降低最大字号以更保守 | ||
| 475 | ) | 516 | ) |
| 476 | 517 | ||
| 477 | if needs_adjustment: | 518 | if needs_adjustment: |
| 478 | config.kpi_card.font_size_value = safe_font_size | 519 | config.kpi_card.font_size_value = safe_font_size |
| 520 | + # 进一步降低以留出安全边界 | ||
| 521 | + config.kpi_card.font_size_value = max(16, safe_font_size - 2) | ||
| 479 | self.optimization_log.append( | 522 | self.optimization_log.append( |
| 480 | f"KPI数值过长({stats['max_kpi_value_length']}字符)," | 523 | f"KPI数值过长({stats['max_kpi_value_length']}字符)," |
| 481 | - f"字号自动调整为{safe_font_size}px以防止溢出" | 524 | + f"字号自动调整为{config.kpi_card.font_size_value}px以防止溢出" |
| 482 | ) | 525 | ) |
| 483 | - elif stats['max_kpi_value_length'] > 10: | ||
| 484 | - # 即使不溢出,也适当缩小以留出更多空间 | ||
| 485 | - config.kpi_card.font_size_value = min(28, safe_font_size) | 526 | + elif stats['max_kpi_value_length'] > 8: |
| 527 | + # 对于较长文本,更保守地调整 | ||
| 528 | + config.kpi_card.font_size_value = min(24, safe_font_size) | ||
| 486 | self.optimization_log.append( | 529 | self.optimization_log.append( |
| 487 | f"KPI数值较长({stats['max_kpi_value_length']}字符)," | 530 | f"KPI数值较长({stats['max_kpi_value_length']}字符)," |
| 488 | f"预防性调整字号为{config.kpi_card.font_size_value}px" | 531 | f"预防性调整字号为{config.kpi_card.font_size_value}px" |
| 489 | ) | 532 | ) |
| 490 | 533 | ||
| 491 | - # 根据KPI数量调整网格布局 | 534 | + # 根据KPI数量调整网格布局和间距 |
| 492 | if stats['kpi_count'] > 6: | 535 | if stats['kpi_count'] > 6: |
| 493 | config.grid.columns = 3 | 536 | config.grid.columns = 3 |
| 494 | config.kpi_card.min_height = 100 | 537 | config.kpi_card.min_height = 100 |
| 495 | - config.kpi_card.padding = 16 # 缩小padding以节省空间 | 538 | + config.kpi_card.padding = 14 # 缩小padding以节省空间 |
| 539 | + config.grid.gap = 16 # 减小间距 | ||
| 496 | self.optimization_log.append( | 540 | self.optimization_log.append( |
| 497 | f"KPI卡片较多({stats['kpi_count']}个)," | 541 | f"KPI卡片较多({stats['kpi_count']}个)," |
| 498 | - f"调整为3列布局并缩小内边距" | 542 | + f"调整为3列布局并缩小内边距和间距" |
| 499 | ) | 543 | ) |
| 500 | elif stats['kpi_count'] > 4: | 544 | elif stats['kpi_count'] > 4: |
| 501 | config.grid.columns = 2 | 545 | config.grid.columns = 2 |
| 502 | - config.kpi_card.padding = 18 | 546 | + config.kpi_card.padding = 16 |
| 547 | + config.grid.gap = 18 | ||
| 503 | self.optimization_log.append( | 548 | self.optimization_log.append( |
| 504 | f"KPI卡片适中({stats['kpi_count']}个),使用2列布局" | 549 | f"KPI卡片适中({stats['kpi_count']}个),使用2列布局" |
| 505 | ) | 550 | ) |
| 506 | elif stats['kpi_count'] <= 2: | 551 | elif stats['kpi_count'] <= 2: |
| 507 | config.grid.columns = 1 | 552 | config.grid.columns = 1 |
| 508 | - config.kpi_card.padding = 24 # 较少卡片时增加padding | 553 | + config.kpi_card.padding = 22 # 较少卡片时增加padding |
| 554 | + config.grid.gap = 20 | ||
| 509 | self.optimization_log.append( | 555 | self.optimization_log.append( |
| 510 | f"KPI卡片较少({stats['kpi_count']}个)," | 556 | f"KPI卡片较少({stats['kpi_count']}个)," |
| 511 | f"使用1列布局并增加内边距" | 557 | f"使用1列布局并增加内边距" |
| @@ -539,11 +585,19 @@ class PDFLayoutOptimizer: | @@ -539,11 +585,19 @@ class PDFLayoutOptimizer: | ||
| 539 | 585 | ||
| 540 | # 如果有长文本,增加行高和段落间距 | 586 | # 如果有长文本,增加行高和段落间距 |
| 541 | if stats['has_long_text']: | 587 | if stats['has_long_text']: |
| 542 | - config.page.line_height = 1.8 | ||
| 543 | - config.callout.line_height = 1.8 | ||
| 544 | - config.page.paragraph_spacing = 18 | 588 | + config.page.line_height = 1.75 # 稍微降低以节省空间 |
| 589 | + config.callout.line_height = 1.75 | ||
| 590 | + config.page.paragraph_spacing = 16 # 适度间距 | ||
| 591 | + self.optimization_log.append( | ||
| 592 | + "检测到长文本,增加行高至1.75和段落间距以提高可读性" | ||
| 593 | + ) | ||
| 594 | + else: | ||
| 595 | + # 没有长文本时使用更紧凑的间距 | ||
| 596 | + config.page.line_height = 1.5 | ||
| 597 | + config.callout.line_height = 1.6 | ||
| 598 | + config.page.paragraph_spacing = 14 | ||
| 545 | self.optimization_log.append( | 599 | self.optimization_log.append( |
| 546 | - "检测到长文本,增加行高至1.8和段落间距以提高可读性" | 600 | + "文本长度适中,使用标准行高和段落间距" |
| 547 | ) | 601 | ) |
| 548 | 602 | ||
| 549 | # 如果内容较多,减小整体字号 | 603 | # 如果内容较多,减小整体字号 |
| @@ -643,6 +697,16 @@ class PDFLayoutOptimizer: | @@ -643,6 +697,16 @@ class PDFLayoutOptimizer: | ||
| 643 | css = f""" | 697 | css = f""" |
| 644 | /* PDF布局优化样式 - 由PDFLayoutOptimizer自动生成 */ | 698 | /* PDF布局优化样式 - 由PDFLayoutOptimizer自动生成 */ |
| 645 | 699 | ||
| 700 | +/* 隐藏独立的封面section,已合并到hero */ | ||
| 701 | +.cover {{ | ||
| 702 | + display: none !important; | ||
| 703 | +}} | ||
| 704 | + | ||
| 705 | +/* PDF中隐藏hero actions(深蓝色的三个按钮) */ | ||
| 706 | +.hero-actions {{ | ||
| 707 | + display: none !important; | ||
| 708 | +}} | ||
| 709 | + | ||
| 646 | /* 页面基础样式 */ | 710 | /* 页面基础样式 */ |
| 647 | body {{ | 711 | body {{ |
| 648 | font-size: {cfg.page.font_size_base}px; | 712 | font-size: {cfg.page.font_size_base}px; |
| @@ -731,12 +795,14 @@ p {{ | @@ -731,12 +795,14 @@ p {{ | ||
| 731 | font-size: {cfg.callout.font_size_title}px !important; | 795 | font-size: {cfg.callout.font_size_title}px !important; |
| 732 | margin-bottom: 10px; | 796 | margin-bottom: 10px; |
| 733 | word-break: break-word; | 797 | word-break: break-word; |
| 798 | + line-height: 1.4; | ||
| 734 | }} | 799 | }} |
| 735 | 800 | ||
| 736 | .callout-content {{ | 801 | .callout-content {{ |
| 737 | font-size: {cfg.callout.font_size_content}px !important; | 802 | font-size: {cfg.callout.font_size_content}px !important; |
| 738 | word-break: break-word; | 803 | word-break: break-word; |
| 739 | overflow-wrap: break-word; | 804 | overflow-wrap: break-word; |
| 805 | + line-height: {cfg.callout.line_height}; | ||
| 740 | }} | 806 | }} |
| 741 | 807 | ||
| 742 | /* 表格优化 - 严格防止溢出 */ | 808 | /* 表格优化 - 严格防止溢出 */ |
| @@ -790,24 +856,196 @@ td {{ | @@ -790,24 +856,196 @@ td {{ | ||
| 790 | word-break: break-word; | 856 | word-break: break-word; |
| 791 | }} | 857 | }} |
| 792 | 858 | ||
| 793 | -/* Hero区域的KPI卡片 */ | 859 | +/* Hero区域合并版本 - 包含标题和内容,保留蓝色椭圆背景 */ |
| 860 | +.hero-section-combined {{ | ||
| 861 | + padding: 45px 55px !important; | ||
| 862 | + margin: 0 auto 40px auto !important; | ||
| 863 | + min-height: 500px; | ||
| 864 | + /* 使用100%宽度,填满整个页面 */ | ||
| 865 | + width: 100% !important; | ||
| 866 | + max-width: 100% !important; | ||
| 867 | + box-sizing: border-box; | ||
| 868 | + overflow: visible; | ||
| 869 | + border-radius: 40px !important; | ||
| 870 | + background: linear-gradient(135deg, #e8f4f8 0%, #d4e9f7 100%); | ||
| 871 | + page-break-after: always !important; | ||
| 872 | +}} | ||
| 873 | + | ||
| 874 | +/* Hero标题区域 */ | ||
| 875 | +.hero-header {{ | ||
| 876 | + text-align: center; | ||
| 877 | + margin-bottom: 25px; | ||
| 878 | + padding-bottom: 18px; | ||
| 879 | + border-bottom: 1px solid rgba(100, 150, 200, 0.2); | ||
| 880 | +}} | ||
| 881 | + | ||
| 882 | +.hero-hint {{ | ||
| 883 | + font-size: {max(cfg.page.font_size_base - 2, 11)}px !important; | ||
| 884 | + color: #d32f2f; | ||
| 885 | + margin: 0 0 6px 0; | ||
| 886 | + font-weight: 500; | ||
| 887 | +}} | ||
| 888 | + | ||
| 889 | +.hero-title {{ | ||
| 890 | + font-size: {max(cfg.page.font_size_base + 5, 19)}px !important; /* 稍微减小标题字号 */ | ||
| 891 | + font-weight: 600; | ||
| 892 | + margin: 6px 0; | ||
| 893 | + color: #1a1a1a; | ||
| 894 | + line-height: 1.3; | ||
| 895 | +}} | ||
| 896 | + | ||
| 897 | +.hero-subtitle {{ | ||
| 898 | + font-size: {max(cfg.page.font_size_base - 1, 12)}px !important; | ||
| 899 | + color: #d32f2f; | ||
| 900 | + margin: 6px 0 0 0; | ||
| 901 | + font-weight: 400; | ||
| 902 | +}} | ||
| 903 | + | ||
| 904 | +/* Hero主体区域 - 左右分栏 */ | ||
| 905 | +.hero-body {{ | ||
| 906 | + display: flex; | ||
| 907 | + gap: 28px; /* 左右间距 */ | ||
| 908 | + align-items: flex-start; | ||
| 909 | +}} | ||
| 910 | + | ||
| 911 | +/* Hero左侧内容区 - 占蓝色背景的70% */ | ||
| 912 | +.hero-content {{ | ||
| 913 | + flex: 7; /* 左侧占70% */ | ||
| 914 | + min-width: 0; | ||
| 915 | + padding-right: 25px; | ||
| 916 | + box-sizing: border-box; | ||
| 917 | + overflow: hidden; | ||
| 918 | +}} | ||
| 919 | + | ||
| 920 | +/* Hero右侧KPI区域 - 占蓝色背景的30% */ | ||
| 921 | +.hero-side {{ | ||
| 922 | + flex: 3; /* 右侧占30% */ | ||
| 923 | + min-width: 0; | ||
| 924 | + display: flex; | ||
| 925 | + flex-direction: column; | ||
| 926 | + gap: {max(cfg.grid.gap - 2, 10)}px; | ||
| 927 | + overflow: hidden; | ||
| 928 | + box-sizing: border-box; | ||
| 929 | +}} | ||
| 930 | + | ||
| 931 | +/* Hero区域的KPI卡片 - 横向拉长,每行显示一个内容 */ | ||
| 794 | .hero-kpi {{ | 932 | .hero-kpi {{ |
| 795 | - padding: {cfg.kpi_card.padding}px !important; | 933 | + padding: 12px 18px !important; /* 增加横向padding */ |
| 796 | overflow: hidden; | 934 | overflow: hidden; |
| 797 | box-sizing: border-box; | 935 | box-sizing: border-box; |
| 936 | + max-width: 100%; | ||
| 937 | + min-height: 85px; /* 增加高度以容纳三行 */ | ||
| 938 | + display: flex; | ||
| 939 | + flex-direction: column; | ||
| 940 | + justify-content: space-between; | ||
| 798 | }} | 941 | }} |
| 799 | 942 | ||
| 800 | .hero-kpi .label {{ | 943 | .hero-kpi .label {{ |
| 801 | - font-size: {cfg.kpi_card.font_size_label}px !important; | 944 | + font-size: {max(cfg.kpi_card.font_size_label - 3, 9)}px !important; /* 减小标签字号 */ |
| 802 | word-break: break-word; | 945 | word-break: break-word; |
| 803 | max-width: 100%; | 946 | max-width: 100%; |
| 947 | + line-height: 1.2; | ||
| 948 | + margin-bottom: 4px; | ||
| 949 | + overflow: hidden; | ||
| 950 | + text-overflow: ellipsis; | ||
| 951 | + display: block; /* 独占一行 */ | ||
| 804 | }} | 952 | }} |
| 805 | 953 | ||
| 806 | .hero-kpi .value {{ | 954 | .hero-kpi .value {{ |
| 807 | - font-size: {cfg.kpi_card.font_size_value}px !important; | 955 | + font-size: {max(cfg.kpi_card.font_size_value - 12, 14)}px !important; /* 减小数值字号 */ |
| 808 | word-break: break-word; | 956 | word-break: break-word; |
| 809 | overflow-wrap: break-word; | 957 | overflow-wrap: break-word; |
| 810 | max-width: 100%; | 958 | max-width: 100%; |
| 959 | + line-height: 1.1; | ||
| 960 | + display: block; /* 独占一行 */ | ||
| 961 | + hyphens: auto; | ||
| 962 | + overflow: hidden; | ||
| 963 | + text-overflow: ellipsis; | ||
| 964 | + margin-bottom: 3px; | ||
| 965 | +}} | ||
| 966 | + | ||
| 967 | +.hero-kpi .delta {{ | ||
| 968 | + font-size: {max(cfg.kpi_card.font_size_change - 3, 9)}px !important; /* 减小变化值字号 */ | ||
| 969 | + word-break: break-word; | ||
| 970 | + margin-top: 3px; | ||
| 971 | + display: block; /* 独占一行 */ | ||
| 972 | + max-width: 100%; | ||
| 973 | + overflow: hidden; | ||
| 974 | + text-overflow: ellipsis; | ||
| 975 | + line-height: 1.2; | ||
| 976 | +}} | ||
| 977 | + | ||
| 978 | +/* Hero summary文本 */ | ||
| 979 | +.hero-summary {{ | ||
| 980 | + font-size: {cfg.page.font_size_base}px !important; | ||
| 981 | + line-height: 1.65; | ||
| 982 | + margin-top: 0; | ||
| 983 | + margin-bottom: 18px; /* 增加底部边距,与badges保持一致 */ | ||
| 984 | + word-break: break-word; | ||
| 985 | + max-width: 98%; /* 与badges宽度一致 */ | ||
| 986 | + overflow: hidden; | ||
| 987 | +}} | ||
| 988 | + | ||
| 989 | +/* Hero highlights列表 - 横向排列,宽度与summary一致 */ | ||
| 990 | +.hero-highlights {{ | ||
| 991 | + list-style: none; | ||
| 992 | + padding: 0; | ||
| 993 | + margin: 16px 0; /* 增加上下边距 */ | ||
| 994 | + display: flex; | ||
| 995 | + flex-direction: column; | ||
| 996 | + gap: 12px; /* 增加间距,让椭圆之间有更多空间 */ | ||
| 997 | + max-width: 100%; | ||
| 998 | + overflow: hidden; | ||
| 999 | +}} | ||
| 1000 | + | ||
| 1001 | +.hero-highlights li {{ | ||
| 1002 | + margin: 0; | ||
| 1003 | + max-width: 100%; | ||
| 1004 | + flex-shrink: 0; | ||
| 1005 | + flex-grow: 0; | ||
| 1006 | +}} | ||
| 1007 | + | ||
| 1008 | +/* hero highlights中的badge - 拉长加宽的椭圆形背景,与上方文本对齐 */ | ||
| 1009 | +.hero-highlights .badge {{ | ||
| 1010 | + font-size: {max(cfg.callout.font_size_content - 3, 10)}px !important; | ||
| 1011 | + padding: 10px 20px !important; /* 增加padding,更好的视觉效果 */ | ||
| 1012 | + max-width: 100%; | ||
| 1013 | + width: 98%; /* 占满宽度,与summary文本对齐 */ | ||
| 1014 | + display: flex; | ||
| 1015 | + align-items: center; /* 垂直居中文字 */ | ||
| 1016 | + justify-content: flex-start; /* 文字左对齐 */ | ||
| 1017 | + word-wrap: break-word; | ||
| 1018 | + white-space: normal; | ||
| 1019 | + overflow: hidden; | ||
| 1020 | + text-overflow: ellipsis; | ||
| 1021 | + box-sizing: border-box; | ||
| 1022 | + line-height: 1.5; /* 增加行高,更好的可读性 */ | ||
| 1023 | + min-height: 40px; /* 增加最小高度 */ | ||
| 1024 | + /* 拉长的椭圆形背景 */ | ||
| 1025 | + background: rgba(100, 120, 150, 0.15) !important; | ||
| 1026 | + border-radius: 22px !important; /* 稍微增加圆角 */ | ||
| 1027 | + border: 1px solid rgba(100, 120, 150, 0.25); | ||
| 1028 | +}} | ||
| 1029 | + | ||
| 1030 | +/* Hero actions按钮 - 确保不溢出椭圆 */ | ||
| 1031 | +.hero-actions {{ | ||
| 1032 | + margin-top: 12px; | ||
| 1033 | + display: flex; | ||
| 1034 | + flex-wrap: wrap; | ||
| 1035 | + gap: 6px; | ||
| 1036 | + max-width: 100%; | ||
| 1037 | + overflow: hidden; | ||
| 1038 | +}} | ||
| 1039 | + | ||
| 1040 | +.hero-actions button {{ | ||
| 1041 | + font-size: {max(cfg.page.font_size_base - 2, 11)}px !important; | ||
| 1042 | + padding: 5px 10px !important; | ||
| 1043 | + max-width: 200px; /* 限制按钮最大宽度 */ | ||
| 1044 | + word-break: break-word; | ||
| 1045 | + white-space: normal; | ||
| 1046 | + overflow: hidden; | ||
| 1047 | + text-overflow: ellipsis; | ||
| 1048 | + box-sizing: border-box; | ||
| 811 | }} | 1049 | }} |
| 812 | 1050 | ||
| 813 | /* 防止标题孤行 */ | 1051 | /* 防止标题孤行 */ |
| @@ -818,6 +1056,19 @@ h1, h2, h3, h4, h5, h6 {{ | @@ -818,6 +1056,19 @@ h1, h2, h3, h4, h5, h6 {{ | ||
| 818 | overflow-wrap: break-word; | 1056 | overflow-wrap: break-word; |
| 819 | }} | 1057 | }} |
| 820 | 1058 | ||
| 1059 | +/* ===== 强制页面分离规则 ===== */ | ||
| 1060 | + | ||
| 1061 | +/* 目录section强制开始新页并在之后强制分页 */ | ||
| 1062 | +.toc-section {{ | ||
| 1063 | + page-break-before: always !important; | ||
| 1064 | + page-break-after: always !important; | ||
| 1065 | +}} | ||
| 1066 | + | ||
| 1067 | +/* 第一个章节强制开始新页(正文从第三页开始) */ | ||
| 1068 | +main > .chapter:first-of-type {{ | ||
| 1069 | + page-break-before: always !important; | ||
| 1070 | +}} | ||
| 1071 | + | ||
| 821 | /* 确保内容块不被分页且不溢出 */ | 1072 | /* 确保内容块不被分页且不溢出 */ |
| 822 | .content-block {{ | 1073 | .content-block {{ |
| 823 | break-inside: avoid; | 1074 | break-inside: avoid; |
| @@ -838,13 +1089,29 @@ h1, h2, h3, h4, h5, h6 {{ | @@ -838,13 +1089,29 @@ h1, h2, h3, h4, h5, h6 {{ | ||
| 838 | letter-spacing: -0.02em; /* 稍微紧缩间距以节省空间 */ | 1089 | letter-spacing: -0.02em; /* 稍微紧缩间距以节省空间 */ |
| 839 | }} | 1090 | }} |
| 840 | 1091 | ||
| 841 | -/* 色块(badge)样式控制 */ | ||
| 842 | -.badge, .callout {{ | 1092 | +/* 色块(badge)样式控制 - 防止过大 */ |
| 1093 | +.badge {{ | ||
| 843 | display: inline-block; | 1094 | display: inline-block; |
| 844 | max-width: 100%; | 1095 | max-width: 100%; |
| 845 | overflow: hidden; | 1096 | overflow: hidden; |
| 846 | text-overflow: ellipsis; | 1097 | text-overflow: ellipsis; |
| 847 | white-space: normal; | 1098 | white-space: normal; |
| 1099 | + /* 限制badge的最大尺寸 */ | ||
| 1100 | + padding: 4px 12px !important; | ||
| 1101 | + font-size: {max(cfg.page.font_size_base - 2, 12)}px !important; | ||
| 1102 | + line-height: 1.4 !important; | ||
| 1103 | + /* 防止badge异常过大 */ | ||
| 1104 | + word-break: break-word; | ||
| 1105 | + hyphens: auto; | ||
| 1106 | +}} | ||
| 1107 | + | ||
| 1108 | +/* 确保callout不会过大 */ | ||
| 1109 | +.callout {{ | ||
| 1110 | + max-width: 100% !important; | ||
| 1111 | + margin: 16px 0 !important; | ||
| 1112 | + padding: {cfg.callout.padding}px !important; | ||
| 1113 | + box-sizing: border-box; | ||
| 1114 | + overflow: hidden; | ||
| 848 | }} | 1115 | }} |
| 849 | 1116 | ||
| 850 | /* 响应式调整 */ | 1117 | /* 响应式调整 */ |
| @@ -6,6 +6,7 @@ PDF渲染器 - 使用WeasyPrint从HTML生成PDF | @@ -6,6 +6,7 @@ PDF渲染器 - 使用WeasyPrint从HTML生成PDF | ||
| 6 | from __future__ import annotations | 6 | from __future__ import annotations |
| 7 | 7 | ||
| 8 | import base64 | 8 | import base64 |
| 9 | +import copy | ||
| 9 | from pathlib import Path | 10 | from pathlib import Path |
| 10 | from typing import Any, Dict | 11 | from typing import Any, Dict |
| 11 | from datetime import datetime | 12 | from datetime import datetime |
| @@ -86,6 +87,102 @@ class PDFRenderer: | @@ -86,6 +87,102 @@ class PDFRenderer: | ||
| 86 | 87 | ||
| 87 | raise FileNotFoundError(f"未找到字体文件,请检查 {fonts_dir} 目录") | 88 | raise FileNotFoundError(f"未找到字体文件,请检查 {fonts_dir} 目录") |
| 88 | 89 | ||
| 90 | + def _preprocess_charts(self, document_ir: Dict[str, Any]) -> Dict[str, Any]: | ||
| 91 | + """ | ||
| 92 | + 预处理图表:验证和修复所有图表数据 | ||
| 93 | + | ||
| 94 | + 这个方法确保在转换为SVG之前,所有图表数据都是有效的。 | ||
| 95 | + 使用与HTMLRenderer相同的验证和修复逻辑,保证PDF和HTML的一致性。 | ||
| 96 | + | ||
| 97 | + 参数: | ||
| 98 | + document_ir: Document IR数据 | ||
| 99 | + | ||
| 100 | + 返回: | ||
| 101 | + Dict[str, Any]: 修复后的Document IR(深拷贝) | ||
| 102 | + """ | ||
| 103 | + # 深拷贝以避免修改原始IR | ||
| 104 | + ir_copy = copy.deepcopy(document_ir) | ||
| 105 | + | ||
| 106 | + repair_stats = { | ||
| 107 | + 'total': 0, | ||
| 108 | + 'repaired': 0, | ||
| 109 | + 'failed': 0 | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + def repair_widgets_in_blocks(blocks: list) -> None: | ||
| 113 | + """递归修复blocks中的所有widget""" | ||
| 114 | + for block in blocks: | ||
| 115 | + if not isinstance(block, dict): | ||
| 116 | + continue | ||
| 117 | + | ||
| 118 | + # 处理widget类型 | ||
| 119 | + if block.get('type') == 'widget': | ||
| 120 | + widget_type = block.get('widgetType', '') | ||
| 121 | + if widget_type.startswith('chart.js'): | ||
| 122 | + repair_stats['total'] += 1 | ||
| 123 | + | ||
| 124 | + # 使用HTMLRenderer的验证器和修复器 | ||
| 125 | + validation = self.html_renderer.chart_validator.validate(block) | ||
| 126 | + | ||
| 127 | + if not validation.is_valid: | ||
| 128 | + logger.debug(f"图表 {block.get('widgetId')} 需要修复: {validation.errors}") | ||
| 129 | + | ||
| 130 | + # 尝试修复 | ||
| 131 | + repair_result = self.html_renderer.chart_repairer.repair(block, validation) | ||
| 132 | + | ||
| 133 | + if repair_result.success and repair_result.repaired_block: | ||
| 134 | + # 更新block内容(在副本中) | ||
| 135 | + block.update(repair_result.repaired_block) | ||
| 136 | + repair_stats['repaired'] += 1 | ||
| 137 | + logger.debug( | ||
| 138 | + f"图表 {block.get('widgetId')} 已修复 " | ||
| 139 | + f"(方法: {repair_result.method})" | ||
| 140 | + ) | ||
| 141 | + else: | ||
| 142 | + repair_stats['failed'] += 1 | ||
| 143 | + logger.warning( | ||
| 144 | + f"图表 {block.get('widgetId')} 修复失败,将使用原始数据" | ||
| 145 | + ) | ||
| 146 | + | ||
| 147 | + # 递归处理嵌套的blocks | ||
| 148 | + nested_blocks = block.get('blocks') | ||
| 149 | + if isinstance(nested_blocks, list): | ||
| 150 | + repair_widgets_in_blocks(nested_blocks) | ||
| 151 | + | ||
| 152 | + # 处理列表项 | ||
| 153 | + if block.get('type') == 'list': | ||
| 154 | + items = block.get('items', []) | ||
| 155 | + for item in items: | ||
| 156 | + if isinstance(item, list): | ||
| 157 | + repair_widgets_in_blocks(item) | ||
| 158 | + | ||
| 159 | + # 处理表格单元格 | ||
| 160 | + if block.get('type') == 'table': | ||
| 161 | + rows = block.get('rows', []) | ||
| 162 | + for row in rows: | ||
| 163 | + cells = row.get('cells', []) | ||
| 164 | + for cell in cells: | ||
| 165 | + cell_blocks = cell.get('blocks', []) | ||
| 166 | + if isinstance(cell_blocks, list): | ||
| 167 | + repair_widgets_in_blocks(cell_blocks) | ||
| 168 | + | ||
| 169 | + # 处理所有章节 | ||
| 170 | + chapters = ir_copy.get('chapters', []) | ||
| 171 | + for chapter in chapters: | ||
| 172 | + blocks = chapter.get('blocks', []) | ||
| 173 | + repair_widgets_in_blocks(blocks) | ||
| 174 | + | ||
| 175 | + # 输出统计信息 | ||
| 176 | + if repair_stats['total'] > 0: | ||
| 177 | + logger.info( | ||
| 178 | + f"PDF图表预处理完成: " | ||
| 179 | + f"总计 {repair_stats['total']} 个图表, " | ||
| 180 | + f"修复 {repair_stats['repaired']} 个, " | ||
| 181 | + f"失败 {repair_stats['failed']} 个" | ||
| 182 | + ) | ||
| 183 | + | ||
| 184 | + return ir_copy | ||
| 185 | + | ||
| 89 | def _convert_charts_to_svg(self, document_ir: Dict[str, Any]) -> Dict[str, str]: | 186 | def _convert_charts_to_svg(self, document_ir: Dict[str, Any]) -> Dict[str, str]: |
| 90 | """ | 187 | """ |
| 91 | 将document_ir中的所有图表转换为SVG | 188 | 将document_ir中的所有图表转换为SVG |
| @@ -260,11 +357,17 @@ class PDFRenderer: | @@ -260,11 +357,17 @@ class PDFRenderer: | ||
| 260 | else: | 357 | else: |
| 261 | layout_config = self.layout_optimizer.config | 358 | layout_config = self.layout_optimizer.config |
| 262 | 359 | ||
| 263 | - # 转换图表为SVG | 360 | + # 关键修复:先预处理图表,确保数据有效 |
| 361 | + logger.info("预处理图表数据...") | ||
| 362 | + preprocessed_ir = self._preprocess_charts(document_ir) | ||
| 363 | + | ||
| 364 | + # 转换图表为SVG(使用预处理后的IR) | ||
| 264 | logger.info("开始转换图表为SVG矢量图形...") | 365 | logger.info("开始转换图表为SVG矢量图形...") |
| 265 | - svg_map = self._convert_charts_to_svg(document_ir) | 366 | + svg_map = self._convert_charts_to_svg(preprocessed_ir) |
| 266 | 367 | ||
| 267 | - # 使用HTML渲染器生成基础HTML | 368 | + # 使用HTML渲染器生成基础HTML(使用原始IR,因为HTMLRenderer会自己修复) |
| 369 | + # 注意:这里仍使用原始document_ir,因为HTMLRenderer内部会进行相同的修复 | ||
| 370 | + # 这确保了HTML和SVG使用相同的修复逻辑 | ||
| 268 | html = self.html_renderer.render(document_ir) | 371 | html = self.html_renderer.render(document_ir) |
| 269 | 372 | ||
| 270 | # 注入SVG | 373 | # 注入SVG |
-
Please register or login to post a comment