马一丁
Committed by GitHub

Merge pull request #489 from 666ghj/Blocked-HTML

Blocked html
@@ -31,6 +31,7 @@ from .nodes import ( @@ -31,6 +31,7 @@ from .nodes import (
31 ChapterGenerationNode, 31 ChapterGenerationNode,
32 ChapterJsonParseError, 32 ChapterJsonParseError,
33 ChapterContentError, 33 ChapterContentError,
  34 + ChapterValidationError,
34 DocumentLayoutNode, 35 DocumentLayoutNode,
35 WordBudgetNode, 36 WordBudgetNode,
36 ) 37 )
@@ -601,11 +602,16 @@ class ReportAgent: @@ -601,11 +602,16 @@ class ReportAgent:
601 stream_callback=chunk_callback 602 stream_callback=chunk_callback
602 ) 603 )
603 break 604 break
604 - except (ChapterJsonParseError, ChapterContentError) as structured_error:  
605 - error_kind = (  
606 - "content_sparse" if isinstance(structured_error, ChapterContentError) else "json_parse"  
607 - )  
608 - readable_label = "内容密度异常" if error_kind == "content_sparse" else "JSON解析失败" 605 + except (ChapterJsonParseError, ChapterContentError, ChapterValidationError) as structured_error:
  606 + if isinstance(structured_error, ChapterContentError):
  607 + error_kind = "content_sparse"
  608 + readable_label = "内容密度异常"
  609 + elif isinstance(structured_error, ChapterValidationError):
  610 + error_kind = "validation"
  611 + readable_label = "结构校验失败"
  612 + else:
  613 + error_kind = "json_parse"
  614 + readable_label = "JSON解析失败"
609 if isinstance(structured_error, ChapterContentError): 615 if isinstance(structured_error, ChapterContentError):
610 candidate = getattr(structured_error, "chapter_payload", None) 616 candidate = getattr(structured_error, "chapter_payload", None)
611 candidate_score = getattr(structured_error, "body_characters", 0) or 0 617 candidate_score = getattr(structured_error, "body_characters", 0) or 0
@@ -636,6 +642,10 @@ class ReportAgent: @@ -636,6 +642,10 @@ class ReportAgent:
636 'error': str(structured_error), 642 'error': str(structured_error),
637 'reason': error_kind, 643 'reason': error_kind,
638 } 644 }
  645 + if isinstance(structured_error, ChapterValidationError):
  646 + validation_errors = getattr(structured_error, "errors", None)
  647 + if validation_errors:
  648 + status_payload['errors'] = validation_errors
