马一丁

Fixed the PDF Rendering Overflow Issue and Updated the Logic for Rendering PDFs

@@ -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,7 +541,13 @@ class HTMLRenderer: @@ -535,7 +541,13 @@ class HTMLRenderer:
535 """ 541 """
536 542
537 return f""" 543 return f"""
538 -<section class="hero-section"> 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>
  549 + </div>
  550 + <div class="hero-body">
539 <div class="hero-content"> 551 <div class="hero-content">
540 {summary_html} 552 {summary_html}
541 <ul class="hero-highlights">{highlight_html}</ul> 553 <ul class="hero-highlights">{highlight_html}</ul>
@@ -544,6 +556,7 @@ class HTMLRenderer: @@ -544,6 +556,7 @@ class HTMLRenderer:
544 <div class="hero-side"> 556 <div class="hero-side">
545 {kpi_cards} 557 {kpi_cards}
546 </div> 558 </div>
  559 + </div>
547 </section> 560 </section>
548 """.strip() 561 """.strip()
549 562
@@ -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