639 if will_fallback: 649 if will_fallback:
640 status_payload['warning'] = 'content_sparse_fallback_pending' 650 status_payload['warning'] = 'content_sparse_fallback_pending'
641 emit('chapter_status', status_payload) 651 emit('chapter_status', status_payload)
@@ -33,6 +33,8 @@ ALLOWED_BLOCK_TYPES: List[str] = [ @@ -33,6 +33,8 @@ ALLOWED_BLOCK_TYPES: List[str] = [
33 "paragraph", 33 "paragraph",
34 "list", 34 "list",
35 "table", 35 "table",
  36 + "swotTable",
  37 + "pestTable",
36 "blockquote", 38 "blockquote",
37 "engineQuote", 39 "engineQuote",
38 "hr", 40 "hr",
@@ -169,6 +171,137 @@ table_block: Dict[str, Any] = { @@ -169,6 +171,137 @@ table_block: Dict[str, Any] = {
169 "additionalProperties": True, 171 "additionalProperties": True,
170 } 172 }
171 173
  174 +swot_item_schema: Dict[str, Any] = {
  175 + "title": "SwotItem",
  176 + "oneOf": [
  177 + {"type": "string"},
  178 + {
  179 + "type": "object",
  180 + "properties": {
  181 + "title": {"type": "string"},
  182 + "label": {"type": "string"},
  183 + "text": {"type": "string"},
  184 + "detail": {"type": "string"},
  185 + "description": {"type": "string"},
  186 + "evidence": {"type": "string"},
  187 + "impact": {
  188 + "type": "string",
  189 + "enum": ["低", "中低", "中", "中高", "高", "极高"],
  190 + "description": "影响评级,只允许填写:低/中低/中/中高/高/极高",
  191 + },
  192 + # "score": {
  193 + # "type": "number",
  194 + # "minimum": 0,
  195 + # "maximum": 10,
  196 + # "description": "评分,只允许0-10的数字",
  197 + # },
  198 + "priority": {"type": ["string", "number"]},
  199 + },
  200 + "required": [],
  201 + "additionalProperties": True,
  202 + },
  203 + ],
  204 +}
  205 +
  206 +swot_block: Dict[str, Any] = {
  207 + "title": "SwotTableBlock",
  208 + "type": "object",
  209 + "properties": {
  210 + "type": {"const": "swotTable"},
  211 + "title": {"type": "string"},
  212 + "summary": {"type": "string"},
  213 + "strengths": {
  214 + "type": "array",
  215 + "items": {"$ref": "#/definitions/swotItem"},
  216 + },
  217 + "weaknesses": {
  218 + "type": "array",
  219 + "items": {"$ref": "#/definitions/swotItem"},
  220 + },
  221 + "opportunities": {
  222 + "type": "array",
  223 + "items": {"$ref": "#/definitions/swotItem"},
  224 + },
  225 + "threats": {
  226 + "type": "array",
  227 + "items": {"$ref": "#/definitions/swotItem"},
  228 + },
  229 + },
  230 + "required": ["type"],
  231 + "anyOf": [
  232 + {"required": ["strengths"]},
  233 + {"required": ["weaknesses"]},
  234 + {"required": ["opportunities"]},
  235 + {"required": ["threats"]},
  236 + ],
  237 + "additionalProperties": True,
  238 +}
  239 +
  240 +pest_item_schema: Dict[str, Any] = {
  241 + "title": "PestItem",
  242 + "oneOf": [
  243 + {"type": "string"},
  244 + {
  245 + "type": "object",
  246 + "properties": {
  247 + "title": {"type": "string"},
  248 + "label": {"type": "string"},
  249 + "text": {"type": "string"},
  250 + "detail": {"type": "string"},
  251 + "description": {"type": "string"},
  252 + "source": {"type": "string"},
  253 + "evidence": {"type": "string"},
  254 + "trend": {
  255 + "type": "string",
  256 + "enum": ["正面利好", "负面影响", "中性", "不确定", "持续观察"],
  257 + "description": "趋势/影响评估,只允许填写:正面利好/负面影响/中性/不确定/持续观察",
  258 + },
  259 + "impact": {"type": ["string", "number"]},
  260 + },
  261 + "required": [],
  262 + "additionalProperties": True,
  263 + },
  264 + ],
  265 +}
  266 +
  267 +pest_block: Dict[str, Any] = {
  268 + "title": "PestTableBlock",
  269 + "type": "object",
  270 + "properties": {
  271 + "type": {"const": "pestTable"},
  272 + "title": {"type": "string"},
  273 + "summary": {"type": "string"},
  274 + "political": {
  275 + "type": "array",
  276 + "items": {"$ref": "#/definitions/pestItem"},
  277 + "description": "政治因素:政策法规、政府态度、政治稳定性等",
  278 + },
  279 + "economic": {
  280 + "type": "array",
  281 + "items": {"$ref": "#/definitions/pestItem"},
  282 + "description": "经济因素:经济周期、利率汇率、消费水平等",
  283 + },
  284 + "social": {
  285 + "type": "array",
  286 + "items": {"$ref": "#/definitions/pestItem"},
  287 + "description": "社会因素:人口结构、文化趋势、生活方式等",
  288 + },
  289 + "technological": {
  290 + "type": "array",
  291 + "items": {"$ref": "#/definitions/pestItem"},
  292 + "description": "技术因素:技术创新、研发投入、技术普及等",
  293 + },
  294 + },
  295 + "required": ["type"],
  296 + "anyOf": [
  297 + {"required": ["political"]},
  298 + {"required": ["economic"]},
  299 + {"required": ["social"]},
  300 + {"required": ["technological"]},
  301 + ],
  302 + "additionalProperties": True,
  303 +}
  304 +
172 blockquote_block: Dict[str, Any] = { 305 blockquote_block: Dict[str, Any] = {
173 "title": "BlockquoteBlock", 306 "title": "BlockquoteBlock",
174 "type": "object", 307 "type": "object",
@@ -361,6 +494,8 @@ block_variants: List[Dict[str, Any]] = [ @@ -361,6 +494,8 @@ block_variants: List[Dict[str, Any]] = [
361 kpi_block, 494 kpi_block,
362 widget_block, 495 widget_block,
363 toc_block, 496 toc_block,
  497 + swot_block,
  498 + pest_block,
364 ] 499 ]
365 500
366 CHAPTER_JSON_SCHEMA: Dict[str, Any] = { 501 CHAPTER_JSON_SCHEMA: Dict[str, Any] = {
@@ -388,6 +523,8 @@ CHAPTER_JSON_SCHEMA: Dict[str, Any] = { @@ -388,6 +523,8 @@ CHAPTER_JSON_SCHEMA: Dict[str, Any] = {
388 "definitions": { 523 "definitions": {
389 "inlineMark": inline_mark_schema, 524 "inlineMark": inline_mark_schema,
390 "inlineRun": inline_run_schema, 525 "inlineRun": inline_run_schema,
  526 + "swotItem": swot_item_schema,
  527 + "pestItem": pest_item_schema,
391 "block": {"oneOf": block_variants}, 528 "block": {"oneOf": block_variants},
392 }, 529 },
393 } 530 }
@@ -132,6 +132,69 @@ class IRValidator: @@ -132,6 +132,69 @@ class IRValidator:
132 errors, 132 errors,
133 ) 133 )
134 134
  135 + def _validate_swotTable_block(self, block: Dict[str, Any], path: str, errors: List[str]):
  136 + """SWOT表至少提供四象限之一,每象限为条目数组"""
  137 + quadrants = ("strengths", "weaknesses", "opportunities", "threats")
  138 + if not any(block.get(name) is not None for name in quadrants):
  139 + errors.append(f"{path} 需要至少包含 strengths/weaknesses/opportunities/threats 之一")
  140 + for name in quadrants:
  141 + entries = block.get(name)
  142 + if entries is None:
  143 + continue
  144 + if not isinstance(entries, list):
  145 + errors.append(f"{path}.{name} 必须是数组")
  146 + continue
  147 + for idx, entry in enumerate(entries):
  148 + self._validate_swot_item(entry, f"{path}.{name}[{idx}]", errors)
  149 +
  150 + # SWOT impact 字段允许的评级值
  151 + ALLOWED_IMPACT_VALUES = {"低", "中低", "中", "中高", "高", "极高"}
  152 +
  153 + def _validate_swot_item(self, item: Any, path: str, errors: List[str]):
  154 + """单个SWOT条目支持字符串或带字段的对象"""
  155 + if isinstance(item, str):
  156 + if not item.strip():
  157 + errors.append(f"{path} 不能为空字符串")
  158 + return
  159 + if not isinstance(item, dict):
  160 + errors.append(f"{path} 必须是字符串或对象")
  161 + return
  162 + title = None
  163 + for key in ("title", "label", "text", "detail", "description"):
  164 + value = item.get(key)
  165 + if isinstance(value, str) and value.strip():
  166 + title = value
  167 + break
  168 + if title is None:
  169 + errors.append(f"{path} 缺少 title/label/text/description 等文字字段")
  170 +
  171 + # 校验 impact 字段:只允许评级值
  172 + impact = item.get("impact")
  173 + if impact is not None:
  174 + if not isinstance(impact, str) or impact not in self.ALLOWED_IMPACT_VALUES:
  175 + errors.append(
  176 + f"{path}.impact 只允许填写影响评级(低/中低/中/中高/高/极高),"
  177 + f"当前值: {impact};如需详细说明请写入 detail 字段"
  178 + )
  179 +
  180 + # # 校验 score 字段:只允许 0-10 的数字(已禁用)
  181 + # score = item.get("score")
  182 + # if score is not None:
  183 + # valid_score = False
  184 + # if isinstance(score, (int, float)):
  185 + # valid_score = 0 <= score <= 10
  186 + # elif isinstance(score, str):
  187 + # # 兼容字符串形式的数字
  188 + # try:
  189 + # numeric_score = float(score)
  190 + # valid_score = 0 <= numeric_score <= 10
  191 + # except ValueError:
  192 + # valid_score = False
  193 + # if not valid_score:
  194 + # errors.append(
  195 + # f"{path}.score 只允许填写 0-10 的数字,当前值: {score}"
  196 + # )
  197 +
135 def _validate_blockquote_block( 198 def _validate_blockquote_block(
136 self, block: Dict[str, Any], path: str, errors: List[str] 199 self, block: Dict[str, Any], path: str, errors: List[str]
137 ): 200 ):
@@ -6,7 +6,12 @@ Report Engine节点处理模块。 @@ -6,7 +6,12 @@ Report Engine节点处理模块。
6 6
7 from .base_node import BaseNode, StateMutationNode 7 from .base_node import BaseNode, StateMutationNode
8 from .template_selection_node import TemplateSelectionNode 8 from .template_selection_node import TemplateSelectionNode
9 -from .chapter_generation_node import ChapterGenerationNode, ChapterJsonParseError, ChapterContentError 9 +from .chapter_generation_node import (
  10 + ChapterGenerationNode,
  11 + ChapterJsonParseError,
  12 + ChapterContentError,
  13 + ChapterValidationError,
  14 +)
10 from .document_layout_node import DocumentLayoutNode 15 from .document_layout_node import DocumentLayoutNode
11 from .word_budget_node import WordBudgetNode 16 from .word_budget_node import WordBudgetNode
12 17
@@ -17,6 +22,7 @@ __all__ = [ @@ -17,6 +22,7 @@ __all__ = [
17 "ChapterGenerationNode", 22 "ChapterGenerationNode",
18 "ChapterJsonParseError", 23 "ChapterJsonParseError",
19 "ChapterContentError", 24 "ChapterContentError",
  25 + "ChapterValidationError",
20 "DocumentLayoutNode", 26 "DocumentLayoutNode",
21 "WordBudgetNode", 27 "WordBudgetNode",
22 ] 28 ]
@@ -77,6 +77,18 @@ class ChapterContentError(ValueError): @@ -77,6 +77,18 @@ class ChapterContentError(ValueError):
77 self.non_heading_blocks: int = int(non_heading_blocks or 0) 77 self.non_heading_blocks: int = int(non_heading_blocks or 0)
78 78
79 79
  80 +class ChapterValidationError(ValueError):
  81 + """
  82 + 章节结构在本地和LLM修复后仍无法通过校验时抛出。
  83 +
  84 + 该异常用于在Agent层触发针对单章的重试,而无需重启整本报告。
  85 + """
  86 +
  87 + def __init__(self, message: str, errors: Optional[List[str]] | None = None):
  88 + super().__init__(message)
  89 + self.errors: List[str] = list(errors or [])
  90 +
  91 +
80 class ChapterGenerationNode(BaseNode): 92 class ChapterGenerationNode(BaseNode):
81 """ 93 """
82 负责按章节调用LLM并校验JSON结构。 94 负责按章节调用LLM并校验JSON结构。
@@ -268,8 +280,9 @@ class ChapterGenerationNode(BaseNode): @@ -268,8 +280,9 @@ class ChapterGenerationNode(BaseNode):
268 ) 280 )
269 281
270 if not valid: 282 if not valid:
271 - raise ValueError(  
272 - f"{section.title} 章节JSON校验失败: {'; '.join(errors[:5])}" 283 + raise ChapterValidationError(
  284 + f"{section.title} 章节JSON校验失败: {'; '.join(errors[:5])}",
  285 + errors=errors,
273 ) 286 )
274 if content_error: 287 if content_error:
275 raise content_error 288 raise content_error
@@ -293,6 +306,11 @@ class ChapterGenerationNode(BaseNode): @@ -293,6 +306,11 @@ class ChapterGenerationNode(BaseNode):
293 # 章节篇幅规划(来自WordBudgetNode),用于指导字数与强调点 306 # 章节篇幅规划(来自WordBudgetNode),用于指导字数与强调点
294 chapter_plan_map = context.get("chapter_directives", {}) 307 chapter_plan_map = context.get("chapter_directives", {})
295 chapter_plan = chapter_plan_map.get(section.chapter_id) if chapter_plan_map else {} 308 chapter_plan = chapter_plan_map.get(section.chapter_id) if chapter_plan_map else {}
  309 +
  310 + # 从 layout 的 tocPlan 中查找该章节是否允许使用SWOT块和PEST块
  311 + allow_swot = self._get_chapter_swot_permission(section.chapter_id, context)
  312 + allow_pest = self._get_chapter_pest_permission(section.chapter_id, context)
  313 +
296 payload = { 314 payload = {
297 "section": { 315 "section": {
298 "chapterId": section.chapter_id, 316 "chapterId": section.chapter_id,
@@ -322,6 +340,8 @@ class ChapterGenerationNode(BaseNode): @@ -322,6 +340,8 @@ class ChapterGenerationNode(BaseNode):
322 "language": "zh-CN", 340 "language": "zh-CN",
323 "maxTokens": context.get("max_tokens", 4096), 341 "maxTokens": context.get("max_tokens", 4096),
324 "allowedBlocks": ALLOWED_BLOCK_TYPES, 342 "allowedBlocks": ALLOWED_BLOCK_TYPES,
  343 + "allowSwot": allow_swot,
  344 + "allowPest": allow_pest,
325 "styleHints": { 345 "styleHints": {
326 "expectWidgets": True, 346 "expectWidgets": True,
327 "forceHeadingAnchors": True, 347 "forceHeadingAnchors": True,
@@ -346,6 +366,72 @@ class ChapterGenerationNode(BaseNode): @@ -346,6 +366,72 @@ class ChapterGenerationNode(BaseNode):
346 payload["globalContext"]["sectionBudgets"] = chapter_plan["sections"] 366 payload["globalContext"]["sectionBudgets"] = chapter_plan["sections"]
347 return payload 367 return payload
348 368
  369 + def _get_chapter_swot_permission(self, chapter_id: str, context: Dict[str, Any]) -> bool:
  370 + """
  371 + 从 layout 的 tocPlan 中查找指定章节是否允许使用 SWOT 块。
  372 +
  373 + 全文最多只有一个章节允许使用 SWOT 块,由文档设计阶段在 tocPlan 中
  374 + 通过 allowSwot 字段标记。
  375 +
  376 + 参数:
  377 + chapter_id: 当前章节ID。
  378 + context: 全局上下文字典。
  379 +
  380 + 返回:
  381 + bool: 如果该章节允许使用 SWOT 块则返回 True,否则返回 False。
  382 + """
  383 + layout = context.get("layout")
  384 + if not isinstance(layout, dict):
  385 + return False
  386 +
  387 + toc_plan = layout.get("tocPlan")
  388 + if not isinstance(toc_plan, list):
  389 + return False
  390 +
  391 + for entry in toc_plan:
  392 + if not isinstance(entry, dict):
  393 + continue
  394 + if entry.get("chapterId") == chapter_id:
  395 + return bool(entry.get("allowSwot", False))
  396 +
  397 + return False
  398 +
  399 + def _get_chapter_pest_permission(self, chapter_id: str, context: Dict[str, Any]) -> bool:
  400 + """
  401 + 从 layout 的 tocPlan 中查找指定章节是否允许使用 PEST 块。
  402 +
  403 + 全文最多只有一个章节允许使用 PEST 块,由文档设计阶段在 tocPlan 中
  404 + 通过 allowPest 字段标记。
  405 +
  406 + PEST块用于宏观环境分析:
  407 + - Political(政治因素)
  408 + - Economic(经济因素)
  409 + - Social(社会因素)
  410 + - Technological(技术因素)
  411 +
  412 + 参数:
  413 + chapter_id: 当前章节ID。
  414 + context: 全局上下文字典。
  415 +
  416 + 返回:
  417 + bool: 如果该章节允许使用 PEST 块则返回 True,否则返回 False。
  418 + """
  419 + layout = context.get("layout")
  420 + if not isinstance(layout, dict):
  421 + return False
  422 +
  423 + toc_plan = layout.get("tocPlan")
  424 + if not isinstance(toc_plan, list):
  425 + return False
  426 +
  427 + for entry in toc_plan:
  428 + if not isinstance(entry, dict):
  429 + continue
  430 + if entry.get("chapterId") == chapter_id:
  431 + return bool(entry.get("allowPest", False))
  432 +
  433 + return False
  434 +
349 def _stream_llm( 435 def _stream_llm(
350 self, 436 self,
351 user_message: str, 437 user_message: str,
@@ -1555,4 +1641,9 @@ class ChapterGenerationNode(BaseNode): @@ -1555,4 +1641,9 @@ class ChapterGenerationNode(BaseNode):
1555 raise last_exc 1641 raise last_exc
1556 1642
1557 1643
1558 -__all__ = ["ChapterGenerationNode", "ChapterJsonParseError"] 1644 +__all__ = [
  1645 + "ChapterGenerationNode",
  1646 + "ChapterJsonParseError",
  1647 + "ChapterContentError",
  1648 + "ChapterValidationError",
  1649 +]
@@ -139,6 +139,14 @@ document_layout_output_schema = { @@ -139,6 +139,14 @@ document_layout_output_schema = {
139 "anchor": {"type": "string"}, 139 "anchor": {"type": "string"},
140 "display": {"type": "string"}, 140 "display": {"type": "string"},
141 "description": {"type": "string"}, 141 "description": {"type": "string"},
  142 + "allowSwot": {
  143 + "type": "boolean",
  144 + "description": "是否允许该章节使用SWOT分析块,全文最多只有一个章节可设为true",
  145 + },
  146 + "allowPest": {
  147 + "type": "boolean",
  148 + "description": "是否允许该章节使用PEST分析块,全文最多只有一个章节可设为true",
  149 + },
142 }, 150 },
143 "required": ["chapterId", "display"], 151 "required": ["chapterId", "display"],
144 }, 152 },
@@ -304,19 +312,30 @@ SYSTEM_PROMPT_CHAPTER_JSON = f""" @@ -304,19 +312,30 @@ SYSTEM_PROMPT_CHAPTER_JSON = f"""
304 3. 所有段落都放入paragraph.inlines,混排样式通过marks表示(bold/italic/color/link等)。 312 3. 所有段落都放入paragraph.inlines,混排样式通过marks表示(bold/italic/color/link等)。
305 4. 所有heading必须包含anchor,锚点与编号保持模板一致,比如section-2-1。 313 4. 所有heading必须包含anchor,锚点与编号保持模板一致,比如section-2-1。
306 5. 表格需给出rows/cells/align,KPI卡请使用kpiGrid,分割线用hr。 314 5. 表格需给出rows/cells/align,KPI卡请使用kpiGrid,分割线用hr。
307 -6. 如需引用图表/交互组件,统一用widgetType表示(例如chart.js/line、chart.js/doughnut)。  
308 -7. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。  
309 -8. engineQuote 仅用于呈现单Agent的原话:使用 block.type="engineQuote",engine 取值 insight/media/query,title 必须固定为对应Agent名字(insight->Insight Agent,media->Media Agent,query->Query Agent,不可自定义),内部 blocks 只允许 paragraph,paragraph.inlines 的 marks 仅可使用 bold/italic(可留空),禁止在 engineQuote 中放表格/图表/引用/公式等;当 reports 或 forumLogs 中有明确的文字段落、结论、数字/时间等可直接引用时,优先分别从 Query/Media/Insight 三个 Agent 摘出关键原文或文字版数据放入 engineQuote,尽量覆盖三类 Agent 而非只用单一来源,严禁臆造内容或把表格/图表改写进 engineQuote。  
310 -9. 如果chapterPlan中包含target/min/max或sections细分预算,请尽量贴合,必要时在notes允许的范围内突破,同时在结构上体现详略;  
311 -10. 一级标题需使用中文数字(“一、二、三”),二级标题使用阿拉伯数字(“1.1、1.2”),heading.text中直接写好编号,与outline顺序对应;  
312 -11. 严禁输出外部图片/AI生图链接,仅可使用Chart.js图表、表格、色块、callout等HTML原生组件;如需视觉辅助请改为文字描述或数据表;  
313 -12. 段落混排需通过marks表达粗体、斜体、下划线、颜色等样式,禁止残留Markdown语法(如**text**);  
314 -13. 行间公式用block.type="math"并填入math.latex,行内公式在paragraph.inlines里将文本设为Latex并加上marks.type="math",渲染层会用MathJax处理;  
315 -14. widget配色需与CSS变量兼容,不要硬编码背景色或文字色,legend/ticks由渲染层控制;  
316 -15. 善用callout、kpiGrid、表格、widget等提升版面丰富度,但必须遵守模板章节范围。  
317 -16. 输出前务必自检JSON语法:禁止出现`{{}}{{`或`][`相连缺少逗号、列表项嵌套超过一层、未闭合的括号或未转义换行,`list` block的items必须是`[[block,...], ...]`结构,若无法满足则返回错误提示而不是输出不合法JSON。  
318 -17. 所有widget块必须在顶层提供`data`或`dataRef`(可将props中的`data`上移),确保Chart.js能够直接渲染;缺失数据时宁可输出表格或段落,绝不留空。  
319 -18. 任何block都必须声明合法`type`(heading/paragraph/list/...);若需要普通文本请使用`paragraph`并给出`inlines`,禁止返回`type:null`或未知值。 315 +6. **SWOT块使用限制(重要!)**:
  316 + - 只有在 constraints.allowSwot 为 true 时才允许使用 block.type="swotTable";
  317 + - 如果 constraints.allowSwot 为 false 或不存在,严禁生成任何 swotTable 类型的块,即使章节标题包含"SWOT"字样也不能使用该块类型,应改用表格(table)或列表(list)呈现相关内容;
  318 + - 当允许使用SWOT块时,分别填写 strengths/weaknesses/opportunities/threats 数组,单项至少包含 title/label/text 之一,可附加 detail/evidence/impact 字段;title/summary 字段用于概览说明;
  319 + - **特别注意:impact 字段只允许填写影响评级("低"/"中低"/"中"/"中高"/"高"/"极高");任何关于影响的文字叙述、详细说明、佐证或扩展描述必须写入 detail 字段,禁止在 impact 字段中混入描述性文字。**
  320 +7. **PEST块使用限制(重要!)**:
  321 + - 只有在 constraints.allowPest 为 true 时才允许使用 block.type="pestTable";
  322 + - 如果 constraints.allowPest 为 false 或不存在,严禁生成任何 pestTable 类型的块,即使章节标题包含"PEST"、"宏观环境"等字样也不能使用该块类型,应改用表格(table)或列表(list)呈现相关内容;
  323 + - 当允许使用PEST块时,分别填写 political/economic/social/technological 数组,单项至少包含 title/label/text 之一,可附加 detail/source/trend 字段;title/summary 字段用于概览说明;
  324 + - **PEST四维度说明**:political(政治因素:政策法规、政府态度、监管环境)、economic(经济因素:经济周期、利率汇率、市场需求)、social(社会因素:人口结构、文化趋势、消费习惯)、technological(技术因素:技术创新、研发趋势、数字化程度);
  325 + - **特别注意:trend 字段只允许填写趋势评估("正面利好"/"负面影响"/"中性"/"不确定"/"持续观察");任何关于趋势的文字叙述、详细说明、来源或扩展描述必须写入 detail 字段,禁止在 trend 字段中混入描述性文字。**
  326 +8. 如需引用图表/交互组件,统一用widgetType表示(例如chart.js/line、chart.js/doughnut)。
  327 +9. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。
  328 +10. engineQuote 仅用于呈现单Agent的原话:使用 block.type="engineQuote",engine 取值 insight/media/query,title 必须固定为对应Agent名字(insight->Insight Agent,media->Media Agent,query->Query Agent,不可自定义),内部 blocks 只允许 paragraph,paragraph.inlines 的 marks 仅可使用 bold/italic(可留空),禁止在 engineQuote 中放表格/图表/引用/公式等;当 reports 或 forumLogs 中有明确的文字段落、结论、数字/时间等可直接引用时,优先分别从 Query/Media/Insight 三个 Agent 摘出关键原文或文字版数据放入 engineQuote,尽量覆盖三类 Agent 而非只用单一来源,严禁臆造内容或把表格/图表改写进 engineQuote。
  329 +11. 如果chapterPlan中包含target/min/max或sections细分预算,请尽量贴合,必要时在notes允许的范围内突破,同时在结构上体现详略;
  330 +12. 一级标题需使用中文数字(“一、二、三”),二级标题使用阿拉伯数字(“1.1、1.2”),heading.text中直接写好编号,与outline顺序对应;
  331 +13. 严禁输出外部图片/AI生图链接,仅可使用Chart.js图表、表格、色块、callout等HTML原生组件;如需视觉辅助请改为文字描述或数据表;
  332 +14. 段落混排需通过marks表达粗体、斜体、下划线、颜色等样式,禁止残留Markdown语法(如**text**);
  333 +15. 行间公式用block.type="math"并填入math.latex,行内公式在paragraph.inlines里将文本设为Latex并加上marks.type="math",渲染层会用MathJax处理;
  334 +16. widget配色需与CSS变量兼容,不要硬编码背景色或文字色,legend/ticks由渲染层控制;
  335 +17. 善用callout、kpiGrid、表格、widget等提升版面丰富度,但必须遵守模板章节范围。
  336 +18. 输出前务必自检JSON语法:禁止出现`{{}}{{`或`][`相连缺少逗号、列表项嵌套超过一层、未闭合的括号或未转义换行,`list` block的items必须是`[[block,...], ...]`结构,若无法满足则返回错误提示而不是输出不合法JSON。
  337 +19. 所有widget块必须在顶层提供`data`或`dataRef`(可将props中的`data`上移),确保Chart.js能够直接渲染;缺失数据时宁可输出表格或段落,绝不留空。
  338 +20. 任何block都必须声明合法`type`(heading/paragraph/list/...);若需要普通文本请使用`paragraph`并给出`inlines`,禁止返回`type:null`或未知值。
320 339
321 <CHAPTER JSON SCHEMA> 340 <CHAPTER JSON SCHEMA>
322 {CHAPTER_JSON_SCHEMA_TEXT} 341 {CHAPTER_JSON_SCHEMA_TEXT}
@@ -375,7 +394,19 @@ SYSTEM_PROMPT_DOCUMENT_LAYOUT = f""" @@ -375,7 +394,19 @@ SYSTEM_PROMPT_DOCUMENT_LAYOUT = f"""
375 3. 输出 tocPlan,一级目录固定用中文数字("一、二、三"),二级目录用"1.1/1.2",可在description里说明详略;如需定制目录标题,请填写 tocTitle; 394 3. 输出 tocPlan,一级目录固定用中文数字("一、二、三"),二级目录用"1.1/1.2",可在description里说明详略;如需定制目录标题,请填写 tocTitle;
376 4. 根据模板结构和素材密度,为 themeTokens / layoutNotes 提出字体、字号、留白建议(需特别强调目录、正文一级标题字号保持统一),如需色板或暗黑模式兼容也在此说明; 395 4. 根据模板结构和素材密度,为 themeTokens / layoutNotes 提出字体、字号、留白建议(需特别强调目录、正文一级标题字号保持统一),如需色板或暗黑模式兼容也在此说明;
377 5. 严禁要求外部图片或AI生图,推荐Chart.js图表、表格、色块、KPI卡等可直接渲染的原生组件; 396 5. 严禁要求外部图片或AI生图,推荐Chart.js图表、表格、色块、KPI卡等可直接渲染的原生组件;
378 -6. 不随意增删章节,仅优化命名或描述;若有排版或章节合并提示,请放入 layoutNotes,渲染层会严格遵循。 397 +6. 不随意增删章节,仅优化命名或描述;若有排版或章节合并提示,请放入 layoutNotes,渲染层会严格遵循;
  398 +7. **SWOT块使用规则**:在 tocPlan 中决定是否以及在哪一章使用SWOT分析块(swotTable):
  399 + - 全文最多只允许一个章节使用SWOT块,该章节需设置 `allowSwot: true`;
  400 + - 其他章节必须设置 `allowSwot: false` 或省略该字段;
  401 + - SWOT块适合出现在"结论与建议"、"综合评估"、"战略分析"等总结性章节;
  402 + - 如果报告内容不适合使用SWOT分析(如纯数据监测报告),则所有章节都不设置 `allowSwot: true`。
  403 +8. **PEST块使用规则**:在 tocPlan 中决定是否以及在哪一章使用PEST宏观环境分析块(pestTable):
  404 + - 全文最多只允许一个章节使用PEST块,该章节需设置 `allowPest: true`;
  405 + - 其他章节必须设置 `allowPest: false` 或省略该字段;
  406 + - PEST块用于分析宏观环境因素(政治Political、经济Economic、社会Social、技术Technological);
  407 + - PEST块适合出现在"行业环境分析"、"宏观背景"、"外部环境研判"等分析宏观因素的章节;
  408 + - 如果报告主题与宏观环境分析无关(如具体事件危机公关报告),则所有章节都不设置 `allowPest: true`;
  409 + - SWOT和PEST不应出现在同一章节,二者分别侧重内部能力与外部环境。
379 410
380 **tocPlan的description字段特别要求:** 411 **tocPlan的description字段特别要求:**
381 - description字段必须是纯文本描述,用于在目录中展示章节简介 412 - description字段必须是纯文本描述,用于在目录中展示章节简介
This diff could not be displayed because it is too large.
@@ -915,16 +915,17 @@ p {{ @@ -915,16 +915,17 @@ p {{
915 height: auto; 915 height: auto;
916 display: flex; 916 display: flex;
917 flex-direction: column; 917 flex-direction: column;
  918 + align-items: stretch !important;
918 gap: 8px; 919 gap: 8px;
919 }} 920 }}
920 921
921 .kpi-card .kpi-value {{ 922 .kpi-card .kpi-value {{
922 font-size: {body_kpi_value}px !important; 923 font-size: {body_kpi_value}px !important;
923 line-height: 1.25; 924 line-height: 1.25;
924 - word-break: break-word;  
925 - overflow-wrap: break-word;  
926 - hyphens: auto;  
927 - max-width: 100%; 925 + white-space: nowrap;
  926 + width: 100%;
  927 + overflow: hidden;
  928 + text-overflow: ellipsis;
928 display: flex; 929 display: flex;
929 flex-wrap: nowrap; 930 flex-wrap: nowrap;
930 align-items: baseline; 931 align-items: baseline;
@@ -1158,8 +1159,8 @@ td {{ @@ -1158,8 +1159,8 @@ td {{
1158 1159
1159 .hero-kpi .value {{ 1160 .hero-kpi .value {{
1160 font-size: {overview_kpi_value}px !important; 1161 font-size: {overview_kpi_value}px !important;
1161 - word-break: break-word;  
1162 - overflow-wrap: break-word; 1162 + white-space: nowrap;
  1163 + width: 100%;
1163 max-width: 100%; 1164 max-width: 100%;
1164 line-height: 1.1; 1165 line-height: 1.1;
1165 display: block; 1166 display: block;
@@ -1000,6 +1000,133 @@ body {{ @@ -1000,6 +1000,133 @@ body {{
1000 background: white !important; 1000 background: white !important;
1001 }} 1001 }}
1002 1002
  1003 +/* ========== 修复 WeasyPrint CSS 变量渐变兼容性问题 ========== */
  1004 +/* WeasyPrint 不支持在 linear-gradient 中使用 var(),需要用静态值覆盖 */
  1005 +
  1006 +/* 覆盖按钮渐变 */
  1007 +.action-btn {{
  1008 + background: linear-gradient(135deg, #4a90e2 0%, #17a2b8 100%) !important;
  1009 +}}
  1010 +
  1011 +/* 覆盖进度条渐变 */
  1012 +.export-progress::after {{
  1013 + background: linear-gradient(90deg, #4a90e2, #17a2b8) !important;
  1014 +}}
  1015 +
  1016 +/* 覆盖 PEST 卡片标题渐变 */
  1017 +.pest-card__title {{
  1018 + background: linear-gradient(135deg, #8e44ad, #2980b9) !important;
  1019 + -webkit-background-clip: text !important;
  1020 + -webkit-text-fill-color: transparent !important;
  1021 + background-clip: text !important;
  1022 +}}
  1023 +
  1024 +/* 覆盖 PEST 条带指示器渐变 */
  1025 +.pest-strip__indicator.political {{
  1026 + background: linear-gradient(180deg, #8e44ad, rgba(142,68,173,0.8)) !important;
  1027 +}}
  1028 +.pest-strip__indicator.economic {{
  1029 + background: linear-gradient(180deg, #16a085, rgba(22,160,133,0.8)) !important;
  1030 +}}
  1031 +.pest-strip__indicator.social {{
  1032 + background: linear-gradient(180deg, #e84393, rgba(232,67,147,0.8)) !important;
  1033 +}}
  1034 +.pest-strip__indicator.technological {{
  1035 + background: linear-gradient(180deg, #2980b9, rgba(41,128,185,0.8)) !important;
  1036 +}}
  1037 +
  1038 +/* 覆盖 PEST 条带背景(原来使用 var(--pest-strip-*-bg),包含渐变和变量) */
  1039 +.pest-strip {{
  1040 + background: #ffffff !important;
  1041 +}}
  1042 +.pest-strip.political {{
  1043 + background: linear-gradient(90deg, rgba(142,68,173,0.08), rgba(255,255,255,0.85)), #ffffff !important;
  1044 + border-color: rgba(142,68,173,0.4) !important;
  1045 +}}
  1046 +.pest-strip.economic {{
  1047 + background: linear-gradient(90deg, rgba(22,160,133,0.08), rgba(255,255,255,0.85)), #ffffff !important;
  1048 + border-color: rgba(22,160,133,0.4) !important;
  1049 +}}
  1050 +.pest-strip.social {{
  1051 + background: linear-gradient(90deg, rgba(232,67,147,0.08), rgba(255,255,255,0.85)), #ffffff !important;
  1052 + border-color: rgba(232,67,147,0.4) !important;
  1053 +}}
  1054 +.pest-strip.technological {{
  1055 + background: linear-gradient(90deg, rgba(41,128,185,0.08), rgba(255,255,255,0.85)), #ffffff !important;
  1056 + border-color: rgba(41,128,185,0.4) !important;
  1057 +}}
  1058 +
  1059 +/* 覆盖 SWOT 卡片背景(原来使用 var(--swot-card-bg),包含渐变和变量) */
  1060 +.swot-card {{
  1061 + background: linear-gradient(135deg, rgba(76,132,255,0.04), rgba(28,127,110,0.06)), #ffffff !important;
  1062 +}}
  1063 +
  1064 +/* 覆盖 SWOT 单元格背景(原来使用 var(--swot-cell-*-bg),包含渐变和变量) */
  1065 +.swot-cell {{
  1066 + background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(255,255,255,0.5)) !important;
  1067 +}}
  1068 +.swot-cell.strength {{
  1069 + background: linear-gradient(135deg, rgba(28,127,110,0.07), rgba(255,255,255,0.78)), #ffffff !important;
  1070 + border-color: rgba(28,127,110,0.35) !important;
  1071 +}}
  1072 +.swot-cell.weakness {{
  1073 + background: linear-gradient(135deg, rgba(192,57,43,0.07), rgba(255,255,255,0.78)), #ffffff !important;
  1074 + border-color: rgba(192,57,43,0.35) !important;
  1075 +}}
  1076 +.swot-cell.opportunity {{
  1077 + background: linear-gradient(135deg, rgba(31,90,179,0.07), rgba(255,255,255,0.78)), #ffffff !important;
  1078 + border-color: rgba(31,90,179,0.35) !important;
  1079 +}}
  1080 +.swot-cell.threat {{
  1081 + background: linear-gradient(135deg, rgba(179,107,22,0.07), rgba(255,255,255,0.78)), #ffffff !important;
  1082 + border-color: rgba(179,107,22,0.35) !important;
  1083 +}}
  1084 +
  1085 +/* 覆盖 SWOT 图例项和药丸(使用静态颜色) */
  1086 +.swot-legend__item.strength, .swot-pill.strength {{
  1087 + background: #1c7f6e !important;
  1088 +}}
  1089 +.swot-legend__item.weakness, .swot-pill.weakness {{
  1090 + background: #c0392b !important;
  1091 +}}
  1092 +.swot-legend__item.opportunity, .swot-pill.opportunity {{
  1093 + background: #1f5ab3 !important;
  1094 +}}
  1095 +.swot-legend__item.threat, .swot-pill.threat {{
  1096 + background: #b36b16 !important;
  1097 +}}
  1098 +
  1099 +/* 覆盖其他使用 var() 的元素 */
  1100 +.swot-item {{
  1101 + background: rgba(255,255,255,0.92) !important;
  1102 +}}
  1103 +.swot-tag {{
  1104 + background: rgba(0,0,0,0.04) !important;
  1105 +}}
  1106 +.swot-empty {{
  1107 + border-color: #e0e0e0 !important;
  1108 +}}
  1109 +
  1110 +/* 覆盖 PEST 卡片背景 */
  1111 +.pest-card {{
  1112 + background: linear-gradient(145deg, rgba(142,68,173,0.03), rgba(22,160,133,0.04)), #ffffff !important;
  1113 +}}
  1114 +
  1115 +/* 覆盖图表卡片错误状态渐变 */
  1116 +.chart-card.chart-card--error {{
  1117 + background: linear-gradient(135deg, rgba(0,0,0,0.015), rgba(0,0,0,0.04)) !important;
  1118 +}}
  1119 +
  1120 +/* 覆盖词云徽章渐变 */
  1121 +.wordcloud-badge {{
  1122 + background: linear-gradient(135deg, rgba(74, 144, 226, 0.14) 0%, rgba(74, 144, 226, 0.24) 100%) !important;
  1123 +}}
  1124 +
  1125 +/* 覆盖英雄区域渐变 */
  1126 +.hero-section {{
  1127 + background: linear-gradient(135deg, rgba(0,123,255,0.1), rgba(23,162,184,0.1)) !important;
  1128 +}}
  1129 +
1003 /* SVG图表容器样式 */ 1130 /* SVG图表容器样式 */
1004 .chart-svg-container {{ 1131 .chart-svg-container {{
1005 width: 100%; 1132 width: 100%;
@@ -1049,6 +1176,379 @@ body {{ @@ -1049,6 +1176,379 @@ body {{
1049 min-height: 400px; 1176 min-height: 400px;
1050 }} 1177 }}
1051 1178
  1179 +/* ========== SWOT PDF表格布局 ========== */
  1180 +/* 核心策略:PDF中使用表格形式而非卡片形式,更适合分页 */
  1181 +
  1182 +/* 隐藏HTML卡片布局,显示PDF表格布局 */
  1183 +.swot-card--html {{
  1184 + display: none !important;
  1185 +}}
  1186 +
  1187 +.swot-pdf-wrapper {{
  1188 + display: block !important;
  1189 + margin: 24px 0;
  1190 +}}
  1191 +
  1192 +/* PDF表格整体样式 */
  1193 +.swot-pdf-table {{
  1194 + width: 100% !important;
  1195 + border-collapse: collapse !important;
  1196 + font-size: 11px !important;
  1197 + table-layout: fixed !important;
  1198 + background: white;
  1199 +}}
  1200 +
  1201 +/* 表格标题 */
  1202 +.swot-pdf-caption {{
  1203 + caption-side: top !important;
  1204 + text-align: left !important;
  1205 + font-size: 16px !important;
  1206 + font-weight: 700 !important;
  1207 + padding: 12px 0 !important;
  1208 + color: #1a1a1a !important;
  1209 + border-bottom: 2px solid #333 !important;
  1210 + margin-bottom: 8px !important;
  1211 +}}
  1212 +
  1213 +/* 表头样式 */
  1214 +.swot-pdf-thead {{
  1215 + break-after: avoid !important;
  1216 + page-break-after: avoid !important;
  1217 +}}
  1218 +
  1219 +.swot-pdf-thead th {{
  1220 + background: #f0f0f0 !important;
  1221 + padding: 10px 8px !important;
  1222 + text-align: left !important;
  1223 + font-weight: 600 !important;
  1224 + border: 1px solid #ccc !important;
  1225 + color: #333 !important;
  1226 + font-size: 11px !important;
  1227 +}}
  1228 +
  1229 +.swot-pdf-th-quadrant {{ width: 70px !important; }}
  1230 +.swot-pdf-th-num {{ width: 40px !important; text-align: center !important; }}
  1231 +.swot-pdf-th-title {{ width: 20% !important; }}
  1232 +.swot-pdf-th-detail {{ width: auto !important; }}
  1233 +.swot-pdf-th-tags {{ width: 80px !important; text-align: center !important; }}
  1234 +
  1235 +/* 摘要行 */
  1236 +.swot-pdf-summary {{
  1237 + padding: 10px 12px !important;
  1238 + background: #f8f8f8 !important;
  1239 + color: #555 !important;
  1240 + font-style: italic !important;
  1241 + border: 1px solid #ccc !important;
  1242 + font-size: 11px !important;
  1243 +}}
  1244 +
  1245 +/* 每个象限区块 - 核心分页控制 */
  1246 +.swot-pdf-quadrant {{
  1247 + break-inside: avoid !important;
  1248 + page-break-inside: avoid !important;
  1249 +}}
  1250 +
  1251 +/* 允许在不同象限之间分页 */
  1252 +.swot-pdf-quadrant + .swot-pdf-quadrant {{
  1253 + break-before: auto;
  1254 + page-break-before: auto;
  1255 +}}
  1256 +
  1257 +/* 象限标签单元格 */
  1258 +.swot-pdf-quadrant-label {{
  1259 + text-align: center !important;
  1260 + vertical-align: middle !important;
  1261 + padding: 12px 6px !important;
  1262 + font-weight: 700 !important;
  1263 + border: 1px solid #ccc !important;
  1264 + width: 70px !important;
  1265 +}}
  1266 +
  1267 +/* 四个象限的颜色主题 */
  1268 +.swot-pdf-quadrant-label.swot-pdf-strength {{
  1269 + background: #e8f5f2 !important;
  1270 + color: #1c7f6e !important;
  1271 + border-left: 4px solid #1c7f6e !important;
  1272 +}}
  1273 +.swot-pdf-quadrant-label.swot-pdf-weakness {{
  1274 + background: #fdeaea !important;
  1275 + color: #c0392b !important;
  1276 + border-left: 4px solid #c0392b !important;
  1277 +}}
  1278 +.swot-pdf-quadrant-label.swot-pdf-opportunity {{
  1279 + background: #e8f0fa !important;
  1280 + color: #1f5ab3 !important;
  1281 + border-left: 4px solid #1f5ab3 !important;
  1282 +}}
  1283 +.swot-pdf-quadrant-label.swot-pdf-threat {{
  1284 + background: #fdf3e6 !important;
  1285 + color: #b36b16 !important;
  1286 + border-left: 4px solid #b36b16 !important;
  1287 +}}
  1288 +
  1289 +/* 象限代码字母 */
  1290 +.swot-pdf-code {{
  1291 + display: block !important;
  1292 + font-size: 20px !important;
  1293 + font-weight: 800 !important;
  1294 + margin-bottom: 2px !important;
  1295 +}}
  1296 +
  1297 +/* 象限标签文字 */
  1298 +.swot-pdf-label-text {{
  1299 + display: block !important;
  1300 + font-size: 9px !important;
  1301 + font-weight: 600 !important;
  1302 + letter-spacing: 0.02em !important;
  1303 +}}
  1304 +
  1305 +/* 数据行 */
  1306 +.swot-pdf-item-row td {{
  1307 + padding: 8px 6px !important;
  1308 + border: 1px solid #ddd !important;
  1309 + vertical-align: top !important;
  1310 + font-size: 11px !important;
  1311 + line-height: 1.4 !important;
  1312 +}}
  1313 +
  1314 +/* 行背景色 */
  1315 +.swot-pdf-item-row.swot-pdf-strength td {{ background: #f7fbfa !important; }}
  1316 +.swot-pdf-item-row.swot-pdf-weakness td {{ background: #fef9f9 !important; }}
  1317 +.swot-pdf-item-row.swot-pdf-opportunity td {{ background: #f7f9fc !important; }}
  1318 +.swot-pdf-item-row.swot-pdf-threat td {{ background: #fdfbf7 !important; }}
  1319 +
  1320 +/* 序号单元格 */
  1321 +.swot-pdf-item-num {{
  1322 + text-align: center !important;
  1323 + font-weight: 600 !important;
  1324 + color: #888 !important;
  1325 + width: 40px !important;
  1326 +}}
  1327 +
  1328 +/* 要点标题 */
  1329 +.swot-pdf-item-title {{
  1330 + font-weight: 600 !important;
  1331 + color: #222 !important;
  1332 +}}
  1333 +
  1334 +/* 详情说明 */
  1335 +.swot-pdf-item-detail {{
  1336 + color: #444 !important;
  1337 + line-height: 1.5 !important;
  1338 +}}
  1339 +
  1340 +/* 标签单元格 */
  1341 +.swot-pdf-item-tags {{
  1342 + text-align: center !important;
  1343 +}}
  1344 +
  1345 +/* 标签样式 */
  1346 +.swot-pdf-tag {{
  1347 + display: inline-block !important;
  1348 + padding: 2px 6px !important;
  1349 + border-radius: 3px !important;
  1350 + font-size: 9px !important;
  1351 + background: #e9ecef !important;
  1352 + color: #495057 !important;
  1353 + margin: 1px !important;
  1354 +}}
  1355 +
  1356 +.swot-pdf-tag--score {{
  1357 + background: #fff3cd !important;
  1358 + color: #856404 !important;
  1359 +}}
  1360 +
  1361 +/* 空数据提示 */
  1362 +.swot-pdf-empty {{
  1363 + text-align: center !important;
  1364 + color: #999 !important;
  1365 + font-style: italic !important;
  1366 +}}
  1367 +
  1368 +/* ========== PEST PDF表格布局 ========== */
  1369 +/* 核心策略:PDF中使用表格形式而非卡片形式,更适合分页 */
  1370 +
  1371 +/* 隐藏HTML卡片布局,显示PDF表格布局 */
  1372 +.pest-card--html {{
  1373 + display: none !important;
  1374 +}}
  1375 +
  1376 +.pest-pdf-wrapper {{
  1377 + display: block !important;
  1378 + margin: 24px 0;
  1379 +}}
  1380 +
  1381 +/* PDF表格整体样式 */
  1382 +.pest-pdf-table {{
  1383 + width: 100% !important;
  1384 + border-collapse: collapse !important;
  1385 + font-size: 11px !important;
  1386 + table-layout: fixed !important;
  1387 + background: white;
  1388 +}}
  1389 +
  1390 +/* 表格标题 */
  1391 +.pest-pdf-caption {{
  1392 + caption-side: top !important;
  1393 + text-align: left !important;
  1394 + font-size: 16px !important;
  1395 + font-weight: 700 !important;
  1396 + padding: 12px 0 !important;
  1397 + color: #333 !important;
  1398 + border-bottom: 2px solid #333 !important;
  1399 + margin-bottom: 8px !important;
  1400 +}}
  1401 +
  1402 +/* 表头样式 */
  1403 +.pest-pdf-thead {{
  1404 + break-after: avoid !important;
  1405 + page-break-after: avoid !important;
  1406 +}}
  1407 +
  1408 +.pest-pdf-thead th {{
  1409 + background: #f5f3f7 !important;
  1410 + padding: 10px 8px !important;
  1411 + text-align: left !important;
  1412 + font-weight: 600 !important;
  1413 + border: 1px solid #ccc !important;
  1414 + color: #4a4458 !important;
  1415 + font-size: 11px !important;
  1416 +}}
  1417 +
  1418 +.pest-pdf-th-dimension {{ width: 70px !important; }}
  1419 +.pest-pdf-th-num {{ width: 40px !important; text-align: center !important; }}
  1420 +.pest-pdf-th-title {{ width: 20% !important; }}
  1421 +.pest-pdf-th-detail {{ width: auto !important; }}
  1422 +.pest-pdf-th-tags {{ width: 80px !important; text-align: center !important; }}
  1423 +
  1424 +/* 摘要行 */
  1425 +.pest-pdf-summary {{
  1426 + padding: 10px 12px !important;
  1427 + background: #f8f6fa !important;
  1428 + color: #555 !important;
  1429 + font-style: italic !important;
  1430 + border: 1px solid #ccc !important;
  1431 + font-size: 11px !important;
  1432 +}}
  1433 +
  1434 +/* 每个维度区块 - 核心分页控制 */
  1435 +.pest-pdf-dimension {{
  1436 + break-inside: avoid !important;
  1437 + page-break-inside: avoid !important;
  1438 +}}
  1439 +
  1440 +/* 允许在不同维度之间分页 */
  1441 +.pest-pdf-dimension + .pest-pdf-dimension {{
  1442 + break-before: auto;
  1443 + page-break-before: auto;
  1444 +}}
  1445 +
  1446 +/* 维度标签单元格 */
  1447 +.pest-pdf-dimension-label {{
  1448 + text-align: center !important;
  1449 + vertical-align: middle !important;
  1450 + padding: 12px 6px !important;
  1451 + font-weight: 700 !important;
  1452 + border: 1px solid #ccc !important;
  1453 + width: 70px !important;
  1454 +}}
  1455 +
  1456 +/* 四个维度的颜色主题 */
  1457 +.pest-pdf-dimension-label.pest-pdf-political {{
  1458 + background: #f5eef8 !important;
  1459 + color: #8e44ad !important;
  1460 + border-left: 4px solid #8e44ad !important;
  1461 +}}
  1462 +.pest-pdf-dimension-label.pest-pdf-economic {{
  1463 + background: #e8f6f3 !important;
  1464 + color: #16a085 !important;
  1465 + border-left: 4px solid #16a085 !important;
  1466 +}}
  1467 +.pest-pdf-dimension-label.pest-pdf-social {{
  1468 + background: #fdecf4 !important;
  1469 + color: #e84393 !important;
  1470 + border-left: 4px solid #e84393 !important;
  1471 +}}
  1472 +.pest-pdf-dimension-label.pest-pdf-technological {{
  1473 + background: #ebf3f9 !important;
  1474 + color: #2980b9 !important;
  1475 + border-left: 4px solid #2980b9 !important;
  1476 +}}
  1477 +
  1478 +/* 维度代码字母 */
  1479 +.pest-pdf-code {{
  1480 + display: block !important;
  1481 + font-size: 20px !important;
  1482 + font-weight: 800 !important;
  1483 + margin-bottom: 2px !important;
  1484 +}}
  1485 +
  1486 +/* 维度标签文字 */
  1487 +.pest-pdf-label-text {{
  1488 + display: block !important;
  1489 + font-size: 9px !important;
  1490 + font-weight: 600 !important;
  1491 + letter-spacing: 0.02em !important;
  1492 +}}
  1493 +
  1494 +/* 数据行 */
  1495 +.pest-pdf-item-row td {{
  1496 + padding: 8px 6px !important;
  1497 + border: 1px solid #ddd !important;
  1498 + vertical-align: top !important;
  1499 + font-size: 11px !important;
  1500 + line-height: 1.4 !important;
  1501 +}}
  1502 +
  1503 +/* 行背景色 */
  1504 +.pest-pdf-item-row.pest-pdf-political td {{ background: #faf7fc !important; }}
  1505 +.pest-pdf-item-row.pest-pdf-economic td {{ background: #f5fbfa !important; }}
  1506 +.pest-pdf-item-row.pest-pdf-social td {{ background: #fef8fb !important; }}
  1507 +.pest-pdf-item-row.pest-pdf-technological td {{ background: #f7fafd !important; }}
  1508 +
  1509 +/* 序号单元格 */
  1510 +.pest-pdf-item-num {{
  1511 + text-align: center !important;
  1512 + font-weight: 600 !important;
  1513 + color: #888 !important;
  1514 + width: 40px !important;
  1515 +}}
  1516 +
  1517 +/* 要点标题 */
  1518 +.pest-pdf-item-title {{
  1519 + font-weight: 600 !important;
  1520 + color: #222 !important;
  1521 +}}
  1522 +
  1523 +/* 详情说明 */
  1524 +.pest-pdf-item-detail {{
  1525 + color: #444 !important;
  1526 + line-height: 1.5 !important;
  1527 +}}
  1528 +
  1529 +/* 标签单元格 */
  1530 +.pest-pdf-item-tags {{
  1531 + text-align: center !important;
  1532 +}}
  1533 +
  1534 +/* 标签样式 */
  1535 +.pest-pdf-tag {{
  1536 + display: inline-block !important;
  1537 + padding: 2px 6px !important;
  1538 + border-radius: 3px !important;
  1539 + font-size: 9px !important;
  1540 + background: #ece9f1 !important;
  1541 + color: #5a4f6a !important;
  1542 + margin: 1px !important;
  1543 +}}
  1544 +
  1545 +/* 空数据提示 */
  1546 +.pest-pdf-empty {{
  1547 + text-align: center !important;
  1548 + color: #999 !important;
  1549 + font-style: italic !important;
  1550 +}}
  1551 +
1052 {optimized_css} 1552 {optimized_css}
1053 </style> 1553 </style>
1054 """ 1554 """
  1 +#!/usr/bin/env python3
  2 +"""
  3 +生成覆盖全部允许block类型的演示 IR,用于验证 HTML 与 PDF 渲染。
  4 +
  5 +执行后会在 `final_reports/ir` 写入一份带时间戳的 IR,
  6 +并分别在 `final_reports/html` 与 `final_reports/pdf` 输出对应的渲染文件。
  7 +"""
  8 +
  9 +from __future__ import annotations
  10 +
  11 +import json
  12 +import sys
  13 +from datetime import datetime
  14 +from pathlib import Path
  15 +
  16 +# 允许直接以脚本形式运行
  17 +ROOT = Path(__file__).resolve().parents[2]
  18 +if str(ROOT) not in sys.path:
  19 + sys.path.insert(0, str(ROOT))
  20 +
  21 +from ReportEngine.core import DocumentComposer
  22 +from ReportEngine.ir import IRValidator
  23 +from ReportEngine.ir.schema import ENGINE_AGENT_TITLES
  24 +from ReportEngine.renderers import HTMLRenderer, PDFRenderer
  25 +from ReportEngine.utils.config import settings
  26 +
  27 +
  28 +def build_inline_marks_demo() -> dict:
  29 + """生成覆盖全部内联标记的 paragraph block。"""
  30 + return {
  31 + "type": "paragraph",
  32 + "inlines": [
  33 + {"text": "这一段覆盖全部内联标记:"},
  34 + {"text": "粗体", "marks": [{"type": "bold"}]},
  35 + {"text": " / 斜体", "marks": [{"type": "italic"}]},
  36 + {"text": " / 下划线", "marks": [{"type": "underline"}]},
  37 + {"text": " / 删除线", "marks": [{"type": "strike"}]},
  38 + {"text": " / 代码", "marks": [{"type": "code"}]},
  39 + {
  40 + "text": " / 链接",
  41 + "marks": [
  42 + {
  43 + "type": "link",
  44 + "href": "https://example.com/demo",
  45 + "title": "示例链接",
  46 + }
  47 + ],
  48 + },
  49 + {"text": " / 颜色", "marks": [{"type": "color", "value": "#c0392b"}]},
  50 + {
  51 + "text": " / 字体",
  52 + "marks": [
  53 + {
  54 + "type": "font",
  55 + "family": "Georgia, serif",
  56 + "size": "15px",
  57 + "weight": "600",
  58 + }
  59 + ],
  60 + },
  61 + {"text": " / 高亮", "marks": [{"type": "highlight"}]},
  62 + {"text": " / 下标", "marks": [{"type": "subscript"}]},
  63 + {"text": " / 上标", "marks": [{"type": "superscript"}]},
  64 + {"text": " / 行内公式", "marks": [{"type": "math", "value": "E=mc^2"}]},
  65 + {"text": "。"},
  66 + ],
  67 + }
  68 +
  69 +
  70 +def build_widget_block() -> dict:
  71 + """构造一个合法的 Chart.js widget block。"""
  72 + return {
  73 + "type": "widget",
  74 + "widgetId": "demo-volume-trend",
  75 + "widgetType": "chart.js/line",
  76 + "props": {
  77 + "type": "line",
  78 + "options": {
  79 + "responsive": True,
  80 + "plugins": {"legend": {"position": "bottom"}},
  81 + "scales": {"y": {"title": {"display": True, "text": "提及量"}}},
  82 + },
  83 + },
  84 + "data": {
  85 + "labels": ["T0", "T0+6h", "T0+12h", "T0+18h", "T0+24h"],
  86 + "datasets": [
  87 + {
  88 + "label": "主流媒体",
  89 + "data": [12, 18, 23, 30, 26],
  90 + "borderColor": "#2980b9",
  91 + "backgroundColor": "rgba(41,128,185,0.18)",
  92 + "tension": 0.25,
  93 + "fill": False,
  94 + },
  95 + {
  96 + "label": "社交平台",
  97 + "data": [8, 10, 15, 28, 40],
  98 + "borderColor": "#c0392b",
  99 + "backgroundColor": "rgba(192,57,43,0.2)",
  100 + "tension": 0.35,
  101 + "fill": False,
  102 + },
  103 + ],
  104 + },
  105 + }
  106 +
  107 +
  108 +def build_chapters() -> list[dict]:
  109 + """构造覆盖所有 block 类型的章节列表。"""
  110 + inline_demo = build_inline_marks_demo()
  111 +
  112 + bullet_list = {
  113 + "type": "list",
  114 + "listType": "bullet",
  115 + "items": [
  116 + [
  117 + {
  118 + "type": "paragraph",
  119 + "inlines": [{"text": "社交媒体热度在 48 小时内翻倍"}],
  120 + }
  121 + ],
  122 + [
  123 + {
  124 + "type": "paragraph",
  125 + "inlines": [{"text": "主流媒体报道集中在早间时段"}],
  126 + },
  127 + {
  128 + "type": "list",
  129 + "listType": "ordered",
  130 + "items": [
  131 + [
  132 + {
  133 + "type": "paragraph",
  134 + "inlines": [{"text": "07:00-09:00:首轮报道"}],
  135 + }
  136 + ],
  137 + [
  138 + {
  139 + "type": "paragraph",
  140 + "inlines": [{"text": "10:00-12:00:评论扩散"}],
  141 + }
  142 + ],
  143 + ],
  144 + },
  145 + ],
  146 + [
  147 + {
  148 + "type": "paragraph",
  149 + "inlines": [{"text": "地方政务号开始回应并同步线下通稿"}],
  150 + }
  151 + ],
  152 + ],
  153 + }
  154 +
  155 + task_list = {
  156 + "type": "list",
  157 + "listType": "task",
  158 + "items": [
  159 + [
  160 + {
  161 + "type": "paragraph",
  162 + "inlines": [{"text": "跟踪权威辟谣素材是否上线"}],
  163 + }
  164 + ],
  165 + [
  166 + {
  167 + "type": "paragraph",
  168 + "inlines": [{"text": "监测新增关联关键词与长尾问题"}],
  169 + }
  170 + ],
  171 + [
  172 + {
  173 + "type": "paragraph",
  174 + "inlines": [{"text": "准备 FAQ 供客服统一答复"}],
  175 + }
  176 + ],
  177 + ],
  178 + }
  179 +
  180 + table_block = {
  181 + "type": "table",
  182 + "caption": "核心信源与传播路径",
  183 + "zebra": True,
  184 + "colgroup": [{"width": "22%"}, {"width": "38%"}, {"width": "40%"}],
  185 + "rows": [
  186 + {
  187 + "cells": [
  188 + {
  189 + "align": "center",
  190 + "blocks": [
  191 + {
  192 + "type": "paragraph",
  193 + "inlines": [{"text": "时间节点", "marks": [{"type": "bold"}]}],
  194 + }
  195 + ],
  196 + },
  197 + {
  198 + "align": "center",
  199 + "blocks": [
  200 + {
  201 + "type": "paragraph",
  202 + "inlines": [{"text": "事件内容", "marks": [{"type": "bold"}]}],
  203 + }
  204 + ],
  205 + },
  206 + {
  207 + "align": "center",
  208 + "blocks": [
  209 + {
  210 + "type": "paragraph",
  211 + "inlines": [{"text": "主要渠道", "marks": [{"type": "bold"}]}],
  212 + }
  213 + ],
  214 + },
  215 + ]
  216 + },
  217 + {
  218 + "cells": [
  219 + {"blocks": [{"type": "paragraph", "inlines": [{"text": "T0"}]}]},
  220 + {
  221 + "blocks": [
  222 + {
  223 + "type": "paragraph",
  224 + "inlines": [{"text": "线下冲突视频首次上传"}],
  225 + }
  226 + ]
  227 + },
  228 + {
  229 + "blocks": [
  230 + {
  231 + "type": "paragraph",
  232 + "inlines": [{"text": "短视频平台 / 私聊转发"}],
  233 + }
  234 + ]
  235 + },
  236 + ]
  237 + },
  238 + {
  239 + "cells": [
  240 + {"blocks": [{"type": "paragraph", "inlines": [{"text": "T0+6h"}]}]},
  241 + {
  242 + "blocks": [
  243 + {
  244 + "type": "paragraph",
  245 + "inlines": [{"text": "登上热搜,出现二次剪辑"}],
  246 + }
  247 + ]
  248 + },
  249 + {
  250 + "blocks": [
  251 + {
  252 + "type": "paragraph",
  253 + "inlines": [{"text": "微博 / 朋友圈"}],
  254 + }
  255 + ]
  256 + },
  257 + ]
  258 + },
  259 + {
  260 + "cells": [
  261 + {"blocks": [{"type": "paragraph", "inlines": [{"text": "T0+18h"}]}]},
  262 + {
  263 + "blocks": [
  264 + {
  265 + "type": "paragraph",
  266 + "inlines": [{"text": "官方回应并发布事实澄清"}],
  267 + }
  268 + ]
  269 + },
  270 + {
  271 + "blocks": [
  272 + {
  273 + "type": "paragraph",
  274 + "inlines": [{"text": "政务号 / 新闻客户端"}],
  275 + }
  276 + ]
  277 + },
  278 + ]
  279 + },
  280 + {
  281 + "cells": [
  282 + {"blocks": [{"type": "paragraph", "inlines": [{"text": "T0+24h"}]}]},
  283 + {
  284 + "blocks": [
  285 + {
  286 + "type": "paragraph",
  287 + "inlines": [{"text": "专家解读,舆论重心转向责任归属"}],
  288 + }
  289 + ]
  290 + },
  291 + {
  292 + "blocks": [
  293 + {
  294 + "type": "paragraph",
  295 + "inlines": [{"text": "视频号直播 / 行业社群"}],
  296 + }
  297 + ]
  298 + },
  299 + ]
  300 + },
  301 + ],
  302 + }
  303 +
  304 + blockquote_block = {
  305 + "type": "blockquote",
  306 + "variant": "accent",
  307 + "blocks": [
  308 + {
  309 + "type": "paragraph",
  310 + "inlines": [{"text": "“公众最关心的信息是真相与责任边界。”"}],
  311 + },
  312 + {
  313 + "type": "paragraph",
  314 + "inlines": [{"text": "—— 模拟引用,验证引用块样式"}],
  315 + },
  316 + ],
  317 + }
  318 +
  319 + engine_quote_block = {
  320 + "type": "engineQuote",
  321 + "engine": "insight",
  322 + "title": ENGINE_AGENT_TITLES["insight"],
  323 + "blocks": [
  324 + {
  325 + "type": "paragraph",
  326 + "inlines": [
  327 + {
  328 + "text": "模型认为 24 小时内保持回应频次,可避免信息真空。",
  329 + "marks": [{"type": "bold"}],
  330 + }
  331 + ],
  332 + },
  333 + {
  334 + "type": "paragraph",
  335 + "inlines": [
  336 + {"text": "建议同时准备简短 FAQ,便于多渠道统一口径。"}
  337 + ],
  338 + },
  339 + ],
  340 + }
  341 +
  342 + swot_block = {
  343 + "type": "swotTable",
  344 + "title": "舆论场 SWOT 速览",
  345 + "summary": "覆盖当前情绪分布、潜在风险与机会。",
  346 + "strengths": [
  347 + {"title": "官方快速响应", "detail": "首条澄清视频 3 小时内上线"},
  348 + {"title": "同城媒体配合", "impact": "高", "score": 8},
  349 + ],
  350 + "weaknesses": [
  351 + {"title": "早期谣言存量大", "detail": "相关转发仍占 30%"},
  352 + "外部专家尚未统一口径",
  353 + ],
  354 + "opportunities": [
  355 + {
  356 + "title": "社区共建讨论",
  357 + "detail": "自发组织“辟谣志愿者”话题,情绪正向",
  358 + },
  359 + {"title": "公益合作窗口", "impact": "中"},
  360 + ],
  361 + "threats": [
  362 + {"title": "跨平台剪辑继续发酵", "impact": "高", "score": 9},
  363 + {"title": "个别自媒体煽动情绪", "evidence": "存在地域标签化倾向"},
  364 + ],
  365 + }
  366 +
  367 + pest_block = {
  368 + "type": "pestTable",
  369 + "title": "宏观环境脉冲扫描(PEST)",
  370 + "summary": "模拟四大维度的外部约束与机会,验证 pestTable 的渲染样式。",
  371 + "political": [
  372 + {
  373 + "title": "地方条例征求意见",
  374 + "detail": "短视频发布需实名溯源,平台合规沟通窗口期开启",
  375 + "trend": "正面利好",
  376 + "impact": 7,
  377 + },
  378 + {
  379 + "title": "监管关注情绪煽动",
  380 + "detail": "对夸大矛盾的账号重点巡查,舆论阈值下调",
  381 + "trend": "持续观察",
  382 + "impact": 6,
  383 + },
  384 + ],
  385 + "economic": [
  386 + {
  387 + "title": "周边商户营收波动",
  388 + "detail": "客流短期下滑 12%,但直播带货订单上升",
  389 + "trend": "中性",
  390 + "impact": 5,
  391 + },
  392 + {
  393 + "title": "品牌赞助谨慎",
  394 + "detail": "赞助延期观察声誉风险,对官宣节奏有压力",
  395 + "trend": "不确定",
  396 + "impact": 4,
  397 + },
  398 + ],
  399 + "social": [
  400 + {
  401 + "title": "核心群体情绪分化",
  402 + "detail": "本地居民关注安全,外地游客关注体验与退款",
  403 + "trend": "负面影响",
  404 + "impact": 8,
  405 + },
  406 + {
  407 + "title": "高校社群自发求证",
  408 + "detail": "校媒与学生会组织“以图搜图”科普贴,情绪趋稳",
  409 + "trend": "正面利好",
  410 + "impact": 6,
  411 + },
  412 + ],
  413 + "technological": [
  414 + {
  415 + "title": "AI 生成内容被混入",
  416 + "detail": "局部画面被放大后再传播,需水印溯源工具辅助鉴伪",
  417 + "trend": "负面影响",
  418 + "impact": 7,
  419 + },
  420 + {
  421 + "title": "多模态检索上线",
  422 + "detail": "平台试行“视频反诈”模型,自动提示剪辑痕迹",
  423 + "trend": "正面利好",
  424 + "impact": 5,
  425 + },
  426 + ],
  427 + }
  428 +
  429 + callout_block = {
  430 + "type": "callout",
  431 + "tone": "warning",
  432 + "title": "排版边界提示",
  433 + "blocks": [
  434 + {
  435 + "type": "paragraph",
  436 + "inlines": [
  437 + {"text": "callout 内部仅放轻量内容,超出部分会自动溢出到外层。"}
  438 + ],
  439 + },
  440 + {
  441 + "type": "list",
  442 + "listType": "bullet",
  443 + "items": [
  444 + [
  445 + {
  446 + "type": "paragraph",
  447 + "inlines": [{"text": "支持嵌套列表 / 表格 / 数学公式"}],
  448 + }
  449 + ],
  450 + [
  451 + {
  452 + "type": "paragraph",
  453 + "inlines": [{"text": "可在这里放置提醒或操作步骤"}],
  454 + }
  455 + ],
  456 + ],
  457 + },
  458 + ],
  459 + }
  460 +
  461 + code_block = {
  462 + "type": "code",
  463 + "lang": "json",
  464 + "caption": "演示代码块",
  465 + "content": '{\n "event": "热点示例",\n "topic": "公共事件",\n "status": "monitoring"\n}',
  466 + }
  467 +
  468 + math_block = {
  469 + "type": "math",
  470 + "latex": r"E = mc^2",
  471 + "displayMode": True,
  472 + }
  473 +
  474 + figure_block = {
  475 + "type": "figure",
  476 + "img": {
  477 + "src": "https://dummyimage.com/600x320/eeeeee/333333&text=Placeholder",
  478 + "alt": "占位示意图",
  479 + "width": 600,
  480 + "height": 320,
  481 + },
  482 + "caption": "图像外链被替换为友好提示,可验证 figure 占位效果。",
  483 + "responsive": True,
  484 + }
  485 +
  486 + widget_block = build_widget_block()
  487 + stacked_bar_chart_block = {
  488 + "type": "widget",
  489 + "widgetId": "demo-stacked-sentiment",
  490 + "widgetType": "chart.js/bar",
  491 + "props": {
  492 + "type": "bar",
  493 + "options": {
  494 + "responsive": True,
  495 + "plugins": {"legend": {"position": "bottom"}},
  496 + "scales": {
  497 + "x": {"stacked": True},
  498 + "y": {"stacked": True, "title": {"display": True, "text": "信息量"}},
  499 + },
  500 + },
  501 + },
  502 + "data": {
  503 + "labels": ["周一", "周二", "周三", "周四", "周五"],
  504 + "datasets": [
  505 + {"label": "正向", "data": [18, 22, 24, 19, 16], "backgroundColor": "#27ae60"},
  506 + {"label": "中性", "data": [22, 20, 18, 21, 23], "backgroundColor": "#f39c12"},
  507 + {"label": "负向", "data": [12, 14, 10, 9, 11], "backgroundColor": "#c0392b"},
  508 + ],
  509 + },
  510 + }
  511 + doughnut_chart_block = {
  512 + "type": "widget",
  513 + "widgetId": "demo-sentiment-share",
  514 + "widgetType": "chart.js/doughnut",
  515 + "props": {
  516 + "type": "doughnut",
  517 + "options": {"plugins": {"legend": {"position": "right"}, "tooltip": {"enabled": True}}},
  518 + },
  519 + "data": {
  520 + "labels": ["政策", "经济", "社会", "技术"],
  521 + "datasets": [
  522 + {
  523 + "label": "关注度占比",
  524 + "data": [24, 30, 28, 18],
  525 + "backgroundColor": ["#8e44ad", "#16a085", "#e67e22", "#2980b9"],
  526 + "hoverOffset": 6,
  527 + }
  528 + ],
  529 + },
  530 + }
  531 + radar_chart_block = {
  532 + "type": "widget",
  533 + "widgetId": "demo-response-radar",
  534 + "widgetType": "chart.js/radar",
  535 + "props": {
  536 + "type": "radar",
  537 + "options": {
  538 + "plugins": {"legend": {"position": "top"}},
  539 + "scales": {"r": {"beginAtZero": True, "max": 100}},
  540 + },
  541 + },
  542 + "data": {
  543 + "labels": ["透明度", "响应速度", "一致性", "互动度", "信息量"],
  544 + "datasets": [
  545 + {
  546 + "label": "官方渠道",
  547 + "data": [78, 88, 82, 66, 91],
  548 + "backgroundColor": "rgba(46,204,113,0.15)",
  549 + "borderColor": "#2ecc71",
  550 + "pointBackgroundColor": "#27ae60",
  551 + },
  552 + {
  553 + "label": "民间讨论",
  554 + "data": [64, 72, 58, 74, 63],
  555 + "backgroundColor": "rgba(52,152,219,0.12)",
  556 + "borderColor": "#3498db",
  557 + "pointBackgroundColor": "#2980b9",
  558 + },
  559 + ],
  560 + },
  561 + }
  562 + polar_area_chart_block = {
  563 + "type": "widget",
  564 + "widgetId": "demo-channel-polar",
  565 + "widgetType": "chart.js/polarArea",
  566 + "props": {"type": "polarArea"},
  567 + "data": {
  568 + "labels": ["短视频", "微博", "社区论坛", "新闻客户端", "线下反馈"],
  569 + "datasets": [
  570 + {
  571 + "label": "渠道渗透度",
  572 + "data": [62, 54, 38, 45, 28],
  573 + "backgroundColor": [
  574 + "rgba(231,76,60,0.65)",
  575 + "rgba(142,68,173,0.6)",
  576 + "rgba(52,152,219,0.55)",
  577 + "rgba(46,204,113,0.55)",
  578 + "rgba(241,196,15,0.6)",
  579 + ],
  580 + }
  581 + ],
  582 + },
  583 + }
  584 + scatter_chart_block = {
  585 + "type": "widget",
  586 + "widgetId": "demo-correlation-scatter",
  587 + "widgetType": "chart.js/scatter",
  588 + "props": {
  589 + "type": "scatter",
  590 + "options": {
  591 + "plugins": {"legend": {"position": "bottom"}},
  592 + "scales": {
  593 + "x": {"title": {"display": True, "text": "情绪极性"}, "min": -1, "max": 1},
  594 + "y": {"title": {"display": True, "text": "互动量"}, "beginAtZero": True},
  595 + },
  596 + },
  597 + },
  598 + "data": {
  599 + "datasets": [
  600 + {
  601 + "label": "帖子散点",
  602 + "data": [
  603 + {"x": -0.65, "y": 120},
  604 + {"x": -0.25, "y": 190},
  605 + {"x": 0.05, "y": 260},
  606 + {"x": 0.42, "y": 340},
  607 + {"x": 0.78, "y": 410},
  608 + ],
  609 + "backgroundColor": "rgba(52,152,219,0.7)",
  610 + }
  611 + ],
  612 + },
  613 + }
  614 + bubble_chart_block = {
  615 + "type": "widget",
  616 + "widgetId": "demo-impact-bubble",
  617 + "widgetType": "chart.js/bubble",
  618 + "props": {
  619 + "type": "bubble",
  620 + "options": {
  621 + "plugins": {"legend": {"position": "bottom"}},
  622 + "scales": {
  623 + "x": {"title": {"display": True, "text": "曝光量 (万)"}, "beginAtZero": True},
  624 + "y": {"title": {"display": True, "text": "情绪强度"}, "min": -100, "max": 100},
  625 + },
  626 + },
  627 + },
  628 + "data": {
  629 + "datasets": [
  630 + {
  631 + "label": "渠道分布",
  632 + "data": [
  633 + {"x": 8, "y": 35, "r": 12},
  634 + {"x": 12, "y": -28, "r": 10},
  635 + {"x": 18, "y": 22, "r": 14},
  636 + {"x": 25, "y": 48, "r": 16},
  637 + {"x": 6, "y": -12, "r": 8},
  638 + ],
  639 + "backgroundColor": "rgba(192,57,43,0.55)",
  640 + "borderColor": "#c0392b",
  641 + }
  642 + ],
  643 + },
  644 + }
  645 +
  646 + chapter_1 = {
  647 + "chapterId": "S1",
  648 + "title": "封面与目录",
  649 + "anchor": "overview",
  650 + "order": 10,
  651 + "blocks": [
  652 + {"type": "heading", "level": 2, "text": "一、封面与目录", "anchor": "overview"},
  653 + {
  654 + "type": "paragraph",
  655 + "inlines": [
  656 + {
  657 + "text": "模拟社会公共热点事件的摘要,便于快速确认排版与字体效果。",
  658 + }
  659 + ],
  660 + },
  661 + inline_demo,
  662 + {
  663 + "type": "kpiGrid",
  664 + "items": [
  665 + {"label": "24h提及量", "value": "98K", "delta": "+41%", "deltaTone": "up"},
  666 + {"label": "正向占比", "value": "32%", "delta": "+5pp", "deltaTone": "up"},
  667 + {"label": "负向占比", "value": "18%", "delta": "-3pp", "deltaTone": "down"},
  668 + {"label": "高频渠道", "value": "短视频 / 微博"},
  669 + ],
  670 + "cols": 4,
  671 + },
  672 + {"type": "toc"},
  673 + {"type": "hr"},
  674 + ],
  675 + }
  676 +
  677 + chapter_2 = {
  678 + "chapterId": "S2",
  679 + "title": "块类型演示",
  680 + "anchor": "blocks-showcase",
  681 + "order": 20,
  682 + "blocks": [
  683 + {
  684 + "type": "heading",
  685 + "level": 2,
  686 + "text": "二、块类型演示",
  687 + "anchor": "blocks-showcase",
  688 + },
  689 + {
  690 + "type": "paragraph",
  691 + "inlines": [
  692 + {
  693 + "text": "以下内容逐一覆盖 paragraph/list/table/swot/pest/widget 等全部块类型。",
  694 + }
  695 + ],
  696 + },
  697 + {
  698 + "type": "heading",
  699 + "level": 3,
  700 + "text": "2.1 列表与表格",
  701 + "anchor": "lists-and-tables",
  702 + },
  703 + bullet_list,
  704 + task_list,
  705 + table_block,
  706 + {
  707 + "type": "heading",
  708 + "level": 3,
  709 + "text": "2.2 图表组件演示",
  710 + "anchor": "charts-demo",
  711 + },
  712 + {
  713 + "type": "paragraph",
  714 + "inlines": [
  715 + {
  716 + "text": "折线/柱状/饼图/雷达/极区/散点/气泡等多类型图表,用于验证 Chart.js 兼容性。",
  717 + }
  718 + ],
  719 + },
  720 + widget_block,
  721 + stacked_bar_chart_block,
  722 + doughnut_chart_block,
  723 + radar_chart_block,
  724 + polar_area_chart_block,
  725 + scatter_chart_block,
  726 + bubble_chart_block,
  727 + {
  728 + "type": "heading",
  729 + "level": 3,
  730 + "text": "2.3 高阶块与富媒体",
  731 + "anchor": "advanced-blocks",
  732 + },
  733 + blockquote_block,
  734 + callout_block,
  735 + engine_quote_block,
  736 + swot_block,
  737 + pest_block,
  738 + code_block,
  739 + math_block,
  740 + figure_block,
  741 + {
  742 + "type": "hr",
  743 + "variant": "dashed",
  744 + },
  745 + {
  746 + "type": "paragraph",
  747 + "align": "justify",
  748 + "inlines": [
  749 + {
  750 + "text": "本章节的 inline math 兜底验证:",
  751 + },
  752 + {"text": "p(t)=p_0 e^{\\lambda t}", "marks": [{"type": "math"}]},
  753 + {"text": ";以上覆盖所有允许块及标记。"},
  754 + ],
  755 + },
  756 + ],
  757 + }
  758 +
  759 + return [chapter_1, chapter_2]
  760 +
  761 +
  762 +def validate_chapters(chapters: list[dict]) -> None:
  763 + """使用 IRValidator 校验章节结构,发现错误时抛出异常。"""
  764 + validator = IRValidator()
  765 + for chapter in chapters:
  766 + ok, errors = validator.validate_chapter(chapter)
  767 + if not ok:
  768 + raise ValueError(f"{chapter.get('chapterId', 'unknown')} 校验失败: {errors}")
  769 +
  770 +
  771 +def render_and_save(document_ir: dict, timestamp: str) -> tuple[Path, Path, Path]:
  772 + """将 IR 保存为 JSON,并渲染 HTML / PDF,返回三个路径。"""
  773 + ir_dir = Path(settings.DOCUMENT_IR_OUTPUT_DIR)
  774 + html_dir = Path(settings.OUTPUT_DIR) / "html"
  775 + pdf_dir = Path(settings.OUTPUT_DIR) / "pdf"
  776 + ir_dir.mkdir(parents=True, exist_ok=True)
  777 + html_dir.mkdir(parents=True, exist_ok=True)
  778 + pdf_dir.mkdir(parents=True, exist_ok=True)
  779 +
  780 + ir_path = ir_dir / f"report_ir_all_blocks_demo_{timestamp}.json"
  781 + ir_path.write_text(json.dumps(document_ir, ensure_ascii=False, indent=2), encoding="utf-8")
  782 +
  783 + html_renderer = HTMLRenderer()
  784 + html_content = html_renderer.render(document_ir)
  785 + html_path = html_dir / f"report_html_all_blocks_demo_{timestamp}.html"
  786 + html_path.write_text(html_content, encoding="utf-8")
  787 +
  788 + pdf_renderer = PDFRenderer()
  789 + pdf_path = pdf_dir / f"report_pdf_all_blocks_demo_{timestamp}.pdf"
  790 + pdf_renderer.render_to_pdf(document_ir, pdf_path)
  791 +
  792 + return ir_path, html_path, pdf_path
  793 +
  794 +
  795 +def main() -> int:
  796 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  797 + report_id = f"all-blocks-demo-{timestamp}"
  798 + metadata = {
  799 + "title": "社会公共热点事件渲染测试",
  800 + "subtitle": "覆盖全部 IR 块类型的示例数据,含多种图表与 PEST 演示",
  801 + "query": "公共事件渲染能力自检 / Chart & PEST",
  802 + "toc": {"title": "目录", "depth": 3},
  803 + "hero": {
  804 + "summary": "用于验证 Report Engine 在 HTML / PDF 渲染时对各类区块、Chart.js 组件与 PEST 模块的兼容性。",
  805 + "kpis": [
  806 + {"label": "示例块数量", "value": "20+", "delta": "含 PEST", "tone": "up"},
  807 + {"label": "图表数", "value": "7", "delta": "新增多类型", "tone": "neutral"},
  808 + ],
  809 + "highlights": ["覆盖全部 block", "含行内/块级公式", "Chart.js 多类型", "PEST + SWOT"],
  810 + "actions": ["重新生成", "导出 PDF"],
  811 + },
  812 + }
  813 +
  814 + chapters = build_chapters()
  815 + validate_chapters(chapters)
  816 +
  817 + composer = DocumentComposer()
  818 + document_ir = composer.build_document(report_id, metadata, chapters)
  819 +
  820 + ir_path, html_path, pdf_path = render_and_save(document_ir, timestamp)
  821 +
  822 + print("✅ 演示 IR 生成完成")
  823 + print(f"IR: {ir_path}")
  824 + print(f"HTML: {html_path}")
  825 + print(f"PDF: {pdf_path}")
  826 + return 0
  827 +
  828 +
  829 +if __name__ == "__main__":
  830 + raise SystemExit(main())
  1 +ISC License
  2 +
  3 +Copyright(c)2024,Xiumuzaidiao
  4 +
  5 +Permission to use, copy, modify, and/or distribute this software for any
  6 +purpose with or without fee is hereby granted, provided that the above
  7 +copyright notice and this permission notice appear in all copies.
  8 +
  9 +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
  10 +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
  11 +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
  12 +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
  13 +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
  14 +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
  15 +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  16 +
  17 +
  1 +<!DOCTYPE html>
  2 +<html>
  3 + <head>
  4 + <meta charset="utf-8" />
  5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6 + <title>白天黑夜切换按钮</title>
  7 + <style>
  8 + body{
  9 + transition: 0.5s;
  10 + }
  11 + </style>
  12 + </head>
  13 + <body>
  14 + <theme-button value="dark" id="btn" size="3"></theme-button>
  15 + <script>
  16 + document.body.style.backgroundColor = "#424242";
  17 + btn.addEventListener("change", e => {
  18 + if (e.detail === 'dark') {
  19 + document.body.style.backgroundColor = "#424242";
  20 + }
  21 + else {
  22 + document.body.style.backgroundColor = "aliceblue";
  23 + }
  24 + });
  25 + </script>
  26 + <script src="js/script.js"></script>
  27 + </body>
  28 +</html>
  1 +(() => {
  2 + const func = (root, initTheme, changeTheme) => {
  3 + const $ = (s) => {
  4 + let dom = root.querySelectorAll(s);
  5 + return dom.length == 1 ? dom[0] : dom;
  6 + };
  7 + let mainButton = $(".main-button");
  8 + let daytimeBackground = $(".daytime-background");
  9 + let cloud = $(".cloud");
  10 + let cloudList = $(".cloud-son");
  11 + let cloudLight = $(".cloud-light");
  12 + let components = $(".components");
  13 + let moon = $(".moon");
  14 + let stars = $(".stars");
  15 + let star = $(".star");
  16 + let isMoved = false;
  17 + let isClicked = false;
  18 + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
  19 + toggleThemeBasedOnSystem();
  20 + });
  21 + const toggleThemeBasedOnSystem = () => {
  22 + if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
  23 + if (!isMoved) {
  24 + components.onclick();
  25 + }
  26 + } else {
  27 + if (isMoved) {
  28 + components.onclick();
  29 + }
  30 + }
  31 + };
  32 + components.onclick = () => {
  33 + if (isMoved) {
  34 + mainButton.style.transform = "translateX(0)";
  35 + mainButton.style.backgroundColor = "rgba(255, 195, 35,1)";
  36 +
  37 + mainButton.style.boxShadow =
  38 + "3em 3em 5em rgba(0, 0, 0, 0.5), inset -3em -5em 3em -3em rgba(0, 0, 0, 0.5), inset 4em 5em 2em -2em rgba(255, 230, 80,1)";
  39 +
  40 + daytimeBackground[0].style.transform = "translateX(0)";
  41 + daytimeBackground[1].style.transform = "translateX(0)";
  42 + daytimeBackground[2].style.transform = "translateX(0)";
  43 + cloud.style.transform = "translateY(10em)";
  44 + cloudLight.style.transform = "translateY(10em)";
  45 + components.style.backgroundColor = "rgba(70, 133, 192,1)";
  46 +
  47 + moon[0].style.opacity = "0";
  48 + moon[1].style.opacity = "0";
  49 + moon[2].style.opacity = "0";
  50 +
  51 + stars.style.transform = "translateY(-125em)";
  52 + stars.style.opacity = "0";
  53 +
  54 + changeTheme("light");
  55 + } else {
  56 + mainButton.style.transform = "translateX(110em)";
  57 + mainButton.style.backgroundColor = "rgba(195, 200,210,1)";
  58 +
  59 + mainButton.style.boxShadow =
  60 + "3em 3em 5em rgba(0, 0, 0, 0.5), inset -3em -5em 3em -3em rgba(0, 0, 0, 0.5), inset 4em 5em 2em -2em rgba(255, 255, 210,1)";
  61 +
  62 + daytimeBackground[0].style.transform = "translateX(110em)";
  63 + daytimeBackground[1].style.transform = "translateX(80em)";
  64 + daytimeBackground[2].style.transform = "translateX(50em)";
  65 + cloud.style.transform = "translateY(80em)";
  66 + cloudLight.style.transform = "translateY(80em)";
  67 + components.style.backgroundColor = "rgba(25,30,50,1)";
  68 +
  69 + moon[0].style.opacity = "1";
  70 + moon[1].style.opacity = "1";
  71 + moon[2].style.opacity = "1";
  72 +
  73 + stars.style.transform = "translateY(-62.5em)";
  74 + stars.style.opacity = "1";
  75 +
  76 + changeTheme("dark");
  77 + }
  78 +
  79 + isClicked = true;
  80 +
  81 + setTimeout(function () {
  82 + isClicked = false;
  83 + }, 500);
  84 + isMoved = !isMoved;
  85 + };
  86 +
  87 + mainButton.addEventListener("mousemove", function () {
  88 + if (isClicked) return;
  89 +
  90 + if (isMoved) {
  91 + mainButton.style.transform = "translateX(100em)";
  92 + daytimeBackground[0].style.transform = "translateX(100em)";
  93 + daytimeBackground[1].style.transform = "translateX(73em)";
  94 + daytimeBackground[2].style.transform = "translateX(46em)";
  95 +
  96 + star[0].style.top = "10em";
  97 + star[0].style.left = "36em";
  98 + star[1].style.top = "40em";
  99 + star[1].style.left = "87em";
  100 + star[2].style.top = "26em";
  101 + star[2].style.left = "16em";
  102 + star[3].style.top = "38em";
  103 + star[3].style.left = "63em";
  104 + star[4].style.top = "20.5em";
  105 + star[4].style.left = "72em";
  106 + star[5].style.top = "51.5em";
  107 + star[5].style.left = "35em";
  108 + } else {
  109 + mainButton.style.transform = "translateX(10em)";
  110 + daytimeBackground[0].style.transform = "translateX(10em)";
  111 + daytimeBackground[1].style.transform = "translateX(7em)";
  112 + daytimeBackground[2].style.transform = "translateX(4em)";
  113 +
  114 + cloudList[0].style.right = "-24em";
  115 + cloudList[0].style.bottom = "10em";
  116 + cloudList[1].style.right = "-12em";
  117 + cloudList[1].style.bottom = "-27em";
  118 + cloudList[2].style.right = "17em";
  119 + cloudList[2].style.bottom = "-43em";
  120 + cloudList[3].style.right = "46em";
  121 + cloudList[3].style.bottom = "-39em";
  122 + cloudList[4].style.right = "70em";
  123 + cloudList[4].style.bottom = "-65em";
  124 + cloudList[5].style.right = "109em";
  125 + cloudList[5].style.bottom = "-54em";
  126 + cloudList[6].style.right = "-23em";
  127 + cloudList[6].style.bottom = "10em";
  128 + cloudList[7].style.right = "-11em";
  129 + cloudList[7].style.bottom = "-26em";
  130 + cloudList[8].style.right = "18em";
  131 + cloudList[8].style.bottom = "-42em";
  132 + cloudList[9].style.right = "47em";
  133 + cloudList[9].style.bottom = "-38em";
  134 + cloudList[10].style.right = "74em";
  135 + cloudList[10].style.bottom = "-64em";
  136 + cloudList[11].style.right = "110em";
  137 + cloudList[11].style.bottom = "-55em";
  138 + }
  139 + });
  140 +
  141 + mainButton.addEventListener("mouseout", function () {
  142 + if (isClicked) {
  143 + return;
  144 + }
  145 + if (isMoved) {
  146 + mainButton.style.transform = "translateX(110em)";
  147 + daytimeBackground[0].style.transform = "translateX(110em)";
  148 + daytimeBackground[1].style.transform = "translateX(80em)";
  149 + daytimeBackground[2].style.transform = "translateX(50em)";
  150 +
  151 + star[0].style.top = "11em";
  152 + star[0].style.left = "39em";
  153 + star[1].style.top = "39em";
  154 + star[1].style.left = "91em";
  155 + star[2].style.top = "26em";
  156 + star[2].style.left = "19em";
  157 + star[3].style.top = "37em";
  158 + star[3].style.left = "66em";
  159 + star[4].style.top = "21em";
  160 + star[4].style.left = "75em";
  161 + star[5].style.top = "51em";
  162 + star[5].style.left = "38em";
  163 + } else {
  164 + mainButton.style.transform = "translateX(0em)";
  165 + daytimeBackground[0].style.transform = "translateX(0em)";
  166 + daytimeBackground[1].style.transform = "translateX(0em)";
  167 + daytimeBackground[2].style.transform = "translateX(0em)";
  168 +
  169 + cloudList[0].style.right = "-20em";
  170 + cloudList[0].style.bottom = "10em";
  171 + cloudList[1].style.right = "-10em";
  172 + cloudList[1].style.bottom = "-25em";
  173 + cloudList[2].style.right = "20em";
  174 + cloudList[2].style.bottom = "-40em";
  175 + cloudList[3].style.right = "50em";
  176 + cloudList[3].style.bottom = "-35em";
  177 + cloudList[4].style.right = "75em";
  178 + cloudList[4].style.bottom = "-60em";
  179 + cloudList[5].style.right = "110em";
  180 + cloudList[5].style.bottom = "-50em";
  181 + cloudList[6].style.right = "-20em";
  182 + cloudList[6].style.bottom = "10em";
  183 + cloudList[7].style.right = "-10em";
  184 + cloudList[7].style.bottom = "-25em";
  185 + cloudList[8].style.right = "20em";
  186 + cloudList[8].style.bottom = "-40em";
  187 + cloudList[9].style.right = "50em";
  188 + cloudList[9].style.bottom = "-35em";
  189 + cloudList[10].style.right = "75em";
  190 + cloudList[10].style.bottom = "-60em";
  191 + cloudList[11].style.right = "110em";
  192 + cloudList[11].style.bottom = "-50em";
  193 + }
  194 + });
  195 +
  196 + const getRandomDirection = () => {
  197 + const directions = ["2em", "-2em"];
  198 + return directions[Math.floor(Math.random() * directions.length)];
  199 + };
  200 +
  201 + const moveElementRandomly = (element) => {
  202 + const randomDirectionX = getRandomDirection();
  203 + const randomDirectionY = getRandomDirection();
  204 + element.style.transform = `translate(${randomDirectionX}, ${randomDirectionY})`;
  205 + };
  206 +
  207 + const cloudSons = root.querySelectorAll(".cloud-son");
  208 + setInterval(() => {
  209 + cloudSons.forEach(moveElementRandomly);
  210 + }, 1000);
  211 +
  212 + if (initTheme === "dark") {
  213 + components.onclick();
  214 + }
  215 + };
  216 +
  217 + class ThemeButton extends HTMLElement {
  218 + constructor() {
  219 + super();
  220 + }
  221 + connectedCallback() {
  222 + const initTheme = this.getAttribute("value") || "light";
  223 + const size = +this.getAttribute("size") || 3;
  224 + const shadow = this.attachShadow({ mode: "closed" });
  225 + const container = document.createElement("div");
  226 + container.setAttribute("class", "container");
  227 + container.setAttribute("style", `font-size: ${(size / 3).toFixed(2)}px`);
  228 + container.innerHTML =
  229 + '<div class="components"><div class="main-button"><div class="moon"></div><div class="moon"></div><div class="moon"></div></div><div class="daytime-background"></div><div class="daytime-background"></div><div class="daytime-background"></div><div class="cloud"><div class="cloud-son"></div><div class="cloud-son"></div><div class="cloud-son"></div><div class="cloud-son"></div><div class="cloud-son"></div><div class="cloud-son"></div></div><div class="cloud-light"><div class="cloud-son"></div><div class="cloud-son"></div><div class="cloud-son"></div><div class="cloud-son"></div><div class="cloud-son"></div><div class="cloud-son"></div></div><div class="stars"><div class="star big"><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div></div><div class="star big"><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div></div><div class="star medium"><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div></div><div class="star medium"><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div></div><div class="star small"><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div></div><div class="star small"><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div><div class="star-son"></div></div></div></div>';
  230 + const style = document.createElement("style");
  231 + style.textContent =
  232 + "* { margin: 0; padding: 0; transition: 0.7s; -webkit-tap-highlight-color:rgba(0,0,0,0); } .container { position: absolute;top: 50%;left: 50%;margin-top: -35em;margin-left: -90em;width: 180em; height: 70em; display: inline-block; vertical-align: bottom; transform: translate3d(0, 0, 0); } .components{ position:fixed; width: 180em; height: 70em; background-color: rgba(70, 133, 192,1); border-radius: 100em; box-shadow: inset 0 0 5em 3em rgba(0, 0, 0, 0.5); overflow: hidden; transition: 0.7s; transition-timing-function: cubic-bezier( 0,0.5, 1,1); cursor: pointer; } .main-button{ margin: 7.5em 0 0 7.5em; width: 55em; height:55em; background-color: rgba(255, 195, 35,1); border-radius: 50%; box-shadow:3em 3em 5em rgba(0, 0, 0, 0.5), inset -3em -5em 3em -3em rgba(0, 0, 0, 0.5), inset 4em 5em 2em -2em rgba(255, 230, 80,1); transition: 1.0s; transition-timing-function: cubic-bezier(0.56, 1.35, 0.52, 1.00); } .moon{ position: absolute; background-color: rgba(150, 160, 180, 1); box-shadow:inset 0em 0em 1em 1em rgba(0, 0, 0, 0.3) ; border-radius: 50%; transition: 0.5s; opacity: 0; } .moon:nth-child(1){ top: 7.5em; left: 25em; width: 12.5em; height: 12.5em; } .moon:nth-child(2){ top: 20em; left: 7.5em; width: 20em; height: 20em; } .moon:nth-child(3){ top: 32.5em; left: 32.5em; width: 12.5em; height: 12.5em; } .daytime-background { position: absolute; border-radius: 50%; transition: 1.0s; transition-timing-function: cubic-bezier(0.56, 1.35, 0.52, 1.00); } .daytime-background:nth-child(2){ top: -20em; left: -20em; width: 110em; height:110em; background-color: rgba(255, 255, 255,0.2); z-index: -2; } .daytime-background:nth-child(3){ top: -32.5em; left: -17.5em; width: 135em; height:135em; background-color: rgba(255, 255, 255,0.1); z-index: -3; } .daytime-background:nth-child(4){ top: -45em; left: -15em; width: 160em; height:160em; background-color: rgba(255, 255, 255,0.05); z-index: -4; } .cloud,.cloud-light{ transform: translateY(10em); transition: 1.0s; transition-timing-function: cubic-bezier(0.56, 1.35, 0.52, 1.00); } .cloud-son{ position: absolute; background-color: #fff; border-radius: 50%; z-index: -1; transition: transform 6s,right 1s,bottom 1s; } .cloud-son:nth-child(6n+1){ right: -20em; bottom: 10em; width: 50em; height: 50em; } .cloud-son:nth-child(6n+2) { right: -10em; bottom: -25em; width: 60em; height: 60em; } .cloud-son:nth-child(6n+3) { right: 20em; bottom: -40em; width: 60em; height: 60em; } .cloud-son:nth-child(6n+4) { right: 50em; bottom: -35em; width: 60em; height: 60em; } .cloud-son:nth-child(6n+5) { right: 75em; bottom: -60em; width: 75em; height: 75em; } .cloud-son:nth-child(6n+6) { right: 110em; bottom: -50em; width: 60em; height: 60em; } .cloud{ z-index: -2; } .cloud-light{ position: absolute; right: 0em; bottom: 25em; opacity: 0.5; z-index: -3; /*transform: rotate(-5deg);*/ } .stars{ transform: translateY(-125em); z-index: -2; transition: 1.0s; transition-timing-function: cubic-bezier(0.56, 1.35, 0.52, 1.00); } .big { --size: 7.5em; } .medium { --size: 5em; } .small { --size: 3em; } .star { position: absolute; width: calc(2*var(--size)); height: calc(2*var(--size)); } .star:nth-child(1){ top: 11em; left: 39em; animation-name: star; animation-duration: 3.5s; } .star:nth-child(2){ top: 39em; left: 91em; animation-name: star; animation-duration: 4.1s; } .star:nth-child(3){ top: 26em; left: 19em; animation-name: star; animation-duration: 4.9s; } .star:nth-child(4){ top: 37em; left: 66em; animation-name: star; animation-duration: 5.3s; } .star:nth-child(5){ top: 21em; left: 75em; animation-name: star; animation-duration: 3s; } .star:nth-child(6){ top: 51em; left: 38em; animation-name: star; animation-duration: 2.2s; } @keyframes star { 0%,20%{ transform: scale(0); } 20%,100% { transform: scale(1); } } .star-son{ float: left; } .star-son:nth-child(1) { --pos: left 0; } .star-son:nth-child(2) { --pos: right 0; } .star-son:nth-child(3) { --pos: 0 bottom; } .star-son:nth-child(4) { --pos: right bottom; } .star-son { width: var(--size); height: var(--size); background-image: radial-gradient(circle var(--size) at var(--pos), transparent var(--size), #fff); } .star{ transform: scale(1); transition-timing-function: cubic-bezier(0.56, 1.35, 0.52, 1.00); transition: 1s; animation-iteration-count:infinite; animation-direction: alternate; animation-timing-function: linear; } .twinkle { transform: scale(0); }";
  233 + const changeTheme = (detail) => {
  234 + this.dispatchEvent(new CustomEvent("change", { detail }));
  235 + };
  236 + func(container, initTheme, changeTheme);
  237 + shadow.appendChild(style);
  238 + shadow.appendChild(container);
  239 + }
  240 + }
  241 +
  242 + customElements.define("theme-button", ThemeButton);
  243 +})();