马一丁
Committed by GitHub

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

Blocked html
... ... @@ -31,6 +31,7 @@ from .nodes import (
ChapterGenerationNode,
ChapterJsonParseError,
ChapterContentError,
ChapterValidationError,
DocumentLayoutNode,
WordBudgetNode,
)
... ... @@ -601,11 +602,16 @@ class ReportAgent:
stream_callback=chunk_callback
)
break
except (ChapterJsonParseError, ChapterContentError) as structured_error:
error_kind = (
"content_sparse" if isinstance(structured_error, ChapterContentError) else "json_parse"
)
readable_label = "内容密度异常" if error_kind == "content_sparse" else "JSON解析失败"
except (ChapterJsonParseError, ChapterContentError, ChapterValidationError) as structured_error:
if isinstance(structured_error, ChapterContentError):
error_kind = "content_sparse"
readable_label = "内容密度异常"
elif isinstance(structured_error, ChapterValidationError):
error_kind = "validation"
readable_label = "结构校验失败"
else:
error_kind = "json_parse"
readable_label = "JSON解析失败"
if isinstance(structured_error, ChapterContentError):
candidate = getattr(structured_error, "chapter_payload", None)
candidate_score = getattr(structured_error, "body_characters", 0) or 0
... ... @@ -636,6 +642,10 @@ class ReportAgent:
'error': str(structured_error),
'reason': error_kind,
}
if isinstance(structured_error, ChapterValidationError):
validation_errors = getattr(structured_error, "errors", None)
if validation_errors:
status_payload['errors'] = validation_errors
if will_fallback:
status_payload['warning'] = 'content_sparse_fallback_pending'
emit('chapter_status', status_payload)
... ...
... ... @@ -33,6 +33,8 @@ ALLOWED_BLOCK_TYPES: List[str] = [
"paragraph",
"list",
"table",
"swotTable",
"pestTable",
"blockquote",
"engineQuote",
"hr",
... ... @@ -169,6 +171,137 @@ table_block: Dict[str, Any] = {
"additionalProperties": True,
}
swot_item_schema: Dict[str, Any] = {
"title": "SwotItem",
"oneOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"title": {"type": "string"},
"label": {"type": "string"},
"text": {"type": "string"},
"detail": {"type": "string"},
"description": {"type": "string"},
"evidence": {"type": "string"},
"impact": {
"type": "string",
"enum": ["低", "中低", "中", "中高", "高", "极高"],
"description": "影响评级,只允许填写:低/中低/中/中高/高/极高",
},
# "score": {
# "type": "number",
# "minimum": 0,
# "maximum": 10,
# "description": "评分,只允许0-10的数字",
# },
"priority": {"type": ["string", "number"]},
},
"required": [],
"additionalProperties": True,
},
],
}
swot_block: Dict[str, Any] = {
"title": "SwotTableBlock",
"type": "object",
"properties": {
"type": {"const": "swotTable"},
"title": {"type": "string"},
"summary": {"type": "string"},
"strengths": {
"type": "array",
"items": {"$ref": "#/definitions/swotItem"},
},
"weaknesses": {
"type": "array",
"items": {"$ref": "#/definitions/swotItem"},
},
"opportunities": {
"type": "array",
"items": {"$ref": "#/definitions/swotItem"},
},
"threats": {
"type": "array",
"items": {"$ref": "#/definitions/swotItem"},
},
},
"required": ["type"],
"anyOf": [
{"required": ["strengths"]},
{"required": ["weaknesses"]},
{"required": ["opportunities"]},
{"required": ["threats"]},
],
"additionalProperties": True,
}
pest_item_schema: Dict[str, Any] = {
"title": "PestItem",
"oneOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"title": {"type": "string"},
"label": {"type": "string"},
"text": {"type": "string"},
"detail": {"type": "string"},
"description": {"type": "string"},
"source": {"type": "string"},
"evidence": {"type": "string"},
"trend": {
"type": "string",
"enum": ["正面利好", "负面影响", "中性", "不确定", "持续观察"],
"description": "趋势/影响评估,只允许填写:正面利好/负面影响/中性/不确定/持续观察",
},
"impact": {"type": ["string", "number"]},
},
"required": [],
"additionalProperties": True,
},
],
}
pest_block: Dict[str, Any] = {
"title": "PestTableBlock",
"type": "object",
"properties": {
"type": {"const": "pestTable"},
"title": {"type": "string"},
"summary": {"type": "string"},
"political": {
"type": "array",
"items": {"$ref": "#/definitions/pestItem"},
"description": "政治因素:政策法规、政府态度、政治稳定性等",
},
"economic": {
"type": "array",
"items": {"$ref": "#/definitions/pestItem"},
"description": "经济因素:经济周期、利率汇率、消费水平等",
},
"social": {
"type": "array",
"items": {"$ref": "#/definitions/pestItem"},
"description": "社会因素:人口结构、文化趋势、生活方式等",
},
"technological": {
"type": "array",
"items": {"$ref": "#/definitions/pestItem"},
"description": "技术因素:技术创新、研发投入、技术普及等",
},
},
"required": ["type"],
"anyOf": [
{"required": ["political"]},
{"required": ["economic"]},
{"required": ["social"]},
{"required": ["technological"]},
],
"additionalProperties": True,
}
blockquote_block: Dict[str, Any] = {
"title": "BlockquoteBlock",
"type": "object",
... ... @@ -361,6 +494,8 @@ block_variants: List[Dict[str, Any]] = [
kpi_block,
widget_block,
toc_block,
swot_block,
pest_block,
]
CHAPTER_JSON_SCHEMA: Dict[str, Any] = {
... ... @@ -388,6 +523,8 @@ CHAPTER_JSON_SCHEMA: Dict[str, Any] = {
"definitions": {
"inlineMark": inline_mark_schema,
"inlineRun": inline_run_schema,
"swotItem": swot_item_schema,
"pestItem": pest_item_schema,
"block": {"oneOf": block_variants},
},
}
... ...
... ... @@ -132,6 +132,69 @@ class IRValidator:
errors,
)
def _validate_swotTable_block(self, block: Dict[str, Any], path: str, errors: List[str]):
"""SWOT表至少提供四象限之一,每象限为条目数组"""
quadrants = ("strengths", "weaknesses", "opportunities", "threats")
if not any(block.get(name) is not None for name in quadrants):
errors.append(f"{path} 需要至少包含 strengths/weaknesses/opportunities/threats 之一")
for name in quadrants:
entries = block.get(name)
if entries is None:
continue
if not isinstance(entries, list):
errors.append(f"{path}.{name} 必须是数组")
continue
for idx, entry in enumerate(entries):
self._validate_swot_item(entry, f"{path}.{name}[{idx}]", errors)
# SWOT impact 字段允许的评级值
ALLOWED_IMPACT_VALUES = {"低", "中低", "中", "中高", "高", "极高"}
def _validate_swot_item(self, item: Any, path: str, errors: List[str]):
"""单个SWOT条目支持字符串或带字段的对象"""
if isinstance(item, str):
if not item.strip():
errors.append(f"{path} 不能为空字符串")
return
if not isinstance(item, dict):
errors.append(f"{path} 必须是字符串或对象")
return
title = None
for key in ("title", "label", "text", "detail", "description"):
value = item.get(key)
if isinstance(value, str) and value.strip():
title = value
break
if title is None:
errors.append(f"{path} 缺少 title/label/text/description 等文字字段")
# 校验 impact 字段:只允许评级值
impact = item.get("impact")
if impact is not None:
if not isinstance(impact, str) or impact not in self.ALLOWED_IMPACT_VALUES:
errors.append(
f"{path}.impact 只允许填写影响评级(低/中低/中/中高/高/极高),"
f"当前值: {impact};如需详细说明请写入 detail 字段"
)
# # 校验 score 字段:只允许 0-10 的数字(已禁用)
# score = item.get("score")
# if score is not None:
# valid_score = False
# if isinstance(score, (int, float)):
# valid_score = 0 <= score <= 10
# elif isinstance(score, str):
# # 兼容字符串形式的数字
# try:
# numeric_score = float(score)
# valid_score = 0 <= numeric_score <= 10
# except ValueError:
# valid_score = False
# if not valid_score:
# errors.append(
# f"{path}.score 只允许填写 0-10 的数字,当前值: {score}"
# )
def _validate_blockquote_block(
self, block: Dict[str, Any], path: str, errors: List[str]
):
... ...
... ... @@ -6,7 +6,12 @@ Report Engine节点处理模块。
from .base_node import BaseNode, StateMutationNode
from .template_selection_node import TemplateSelectionNode
from .chapter_generation_node import ChapterGenerationNode, ChapterJsonParseError, ChapterContentError
from .chapter_generation_node import (
ChapterGenerationNode,
ChapterJsonParseError,
ChapterContentError,
ChapterValidationError,
)
from .document_layout_node import DocumentLayoutNode
from .word_budget_node import WordBudgetNode
... ... @@ -17,6 +22,7 @@ __all__ = [
"ChapterGenerationNode",
"ChapterJsonParseError",
"ChapterContentError",
"ChapterValidationError",
"DocumentLayoutNode",
"WordBudgetNode",
]
... ...
... ... @@ -77,6 +77,18 @@ class ChapterContentError(ValueError):
self.non_heading_blocks: int = int(non_heading_blocks or 0)
class ChapterValidationError(ValueError):
"""
章节结构在本地和LLM修复后仍无法通过校验时抛出。
该异常用于在Agent层触发针对单章的重试,而无需重启整本报告。
"""
def __init__(self, message: str, errors: Optional[List[str]] | None = None):
super().__init__(message)
self.errors: List[str] = list(errors or [])
class ChapterGenerationNode(BaseNode):
"""
负责按章节调用LLM并校验JSON结构。
... ... @@ -268,8 +280,9 @@ class ChapterGenerationNode(BaseNode):
)
if not valid:
raise ValueError(
f"{section.title} 章节JSON校验失败: {'; '.join(errors[:5])}"
raise ChapterValidationError(
f"{section.title} 章节JSON校验失败: {'; '.join(errors[:5])}",
errors=errors,
)
if content_error:
raise content_error
... ... @@ -293,6 +306,11 @@ class ChapterGenerationNode(BaseNode):
# 章节篇幅规划(来自WordBudgetNode),用于指导字数与强调点
chapter_plan_map = context.get("chapter_directives", {})
chapter_plan = chapter_plan_map.get(section.chapter_id) if chapter_plan_map else {}
# 从 layout 的 tocPlan 中查找该章节是否允许使用SWOT块和PEST块
allow_swot = self._get_chapter_swot_permission(section.chapter_id, context)
allow_pest = self._get_chapter_pest_permission(section.chapter_id, context)
payload = {
"section": {
"chapterId": section.chapter_id,
... ... @@ -322,6 +340,8 @@ class ChapterGenerationNode(BaseNode):
"language": "zh-CN",
"maxTokens": context.get("max_tokens", 4096),
"allowedBlocks": ALLOWED_BLOCK_TYPES,
"allowSwot": allow_swot,
"allowPest": allow_pest,
"styleHints": {
"expectWidgets": True,
"forceHeadingAnchors": True,
... ... @@ -346,6 +366,72 @@ class ChapterGenerationNode(BaseNode):
payload["globalContext"]["sectionBudgets"] = chapter_plan["sections"]
return payload
def _get_chapter_swot_permission(self, chapter_id: str, context: Dict[str, Any]) -> bool:
"""
从 layout 的 tocPlan 中查找指定章节是否允许使用 SWOT 块。
全文最多只有一个章节允许使用 SWOT 块,由文档设计阶段在 tocPlan 中
通过 allowSwot 字段标记。
参数:
chapter_id: 当前章节ID。
context: 全局上下文字典。
返回:
bool: 如果该章节允许使用 SWOT 块则返回 True,否则返回 False。
"""
layout = context.get("layout")
if not isinstance(layout, dict):
return False
toc_plan = layout.get("tocPlan")
if not isinstance(toc_plan, list):
return False
for entry in toc_plan:
if not isinstance(entry, dict):
continue
if entry.get("chapterId") == chapter_id:
return bool(entry.get("allowSwot", False))
return False
def _get_chapter_pest_permission(self, chapter_id: str, context: Dict[str, Any]) -> bool:
"""
从 layout 的 tocPlan 中查找指定章节是否允许使用 PEST 块。
全文最多只有一个章节允许使用 PEST 块,由文档设计阶段在 tocPlan 中
通过 allowPest 字段标记。
PEST块用于宏观环境分析:
- Political(政治因素)
- Economic(经济因素)
- Social(社会因素)
- Technological(技术因素)
参数:
chapter_id: 当前章节ID。
context: 全局上下文字典。
返回:
bool: 如果该章节允许使用 PEST 块则返回 True,否则返回 False。
"""
layout = context.get("layout")
if not isinstance(layout, dict):
return False
toc_plan = layout.get("tocPlan")
if not isinstance(toc_plan, list):
return False
for entry in toc_plan:
if not isinstance(entry, dict):
continue
if entry.get("chapterId") == chapter_id:
return bool(entry.get("allowPest", False))
return False
def _stream_llm(
self,
user_message: str,
... ... @@ -1555,4 +1641,9 @@ class ChapterGenerationNode(BaseNode):
raise last_exc
__all__ = ["ChapterGenerationNode", "ChapterJsonParseError"]
__all__ = [
"ChapterGenerationNode",
"ChapterJsonParseError",
"ChapterContentError",
"ChapterValidationError",
]
... ...
... ... @@ -139,6 +139,14 @@ document_layout_output_schema = {
"anchor": {"type": "string"},
"display": {"type": "string"},
"description": {"type": "string"},
"allowSwot": {
"type": "boolean",
"description": "是否允许该章节使用SWOT分析块,全文最多只有一个章节可设为true",
},
"allowPest": {
"type": "boolean",
"description": "是否允许该章节使用PEST分析块,全文最多只有一个章节可设为true",
},
},
"required": ["chapterId", "display"],
},
... ... @@ -304,19 +312,30 @@ SYSTEM_PROMPT_CHAPTER_JSON = f"""
3. 所有段落都放入paragraph.inlines,混排样式通过marks表示(bold/italic/color/link等)。
4. 所有heading必须包含anchor,锚点与编号保持模板一致,比如section-2-1。
5. 表格需给出rows/cells/align,KPI卡请使用kpiGrid,分割线用hr。
6. 如需引用图表/交互组件,统一用widgetType表示(例如chart.js/line、chart.js/doughnut)。
7. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。
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。
9. 如果chapterPlan中包含target/min/max或sections细分预算,请尽量贴合,必要时在notes允许的范围内突破,同时在结构上体现详略;
10. 一级标题需使用中文数字(“一、二、三”),二级标题使用阿拉伯数字(“1.1、1.2”),heading.text中直接写好编号,与outline顺序对应;
11. 严禁输出外部图片/AI生图链接,仅可使用Chart.js图表、表格、色块、callout等HTML原生组件;如需视觉辅助请改为文字描述或数据表;
12. 段落混排需通过marks表达粗体、斜体、下划线、颜色等样式,禁止残留Markdown语法(如**text**);
13. 行间公式用block.type="math"并填入math.latex,行内公式在paragraph.inlines里将文本设为Latex并加上marks.type="math",渲染层会用MathJax处理;
14. widget配色需与CSS变量兼容,不要硬编码背景色或文字色,legend/ticks由渲染层控制;
15. 善用callout、kpiGrid、表格、widget等提升版面丰富度,但必须遵守模板章节范围。
16. 输出前务必自检JSON语法:禁止出现`{{}}{{`或`][`相连缺少逗号、列表项嵌套超过一层、未闭合的括号或未转义换行,`list` block的items必须是`[[block,...], ...]`结构,若无法满足则返回错误提示而不是输出不合法JSON。
17. 所有widget块必须在顶层提供`data`或`dataRef`(可将props中的`data`上移),确保Chart.js能够直接渲染;缺失数据时宁可输出表格或段落,绝不留空。
18. 任何block都必须声明合法`type`(heading/paragraph/list/...);若需要普通文本请使用`paragraph`并给出`inlines`,禁止返回`type:null`或未知值。
6. **SWOT块使用限制(重要!)**:
- 只有在 constraints.allowSwot 为 true 时才允许使用 block.type="swotTable";
- 如果 constraints.allowSwot 为 false 或不存在,严禁生成任何 swotTable 类型的块,即使章节标题包含"SWOT"字样也不能使用该块类型,应改用表格(table)或列表(list)呈现相关内容;
- 当允许使用SWOT块时,分别填写 strengths/weaknesses/opportunities/threats 数组,单项至少包含 title/label/text 之一,可附加 detail/evidence/impact 字段;title/summary 字段用于概览说明;
- **特别注意:impact 字段只允许填写影响评级("低"/"中低"/"中"/"中高"/"高"/"极高");任何关于影响的文字叙述、详细说明、佐证或扩展描述必须写入 detail 字段,禁止在 impact 字段中混入描述性文字。**
7. **PEST块使用限制(重要!)**:
- 只有在 constraints.allowPest 为 true 时才允许使用 block.type="pestTable";
- 如果 constraints.allowPest 为 false 或不存在,严禁生成任何 pestTable 类型的块,即使章节标题包含"PEST"、"宏观环境"等字样也不能使用该块类型,应改用表格(table)或列表(list)呈现相关内容;
- 当允许使用PEST块时,分别填写 political/economic/social/technological 数组,单项至少包含 title/label/text 之一,可附加 detail/source/trend 字段;title/summary 字段用于概览说明;
- **PEST四维度说明**:political(政治因素:政策法规、政府态度、监管环境)、economic(经济因素:经济周期、利率汇率、市场需求)、social(社会因素:人口结构、文化趋势、消费习惯)、technological(技术因素:技术创新、研发趋势、数字化程度);
- **特别注意:trend 字段只允许填写趋势评估("正面利好"/"负面影响"/"中性"/"不确定"/"持续观察");任何关于趋势的文字叙述、详细说明、来源或扩展描述必须写入 detail 字段,禁止在 trend 字段中混入描述性文字。**
8. 如需引用图表/交互组件,统一用widgetType表示(例如chart.js/line、chart.js/doughnut)。
9. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。
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。
11. 如果chapterPlan中包含target/min/max或sections细分预算,请尽量贴合,必要时在notes允许的范围内突破,同时在结构上体现详略;
12. 一级标题需使用中文数字(“一、二、三”),二级标题使用阿拉伯数字(“1.1、1.2”),heading.text中直接写好编号,与outline顺序对应;
13. 严禁输出外部图片/AI生图链接,仅可使用Chart.js图表、表格、色块、callout等HTML原生组件;如需视觉辅助请改为文字描述或数据表;
14. 段落混排需通过marks表达粗体、斜体、下划线、颜色等样式,禁止残留Markdown语法(如**text**);
15. 行间公式用block.type="math"并填入math.latex,行内公式在paragraph.inlines里将文本设为Latex并加上marks.type="math",渲染层会用MathJax处理;
16. widget配色需与CSS变量兼容,不要硬编码背景色或文字色,legend/ticks由渲染层控制;
17. 善用callout、kpiGrid、表格、widget等提升版面丰富度,但必须遵守模板章节范围。
18. 输出前务必自检JSON语法:禁止出现`{{}}{{`或`][`相连缺少逗号、列表项嵌套超过一层、未闭合的括号或未转义换行,`list` block的items必须是`[[block,...], ...]`结构,若无法满足则返回错误提示而不是输出不合法JSON。
19. 所有widget块必须在顶层提供`data`或`dataRef`(可将props中的`data`上移),确保Chart.js能够直接渲染;缺失数据时宁可输出表格或段落,绝不留空。
20. 任何block都必须声明合法`type`(heading/paragraph/list/...);若需要普通文本请使用`paragraph`并给出`inlines`,禁止返回`type:null`或未知值。
<CHAPTER JSON SCHEMA>
{CHAPTER_JSON_SCHEMA_TEXT}
... ... @@ -375,7 +394,19 @@ SYSTEM_PROMPT_DOCUMENT_LAYOUT = f"""
3. 输出 tocPlan,一级目录固定用中文数字("一、二、三"),二级目录用"1.1/1.2",可在description里说明详略;如需定制目录标题,请填写 tocTitle;
4. 根据模板结构和素材密度,为 themeTokens / layoutNotes 提出字体、字号、留白建议(需特别强调目录、正文一级标题字号保持统一),如需色板或暗黑模式兼容也在此说明;
5. 严禁要求外部图片或AI生图,推荐Chart.js图表、表格、色块、KPI卡等可直接渲染的原生组件;
6. 不随意增删章节,仅优化命名或描述;若有排版或章节合并提示,请放入 layoutNotes,渲染层会严格遵循。
6. 不随意增删章节,仅优化命名或描述;若有排版或章节合并提示,请放入 layoutNotes,渲染层会严格遵循;
7. **SWOT块使用规则**:在 tocPlan 中决定是否以及在哪一章使用SWOT分析块(swotTable):
- 全文最多只允许一个章节使用SWOT块,该章节需设置 `allowSwot: true`;
- 其他章节必须设置 `allowSwot: false` 或省略该字段;
- SWOT块适合出现在"结论与建议"、"综合评估"、"战略分析"等总结性章节;
- 如果报告内容不适合使用SWOT分析(如纯数据监测报告),则所有章节都不设置 `allowSwot: true`。
8. **PEST块使用规则**:在 tocPlan 中决定是否以及在哪一章使用PEST宏观环境分析块(pestTable):
- 全文最多只允许一个章节使用PEST块,该章节需设置 `allowPest: true`;
- 其他章节必须设置 `allowPest: false` 或省略该字段;
- PEST块用于分析宏观环境因素(政治Political、经济Economic、社会Social、技术Technological);
- PEST块适合出现在"行业环境分析"、"宏观背景"、"外部环境研判"等分析宏观因素的章节;
- 如果报告主题与宏观环境分析无关(如具体事件危机公关报告),则所有章节都不设置 `allowPest: true`;
- SWOT和PEST不应出现在同一章节,二者分别侧重内部能力与外部环境。
**tocPlan的description字段特别要求:**
- description字段必须是纯文本描述,用于在目录中展示章节简介
... ...
This diff could not be displayed because it is too large.
... ... @@ -915,16 +915,17 @@ p {{
height: auto;
display: flex;
flex-direction: column;
align-items: stretch !important;
gap: 8px;
}}
.kpi-card .kpi-value {{
font-size: {body_kpi_value}px !important;
line-height: 1.25;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
max-width: 100%;
white-space: nowrap;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
flex-wrap: nowrap;
align-items: baseline;
... ... @@ -1158,8 +1159,8 @@ td {{
.hero-kpi .value {{
font-size: {overview_kpi_value}px !important;
word-break: break-word;
overflow-wrap: break-word;
white-space: nowrap;
width: 100%;
max-width: 100%;
line-height: 1.1;
display: block;
... ...
... ... @@ -1000,6 +1000,133 @@ body {{
background: white !important;
}}
/* ========== 修复 WeasyPrint CSS 变量渐变兼容性问题 ========== */
/* WeasyPrint 不支持在 linear-gradient 中使用 var(),需要用静态值覆盖 */
/* 覆盖按钮渐变 */
.action-btn {{
background: linear-gradient(135deg, #4a90e2 0%, #17a2b8 100%) !important;
}}
/* 覆盖进度条渐变 */
.export-progress::after {{
background: linear-gradient(90deg, #4a90e2, #17a2b8) !important;
}}
/* 覆盖 PEST 卡片标题渐变 */
.pest-card__title {{
background: linear-gradient(135deg, #8e44ad, #2980b9) !important;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
background-clip: text !important;
}}
/* 覆盖 PEST 条带指示器渐变 */
.pest-strip__indicator.political {{
background: linear-gradient(180deg, #8e44ad, rgba(142,68,173,0.8)) !important;
}}
.pest-strip__indicator.economic {{
background: linear-gradient(180deg, #16a085, rgba(22,160,133,0.8)) !important;
}}
.pest-strip__indicator.social {{
background: linear-gradient(180deg, #e84393, rgba(232,67,147,0.8)) !important;
}}
.pest-strip__indicator.technological {{
background: linear-gradient(180deg, #2980b9, rgba(41,128,185,0.8)) !important;
}}
/* 覆盖 PEST 条带背景(原来使用 var(--pest-strip-*-bg),包含渐变和变量) */
.pest-strip {{
background: #ffffff !important;
}}
.pest-strip.political {{
background: linear-gradient(90deg, rgba(142,68,173,0.08), rgba(255,255,255,0.85)), #ffffff !important;
border-color: rgba(142,68,173,0.4) !important;
}}
.pest-strip.economic {{
background: linear-gradient(90deg, rgba(22,160,133,0.08), rgba(255,255,255,0.85)), #ffffff !important;
border-color: rgba(22,160,133,0.4) !important;
}}
.pest-strip.social {{
background: linear-gradient(90deg, rgba(232,67,147,0.08), rgba(255,255,255,0.85)), #ffffff !important;
border-color: rgba(232,67,147,0.4) !important;
}}
.pest-strip.technological {{
background: linear-gradient(90deg, rgba(41,128,185,0.08), rgba(255,255,255,0.85)), #ffffff !important;
border-color: rgba(41,128,185,0.4) !important;
}}
/* 覆盖 SWOT 卡片背景(原来使用 var(--swot-card-bg),包含渐变和变量) */
.swot-card {{
background: linear-gradient(135deg, rgba(76,132,255,0.04), rgba(28,127,110,0.06)), #ffffff !important;
}}
/* 覆盖 SWOT 单元格背景(原来使用 var(--swot-cell-*-bg),包含渐变和变量) */
.swot-cell {{
background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(255,255,255,0.5)) !important;
}}
.swot-cell.strength {{
background: linear-gradient(135deg, rgba(28,127,110,0.07), rgba(255,255,255,0.78)), #ffffff !important;
border-color: rgba(28,127,110,0.35) !important;
}}
.swot-cell.weakness {{
background: linear-gradient(135deg, rgba(192,57,43,0.07), rgba(255,255,255,0.78)), #ffffff !important;
border-color: rgba(192,57,43,0.35) !important;
}}
.swot-cell.opportunity {{
background: linear-gradient(135deg, rgba(31,90,179,0.07), rgba(255,255,255,0.78)), #ffffff !important;
border-color: rgba(31,90,179,0.35) !important;
}}
.swot-cell.threat {{
background: linear-gradient(135deg, rgba(179,107,22,0.07), rgba(255,255,255,0.78)), #ffffff !important;
border-color: rgba(179,107,22,0.35) !important;
}}
/* 覆盖 SWOT 图例项和药丸(使用静态颜色) */
.swot-legend__item.strength, .swot-pill.strength {{
background: #1c7f6e !important;
}}
.swot-legend__item.weakness, .swot-pill.weakness {{
background: #c0392b !important;
}}
.swot-legend__item.opportunity, .swot-pill.opportunity {{
background: #1f5ab3 !important;
}}
.swot-legend__item.threat, .swot-pill.threat {{
background: #b36b16 !important;
}}
/* 覆盖其他使用 var() 的元素 */
.swot-item {{
background: rgba(255,255,255,0.92) !important;
}}
.swot-tag {{
background: rgba(0,0,0,0.04) !important;
}}
.swot-empty {{
border-color: #e0e0e0 !important;
}}
/* 覆盖 PEST 卡片背景 */
.pest-card {{
background: linear-gradient(145deg, rgba(142,68,173,0.03), rgba(22,160,133,0.04)), #ffffff !important;
}}
/* 覆盖图表卡片错误状态渐变 */
.chart-card.chart-card--error {{
background: linear-gradient(135deg, rgba(0,0,0,0.015), rgba(0,0,0,0.04)) !important;
}}
/* 覆盖词云徽章渐变 */
.wordcloud-badge {{
background: linear-gradient(135deg, rgba(74, 144, 226, 0.14) 0%, rgba(74, 144, 226, 0.24) 100%) !important;
}}
/* 覆盖英雄区域渐变 */
.hero-section {{
background: linear-gradient(135deg, rgba(0,123,255,0.1), rgba(23,162,184,0.1)) !important;
}}
/* SVG图表容器样式 */
.chart-svg-container {{
width: 100%;
... ... @@ -1049,6 +1176,379 @@ body {{
min-height: 400px;
}}
/* ========== SWOT PDF表格布局 ========== */
/* 核心策略:PDF中使用表格形式而非卡片形式,更适合分页 */
/* 隐藏HTML卡片布局,显示PDF表格布局 */
.swot-card--html {{
display: none !important;
}}
.swot-pdf-wrapper {{
display: block !important;
margin: 24px 0;
}}
/* PDF表格整体样式 */
.swot-pdf-table {{
width: 100% !important;
border-collapse: collapse !important;
font-size: 11px !important;
table-layout: fixed !important;
background: white;
}}
/* 表格标题 */
.swot-pdf-caption {{
caption-side: top !important;
text-align: left !important;
font-size: 16px !important;
font-weight: 700 !important;
padding: 12px 0 !important;
color: #1a1a1a !important;
border-bottom: 2px solid #333 !important;
margin-bottom: 8px !important;
}}
/* 表头样式 */
.swot-pdf-thead {{
break-after: avoid !important;
page-break-after: avoid !important;
}}
.swot-pdf-thead th {{
background: #f0f0f0 !important;
padding: 10px 8px !important;
text-align: left !important;
font-weight: 600 !important;
border: 1px solid #ccc !important;
color: #333 !important;
font-size: 11px !important;
}}
.swot-pdf-th-quadrant {{ width: 70px !important; }}
.swot-pdf-th-num {{ width: 40px !important; text-align: center !important; }}
.swot-pdf-th-title {{ width: 20% !important; }}
.swot-pdf-th-detail {{ width: auto !important; }}
.swot-pdf-th-tags {{ width: 80px !important; text-align: center !important; }}
/* 摘要行 */
.swot-pdf-summary {{
padding: 10px 12px !important;
background: #f8f8f8 !important;
color: #555 !important;
font-style: italic !important;
border: 1px solid #ccc !important;
font-size: 11px !important;
}}
/* 每个象限区块 - 核心分页控制 */
.swot-pdf-quadrant {{
break-inside: avoid !important;
page-break-inside: avoid !important;
}}
/* 允许在不同象限之间分页 */
.swot-pdf-quadrant + .swot-pdf-quadrant {{
break-before: auto;
page-break-before: auto;
}}
/* 象限标签单元格 */
.swot-pdf-quadrant-label {{
text-align: center !important;
vertical-align: middle !important;
padding: 12px 6px !important;
font-weight: 700 !important;
border: 1px solid #ccc !important;
width: 70px !important;
}}
/* 四个象限的颜色主题 */
.swot-pdf-quadrant-label.swot-pdf-strength {{
background: #e8f5f2 !important;
color: #1c7f6e !important;
border-left: 4px solid #1c7f6e !important;
}}
.swot-pdf-quadrant-label.swot-pdf-weakness {{
background: #fdeaea !important;
color: #c0392b !important;
border-left: 4px solid #c0392b !important;
}}
.swot-pdf-quadrant-label.swot-pdf-opportunity {{
background: #e8f0fa !important;
color: #1f5ab3 !important;
border-left: 4px solid #1f5ab3 !important;
}}
.swot-pdf-quadrant-label.swot-pdf-threat {{
background: #fdf3e6 !important;
color: #b36b16 !important;
border-left: 4px solid #b36b16 !important;
}}
/* 象限代码字母 */
.swot-pdf-code {{
display: block !important;
font-size: 20px !important;
font-weight: 800 !important;
margin-bottom: 2px !important;
}}
/* 象限标签文字 */
.swot-pdf-label-text {{
display: block !important;
font-size: 9px !important;
font-weight: 600 !important;
letter-spacing: 0.02em !important;
}}
/* 数据行 */
.swot-pdf-item-row td {{
padding: 8px 6px !important;
border: 1px solid #ddd !important;
vertical-align: top !important;
font-size: 11px !important;
line-height: 1.4 !important;
}}
/* 行背景色 */
.swot-pdf-item-row.swot-pdf-strength td {{ background: #f7fbfa !important; }}
.swot-pdf-item-row.swot-pdf-weakness td {{ background: #fef9f9 !important; }}
.swot-pdf-item-row.swot-pdf-opportunity td {{ background: #f7f9fc !important; }}
.swot-pdf-item-row.swot-pdf-threat td {{ background: #fdfbf7 !important; }}
/* 序号单元格 */
.swot-pdf-item-num {{
text-align: center !important;
font-weight: 600 !important;
color: #888 !important;
width: 40px !important;
}}
/* 要点标题 */
.swot-pdf-item-title {{
font-weight: 600 !important;
color: #222 !important;
}}
/* 详情说明 */
.swot-pdf-item-detail {{
color: #444 !important;
line-height: 1.5 !important;
}}
/* 标签单元格 */
.swot-pdf-item-tags {{
text-align: center !important;
}}
/* 标签样式 */
.swot-pdf-tag {{
display: inline-block !important;
padding: 2px 6px !important;
border-radius: 3px !important;
font-size: 9px !important;
background: #e9ecef !important;
color: #495057 !important;
margin: 1px !important;
}}
.swot-pdf-tag--score {{
background: #fff3cd !important;
color: #856404 !important;
}}
/* 空数据提示 */
.swot-pdf-empty {{
text-align: center !important;
color: #999 !important;
font-style: italic !important;
}}
/* ========== PEST PDF表格布局 ========== */
/* 核心策略:PDF中使用表格形式而非卡片形式,更适合分页 */
/* 隐藏HTML卡片布局,显示PDF表格布局 */
.pest-card--html {{
display: none !important;
}}
.pest-pdf-wrapper {{
display: block !important;
margin: 24px 0;
}}
/* PDF表格整体样式 */
.pest-pdf-table {{
width: 100% !important;
border-collapse: collapse !important;
font-size: 11px !important;
table-layout: fixed !important;
background: white;
}}
/* 表格标题 */
.pest-pdf-caption {{
caption-side: top !important;
text-align: left !important;
font-size: 16px !important;
font-weight: 700 !important;
padding: 12px 0 !important;
color: #333 !important;
border-bottom: 2px solid #333 !important;
margin-bottom: 8px !important;
}}
/* 表头样式 */
.pest-pdf-thead {{
break-after: avoid !important;
page-break-after: avoid !important;
}}
.pest-pdf-thead th {{
background: #f5f3f7 !important;
padding: 10px 8px !important;
text-align: left !important;
font-weight: 600 !important;
border: 1px solid #ccc !important;
color: #4a4458 !important;
font-size: 11px !important;
}}
.pest-pdf-th-dimension {{ width: 70px !important; }}
.pest-pdf-th-num {{ width: 40px !important; text-align: center !important; }}
.pest-pdf-th-title {{ width: 20% !important; }}
.pest-pdf-th-detail {{ width: auto !important; }}
.pest-pdf-th-tags {{ width: 80px !important; text-align: center !important; }}
/* 摘要行 */
.pest-pdf-summary {{
padding: 10px 12px !important;
background: #f8f6fa !important;
color: #555 !important;
font-style: italic !important;
border: 1px solid #ccc !important;
font-size: 11px !important;
}}
/* 每个维度区块 - 核心分页控制 */
.pest-pdf-dimension {{
break-inside: avoid !important;
page-break-inside: avoid !important;
}}
/* 允许在不同维度之间分页 */
.pest-pdf-dimension + .pest-pdf-dimension {{
break-before: auto;
page-break-before: auto;
}}
/* 维度标签单元格 */
.pest-pdf-dimension-label {{
text-align: center !important;
vertical-align: middle !important;
padding: 12px 6px !important;
font-weight: 700 !important;
border: 1px solid #ccc !important;
width: 70px !important;
}}
/* 四个维度的颜色主题 */
.pest-pdf-dimension-label.pest-pdf-political {{
background: #f5eef8 !important;
color: #8e44ad !important;
border-left: 4px solid #8e44ad !important;
}}
.pest-pdf-dimension-label.pest-pdf-economic {{
background: #e8f6f3 !important;
color: #16a085 !important;
border-left: 4px solid #16a085 !important;
}}
.pest-pdf-dimension-label.pest-pdf-social {{
background: #fdecf4 !important;
color: #e84393 !important;
border-left: 4px solid #e84393 !important;
}}
.pest-pdf-dimension-label.pest-pdf-technological {{
background: #ebf3f9 !important;
color: #2980b9 !important;
border-left: 4px solid #2980b9 !important;
}}
/* 维度代码字母 */
.pest-pdf-code {{
display: block !important;
font-size: 20px !important;
font-weight: 800 !important;
margin-bottom: 2px !important;
}}
/* 维度标签文字 */
.pest-pdf-label-text {{
display: block !important;
font-size: 9px !important;
font-weight: 600 !important;
letter-spacing: 0.02em !important;
}}
/* 数据行 */
.pest-pdf-item-row td {{
padding: 8px 6px !important;
border: 1px solid #ddd !important;
vertical-align: top !important;
font-size: 11px !important;
line-height: 1.4 !important;
}}
/* 行背景色 */
.pest-pdf-item-row.pest-pdf-political td {{ background: #faf7fc !important; }}
.pest-pdf-item-row.pest-pdf-economic td {{ background: #f5fbfa !important; }}
.pest-pdf-item-row.pest-pdf-social td {{ background: #fef8fb !important; }}
.pest-pdf-item-row.pest-pdf-technological td {{ background: #f7fafd !important; }}
/* 序号单元格 */
.pest-pdf-item-num {{
text-align: center !important;
font-weight: 600 !important;
color: #888 !important;
width: 40px !important;
}}
/* 要点标题 */
.pest-pdf-item-title {{
font-weight: 600 !important;
color: #222 !important;
}}
/* 详情说明 */
.pest-pdf-item-detail {{
color: #444 !important;
line-height: 1.5 !important;
}}
/* 标签单元格 */
.pest-pdf-item-tags {{
text-align: center !important;
}}
/* 标签样式 */
.pest-pdf-tag {{
display: inline-block !important;
padding: 2px 6px !important;
border-radius: 3px !important;
font-size: 9px !important;
background: #ece9f1 !important;
color: #5a4f6a !important;
margin: 1px !important;
}}
/* 空数据提示 */
.pest-pdf-empty {{
text-align: center !important;
color: #999 !important;
font-style: italic !important;
}}
{optimized_css}
</style>
"""
... ...
#!/usr/bin/env python3
"""
生成覆盖全部允许block类型的演示 IR,用于验证 HTML 与 PDF 渲染。
执行后会在 `final_reports/ir` 写入一份带时间戳的 IR,
并分别在 `final_reports/html` 与 `final_reports/pdf` 输出对应的渲染文件。
"""
from __future__ import annotations
import json
import sys
from datetime import datetime
from pathlib import Path
# 允许直接以脚本形式运行
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from ReportEngine.core import DocumentComposer
from ReportEngine.ir import IRValidator
from ReportEngine.ir.schema import ENGINE_AGENT_TITLES
from ReportEngine.renderers import HTMLRenderer, PDFRenderer
from ReportEngine.utils.config import settings
def build_inline_marks_demo() -> dict:
"""生成覆盖全部内联标记的 paragraph block。"""
return {
"type": "paragraph",
"inlines": [
{"text": "这一段覆盖全部内联标记:"},
{"text": "粗体", "marks": [{"type": "bold"}]},
{"text": " / 斜体", "marks": [{"type": "italic"}]},
{"text": " / 下划线", "marks": [{"type": "underline"}]},
{"text": " / 删除线", "marks": [{"type": "strike"}]},
{"text": " / 代码", "marks": [{"type": "code"}]},
{
"text": " / 链接",
"marks": [
{
"type": "link",
"href": "https://example.com/demo",
"title": "示例链接",
}
],
},
{"text": " / 颜色", "marks": [{"type": "color", "value": "#c0392b"}]},
{
"text": " / 字体",
"marks": [
{
"type": "font",
"family": "Georgia, serif",
"size": "15px",
"weight": "600",
}
],
},
{"text": " / 高亮", "marks": [{"type": "highlight"}]},
{"text": " / 下标", "marks": [{"type": "subscript"}]},
{"text": " / 上标", "marks": [{"type": "superscript"}]},
{"text": " / 行内公式", "marks": [{"type": "math", "value": "E=mc^2"}]},
{"text": "。"},
],
}
def build_widget_block() -> dict:
"""构造一个合法的 Chart.js widget block。"""
return {
"type": "widget",
"widgetId": "demo-volume-trend",
"widgetType": "chart.js/line",
"props": {
"type": "line",
"options": {
"responsive": True,
"plugins": {"legend": {"position": "bottom"}},
"scales": {"y": {"title": {"display": True, "text": "提及量"}}},
},
},
"data": {
"labels": ["T0", "T0+6h", "T0+12h", "T0+18h", "T0+24h"],
"datasets": [
{
"label": "主流媒体",
"data": [12, 18, 23, 30, 26],
"borderColor": "#2980b9",
"backgroundColor": "rgba(41,128,185,0.18)",
"tension": 0.25,
"fill": False,
},
{
"label": "社交平台",
"data": [8, 10, 15, 28, 40],
"borderColor": "#c0392b",
"backgroundColor": "rgba(192,57,43,0.2)",
"tension": 0.35,
"fill": False,
},
],
},
}
def build_chapters() -> list[dict]:
"""构造覆盖所有 block 类型的章节列表。"""
inline_demo = build_inline_marks_demo()
bullet_list = {
"type": "list",
"listType": "bullet",
"items": [
[
{
"type": "paragraph",
"inlines": [{"text": "社交媒体热度在 48 小时内翻倍"}],
}
],
[
{
"type": "paragraph",
"inlines": [{"text": "主流媒体报道集中在早间时段"}],
},
{
"type": "list",
"listType": "ordered",
"items": [
[
{
"type": "paragraph",
"inlines": [{"text": "07:00-09:00:首轮报道"}],
}
],
[
{
"type": "paragraph",
"inlines": [{"text": "10:00-12:00:评论扩散"}],
}
],
],
},
],
[
{
"type": "paragraph",
"inlines": [{"text": "地方政务号开始回应并同步线下通稿"}],
}
],
],
}
task_list = {
"type": "list",
"listType": "task",
"items": [
[
{
"type": "paragraph",
"inlines": [{"text": "跟踪权威辟谣素材是否上线"}],
}
],
[
{
"type": "paragraph",
"inlines": [{"text": "监测新增关联关键词与长尾问题"}],
}
],
[
{
"type": "paragraph",
"inlines": [{"text": "准备 FAQ 供客服统一答复"}],
}
],
],
}
table_block = {
"type": "table",
"caption": "核心信源与传播路径",
"zebra": True,
"colgroup": [{"width": "22%"}, {"width": "38%"}, {"width": "40%"}],
"rows": [
{
"cells": [
{
"align": "center",
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "时间节点", "marks": [{"type": "bold"}]}],
}
],
},
{
"align": "center",
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "事件内容", "marks": [{"type": "bold"}]}],
}
],
},
{
"align": "center",
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "主要渠道", "marks": [{"type": "bold"}]}],
}
],
},
]
},
{
"cells": [
{"blocks": [{"type": "paragraph", "inlines": [{"text": "T0"}]}]},
{
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "线下冲突视频首次上传"}],
}
]
},
{
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "短视频平台 / 私聊转发"}],
}
]
},
]
},
{
"cells": [
{"blocks": [{"type": "paragraph", "inlines": [{"text": "T0+6h"}]}]},
{
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "登上热搜,出现二次剪辑"}],
}
]
},
{
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "微博 / 朋友圈"}],
}
]
},
]
},
{
"cells": [
{"blocks": [{"type": "paragraph", "inlines": [{"text": "T0+18h"}]}]},
{
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "官方回应并发布事实澄清"}],
}
]
},
{
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "政务号 / 新闻客户端"}],
}
]
},
]
},
{
"cells": [
{"blocks": [{"type": "paragraph", "inlines": [{"text": "T0+24h"}]}]},
{
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "专家解读,舆论重心转向责任归属"}],
}
]
},
{
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "视频号直播 / 行业社群"}],
}
]
},
]
},
],
}
blockquote_block = {
"type": "blockquote",
"variant": "accent",
"blocks": [
{
"type": "paragraph",
"inlines": [{"text": "“公众最关心的信息是真相与责任边界。”"}],
},
{
"type": "paragraph",
"inlines": [{"text": "—— 模拟引用,验证引用块样式"}],
},
],
}
engine_quote_block = {
"type": "engineQuote",
"engine": "insight",
"title": ENGINE_AGENT_TITLES["insight"],
"blocks": [
{
"type": "paragraph",
"inlines": [
{
"text": "模型认为 24 小时内保持回应频次,可避免信息真空。",
"marks": [{"type": "bold"}],
}
],
},
{
"type": "paragraph",
"inlines": [
{"text": "建议同时准备简短 FAQ,便于多渠道统一口径。"}
],
},
],
}
swot_block = {
"type": "swotTable",
"title": "舆论场 SWOT 速览",
"summary": "覆盖当前情绪分布、潜在风险与机会。",
"strengths": [
{"title": "官方快速响应", "detail": "首条澄清视频 3 小时内上线"},
{"title": "同城媒体配合", "impact": "高", "score": 8},
],
"weaknesses": [
{"title": "早期谣言存量大", "detail": "相关转发仍占 30%"},
"外部专家尚未统一口径",
],
"opportunities": [
{
"title": "社区共建讨论",
"detail": "自发组织“辟谣志愿者”话题,情绪正向",
},
{"title": "公益合作窗口", "impact": "中"},
],
"threats": [
{"title": "跨平台剪辑继续发酵", "impact": "高", "score": 9},
{"title": "个别自媒体煽动情绪", "evidence": "存在地域标签化倾向"},
],
}
pest_block = {
"type": "pestTable",
"title": "宏观环境脉冲扫描(PEST)",
"summary": "模拟四大维度的外部约束与机会,验证 pestTable 的渲染样式。",
"political": [
{
"title": "地方条例征求意见",
"detail": "短视频发布需实名溯源,平台合规沟通窗口期开启",
"trend": "正面利好",
"impact": 7,
},
{
"title": "监管关注情绪煽动",
"detail": "对夸大矛盾的账号重点巡查,舆论阈值下调",
"trend": "持续观察",
"impact": 6,
},
],
"economic": [
{
"title": "周边商户营收波动",
"detail": "客流短期下滑 12%,但直播带货订单上升",
"trend": "中性",
"impact": 5,
},
{
"title": "品牌赞助谨慎",
"detail": "赞助延期观察声誉风险,对官宣节奏有压力",
"trend": "不确定",
"impact": 4,
},
],
"social": [
{
"title": "核心群体情绪分化",
"detail": "本地居民关注安全,外地游客关注体验与退款",
"trend": "负面影响",
"impact": 8,
},
{
"title": "高校社群自发求证",
"detail": "校媒与学生会组织“以图搜图”科普贴,情绪趋稳",
"trend": "正面利好",
"impact": 6,
},
],
"technological": [
{
"title": "AI 生成内容被混入",
"detail": "局部画面被放大后再传播,需水印溯源工具辅助鉴伪",
"trend": "负面影响",
"impact": 7,
},
{
"title": "多模态检索上线",
"detail": "平台试行“视频反诈”模型,自动提示剪辑痕迹",
"trend": "正面利好",
"impact": 5,
},
],
}
callout_block = {
"type": "callout",
"tone": "warning",
"title": "排版边界提示",
"blocks": [
{
"type": "paragraph",
"inlines": [
{"text": "callout 内部仅放轻量内容,超出部分会自动溢出到外层。"}
],
},
{
"type": "list",
"listType": "bullet",
"items": [
[
{
"type": "paragraph",
"inlines": [{"text": "支持嵌套列表 / 表格 / 数学公式"}],
}
],
[
{
"type": "paragraph",
"inlines": [{"text": "可在这里放置提醒或操作步骤"}],
}
],
],
},
],
}
code_block = {
"type": "code",
"lang": "json",
"caption": "演示代码块",
"content": '{\n "event": "热点示例",\n "topic": "公共事件",\n "status": "monitoring"\n}',
}
math_block = {
"type": "math",
"latex": r"E = mc^2",
"displayMode": True,
}
figure_block = {
"type": "figure",
"img": {
"src": "https://dummyimage.com/600x320/eeeeee/333333&text=Placeholder",
"alt": "占位示意图",
"width": 600,
"height": 320,
},
"caption": "图像外链被替换为友好提示,可验证 figure 占位效果。",
"responsive": True,
}
widget_block = build_widget_block()
stacked_bar_chart_block = {
"type": "widget",
"widgetId": "demo-stacked-sentiment",
"widgetType": "chart.js/bar",
"props": {
"type": "bar",
"options": {
"responsive": True,
"plugins": {"legend": {"position": "bottom"}},
"scales": {
"x": {"stacked": True},
"y": {"stacked": True, "title": {"display": True, "text": "信息量"}},
},
},
},
"data": {
"labels": ["周一", "周二", "周三", "周四", "周五"],
"datasets": [
{"label": "正向", "data": [18, 22, 24, 19, 16], "backgroundColor": "#27ae60"},
{"label": "中性", "data": [22, 20, 18, 21, 23], "backgroundColor": "#f39c12"},
{"label": "负向", "data": [12, 14, 10, 9, 11], "backgroundColor": "#c0392b"},
],
},
}
doughnut_chart_block = {
"type": "widget",
"widgetId": "demo-sentiment-share",
"widgetType": "chart.js/doughnut",
"props": {
"type": "doughnut",
"options": {"plugins": {"legend": {"position": "right"}, "tooltip": {"enabled": True}}},
},
"data": {
"labels": ["政策", "经济", "社会", "技术"],
"datasets": [
{
"label": "关注度占比",
"data": [24, 30, 28, 18],
"backgroundColor": ["#8e44ad", "#16a085", "#e67e22", "#2980b9"],
"hoverOffset": 6,
}
],
},
}
radar_chart_block = {
"type": "widget",
"widgetId": "demo-response-radar",
"widgetType": "chart.js/radar",
"props": {
"type": "radar",
"options": {
"plugins": {"legend": {"position": "top"}},
"scales": {"r": {"beginAtZero": True, "max": 100}},
},
},
"data": {
"labels": ["透明度", "响应速度", "一致性", "互动度", "信息量"],
"datasets": [
{
"label": "官方渠道",
"data": [78, 88, 82, 66, 91],
"backgroundColor": "rgba(46,204,113,0.15)",
"borderColor": "#2ecc71",
"pointBackgroundColor": "#27ae60",
},
{
"label": "民间讨论",
"data": [64, 72, 58, 74, 63],
"backgroundColor": "rgba(52,152,219,0.12)",
"borderColor": "#3498db",
"pointBackgroundColor": "#2980b9",
},
],
},
}
polar_area_chart_block = {
"type": "widget",
"widgetId": "demo-channel-polar",
"widgetType": "chart.js/polarArea",
"props": {"type": "polarArea"},
"data": {
"labels": ["短视频", "微博", "社区论坛", "新闻客户端", "线下反馈"],
"datasets": [
{
"label": "渠道渗透度",
"data": [62, 54, 38, 45, 28],
"backgroundColor": [
"rgba(231,76,60,0.65)",
"rgba(142,68,173,0.6)",
"rgba(52,152,219,0.55)",
"rgba(46,204,113,0.55)",
"rgba(241,196,15,0.6)",
],
}
],
},
}
scatter_chart_block = {
"type": "widget",
"widgetId": "demo-correlation-scatter",
"widgetType": "chart.js/scatter",
"props": {
"type": "scatter",
"options": {
"plugins": {"legend": {"position": "bottom"}},
"scales": {
"x": {"title": {"display": True, "text": "情绪极性"}, "min": -1, "max": 1},
"y": {"title": {"display": True, "text": "互动量"}, "beginAtZero": True},
},
},
},
"data": {
"datasets": [
{
"label": "帖子散点",
"data": [
{"x": -0.65, "y": 120},
{"x": -0.25, "y": 190},
{"x": 0.05, "y": 260},
{"x": 0.42, "y": 340},
{"x": 0.78, "y": 410},
],
"backgroundColor": "rgba(52,152,219,0.7)",
}
],
},
}
bubble_chart_block = {
"type": "widget",
"widgetId": "demo-impact-bubble",
"widgetType": "chart.js/bubble",
"props": {
"type": "bubble",
"options": {
"plugins": {"legend": {"position": "bottom"}},
"scales": {
"x": {"title": {"display": True, "text": "曝光量 (万)"}, "beginAtZero": True},
"y": {"title": {"display": True, "text": "情绪强度"}, "min": -100, "max": 100},
},
},
},
"data": {
"datasets": [
{
"label": "渠道分布",
"data": [
{"x": 8, "y": 35, "r": 12},
{"x": 12, "y": -28, "r": 10},
{"x": 18, "y": 22, "r": 14},
{"x": 25, "y": 48, "r": 16},
{"x": 6, "y": -12, "r": 8},
],
"backgroundColor": "rgba(192,57,43,0.55)",
"borderColor": "#c0392b",
}
],
},
}
chapter_1 = {
"chapterId": "S1",
"title": "封面与目录",
"anchor": "overview",
"order": 10,
"blocks": [
{"type": "heading", "level": 2, "text": "一、封面与目录", "anchor": "overview"},
{
"type": "paragraph",
"inlines": [
{
"text": "模拟社会公共热点事件的摘要,便于快速确认排版与字体效果。",
}
],
},
inline_demo,
{
"type": "kpiGrid",
"items": [
{"label": "24h提及量", "value": "98K", "delta": "+41%", "deltaTone": "up"},
{"label": "正向占比", "value": "32%", "delta": "+5pp", "deltaTone": "up"},
{"label": "负向占比", "value": "18%", "delta": "-3pp", "deltaTone": "down"},
{"label": "高频渠道", "value": "短视频 / 微博"},
],
"cols": 4,
},
{"type": "toc"},
{"type": "hr"},
],
}
chapter_2 = {
"chapterId": "S2",
"title": "块类型演示",
"anchor": "blocks-showcase",
"order": 20,
"blocks": [
{
"type": "heading",
"level": 2,
"text": "二、块类型演示",
"anchor": "blocks-showcase",
},
{
"type": "paragraph",
"inlines": [
{
"text": "以下内容逐一覆盖 paragraph/list/table/swot/pest/widget 等全部块类型。",
}
],
},
{
"type": "heading",
"level": 3,
"text": "2.1 列表与表格",
"anchor": "lists-and-tables",
},
bullet_list,
task_list,
table_block,
{
"type": "heading",
"level": 3,
"text": "2.2 图表组件演示",
"anchor": "charts-demo",
},
{
"type": "paragraph",
"inlines": [
{
"text": "折线/柱状/饼图/雷达/极区/散点/气泡等多类型图表,用于验证 Chart.js 兼容性。",
}
],
},
widget_block,
stacked_bar_chart_block,
doughnut_chart_block,
radar_chart_block,
polar_area_chart_block,
scatter_chart_block,
bubble_chart_block,
{
"type": "heading",
"level": 3,
"text": "2.3 高阶块与富媒体",
"anchor": "advanced-blocks",
},
blockquote_block,
callout_block,
engine_quote_block,
swot_block,
pest_block,
code_block,
math_block,
figure_block,
{
"type": "hr",
"variant": "dashed",
},
{
"type": "paragraph",
"align": "justify",
"inlines": [
{
"text": "本章节的 inline math 兜底验证:",
},
{"text": "p(t)=p_0 e^{\\lambda t}", "marks": [{"type": "math"}]},
{"text": ";以上覆盖所有允许块及标记。"},
],
},
],
}
return [chapter_1, chapter_2]
def validate_chapters(chapters: list[dict]) -> None:
"""使用 IRValidator 校验章节结构,发现错误时抛出异常。"""
validator = IRValidator()
for chapter in chapters:
ok, errors = validator.validate_chapter(chapter)
if not ok:
raise ValueError(f"{chapter.get('chapterId', 'unknown')} 校验失败: {errors}")
def render_and_save(document_ir: dict, timestamp: str) -> tuple[Path, Path, Path]:
"""将 IR 保存为 JSON,并渲染 HTML / PDF,返回三个路径。"""
ir_dir = Path(settings.DOCUMENT_IR_OUTPUT_DIR)
html_dir = Path(settings.OUTPUT_DIR) / "html"
pdf_dir = Path(settings.OUTPUT_DIR) / "pdf"
ir_dir.mkdir(parents=True, exist_ok=True)
html_dir.mkdir(parents=True, exist_ok=True)
pdf_dir.mkdir(parents=True, exist_ok=True)
ir_path = ir_dir / f"report_ir_all_blocks_demo_{timestamp}.json"
ir_path.write_text(json.dumps(document_ir, ensure_ascii=False, indent=2), encoding="utf-8")
html_renderer = HTMLRenderer()
html_content = html_renderer.render(document_ir)
html_path = html_dir / f"report_html_all_blocks_demo_{timestamp}.html"
html_path.write_text(html_content, encoding="utf-8")
pdf_renderer = PDFRenderer()
pdf_path = pdf_dir / f"report_pdf_all_blocks_demo_{timestamp}.pdf"
pdf_renderer.render_to_pdf(document_ir, pdf_path)
return ir_path, html_path, pdf_path
def main() -> int:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_id = f"all-blocks-demo-{timestamp}"
metadata = {
"title": "社会公共热点事件渲染测试",
"subtitle": "覆盖全部 IR 块类型的示例数据,含多种图表与 PEST 演示",
"query": "公共事件渲染能力自检 / Chart & PEST",
"toc": {"title": "目录", "depth": 3},
"hero": {
"summary": "用于验证 Report Engine 在 HTML / PDF 渲染时对各类区块、Chart.js 组件与 PEST 模块的兼容性。",
"kpis": [
{"label": "示例块数量", "value": "20+", "delta": "含 PEST", "tone": "up"},
{"label": "图表数", "value": "7", "delta": "新增多类型", "tone": "neutral"},
],
"highlights": ["覆盖全部 block", "含行内/块级公式", "Chart.js 多类型", "PEST + SWOT"],
"actions": ["重新生成", "导出 PDF"],
},
}
chapters = build_chapters()
validate_chapters(chapters)
composer = DocumentComposer()
document_ir = composer.build_document(report_id, metadata, chapters)
ir_path, html_path, pdf_path = render_and_save(document_ir, timestamp)
print("✅ 演示 IR 生成完成")
print(f"IR: {ir_path}")
print(f"HTML: {html_path}")
print(f"PDF: {pdf_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
... ...
ISC License
Copyright(c)2024,Xiumuzaidiao
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
... ...
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>白天黑夜切换按钮</title>
<style>
body{
transition: 0.5s;
}
</style>
</head>
<body>
<theme-button value="dark" id="btn" size="3"></theme-button>
<script>
document.body.style.backgroundColor = "#424242";
btn.addEventListener("change", e => {
if (e.detail === 'dark') {
document.body.style.backgroundColor = "#424242";
}
else {
document.body.style.backgroundColor = "aliceblue";
}
});
</script>
<script src="js/script.js"></script>
</body>
</html>
... ...
(() => {
const func = (root, initTheme, changeTheme) => {
const $ = (s) => {
let dom = root.querySelectorAll(s);
return dom.length == 1 ? dom[0] : dom;
};
let mainButton = $(".main-button");
let daytimeBackground = $(".daytime-background");
let cloud = $(".cloud");
let cloudList = $(".cloud-son");
let cloudLight = $(".cloud-light");
let components = $(".components");
let moon = $(".moon");
let stars = $(".stars");
let star = $(".star");
let isMoved = false;
let isClicked = false;
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
toggleThemeBasedOnSystem();
});
const toggleThemeBasedOnSystem = () => {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
if (!isMoved) {
components.onclick();
}
} else {
if (isMoved) {
components.onclick();
}
}
};
components.onclick = () => {
if (isMoved) {
mainButton.style.transform = "translateX(0)";
mainButton.style.backgroundColor = "rgba(255, 195, 35,1)";
mainButton.style.boxShadow =
"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)";
daytimeBackground[0].style.transform = "translateX(0)";
daytimeBackground[1].style.transform = "translateX(0)";
daytimeBackground[2].style.transform = "translateX(0)";
cloud.style.transform = "translateY(10em)";
cloudLight.style.transform = "translateY(10em)";
components.style.backgroundColor = "rgba(70, 133, 192,1)";
moon[0].style.opacity = "0";
moon[1].style.opacity = "0";
moon[2].style.opacity = "0";
stars.style.transform = "translateY(-125em)";
stars.style.opacity = "0";
changeTheme("light");
} else {
mainButton.style.transform = "translateX(110em)";
mainButton.style.backgroundColor = "rgba(195, 200,210,1)";
mainButton.style.boxShadow =
"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)";
daytimeBackground[0].style.transform = "translateX(110em)";
daytimeBackground[1].style.transform = "translateX(80em)";
daytimeBackground[2].style.transform = "translateX(50em)";
cloud.style.transform = "translateY(80em)";
cloudLight.style.transform = "translateY(80em)";
components.style.backgroundColor = "rgba(25,30,50,1)";
moon[0].style.opacity = "1";
moon[1].style.opacity = "1";
moon[2].style.opacity = "1";
stars.style.transform = "translateY(-62.5em)";
stars.style.opacity = "1";
changeTheme("dark");
}
isClicked = true;
setTimeout(function () {
isClicked = false;
}, 500);
isMoved = !isMoved;
};
mainButton.addEventListener("mousemove", function () {
if (isClicked) return;
if (isMoved) {
mainButton.style.transform = "translateX(100em)";
daytimeBackground[0].style.transform = "translateX(100em)";
daytimeBackground[1].style.transform = "translateX(73em)";
daytimeBackground[2].style.transform = "translateX(46em)";
star[0].style.top = "10em";
star[0].style.left = "36em";
star[1].style.top = "40em";
star[1].style.left = "87em";
star[2].style.top = "26em";
star[2].style.left = "16em";
star[3].style.top = "38em";
star[3].style.left = "63em";
star[4].style.top = "20.5em";
star[4].style.left = "72em";
star[5].style.top = "51.5em";
star[5].style.left = "35em";
} else {
mainButton.style.transform = "translateX(10em)";
daytimeBackground[0].style.transform = "translateX(10em)";
daytimeBackground[1].style.transform = "translateX(7em)";
daytimeBackground[2].style.transform = "translateX(4em)";
cloudList[0].style.right = "-24em";
cloudList[0].style.bottom = "10em";
cloudList[1].style.right = "-12em";
cloudList[1].style.bottom = "-27em";
cloudList[2].style.right = "17em";
cloudList[2].style.bottom = "-43em";
cloudList[3].style.right = "46em";
cloudList[3].style.bottom = "-39em";
cloudList[4].style.right = "70em";
cloudList[4].style.bottom = "-65em";
cloudList[5].style.right = "109em";
cloudList[5].style.bottom = "-54em";
cloudList[6].style.right = "-23em";
cloudList[6].style.bottom = "10em";
cloudList[7].style.right = "-11em";
cloudList[7].style.bottom = "-26em";
cloudList[8].style.right = "18em";
cloudList[8].style.bottom = "-42em";
cloudList[9].style.right = "47em";
cloudList[9].style.bottom = "-38em";
cloudList[10].style.right = "74em";
cloudList[10].style.bottom = "-64em";
cloudList[11].style.right = "110em";
cloudList[11].style.bottom = "-55em";
}
});
mainButton.addEventListener("mouseout", function () {
if (isClicked) {
return;
}
if (isMoved) {
mainButton.style.transform = "translateX(110em)";
daytimeBackground[0].style.transform = "translateX(110em)";
daytimeBackground[1].style.transform = "translateX(80em)";
daytimeBackground[2].style.transform = "translateX(50em)";
star[0].style.top = "11em";
star[0].style.left = "39em";
star[1].style.top = "39em";
star[1].style.left = "91em";
star[2].style.top = "26em";
star[2].style.left = "19em";
star[3].style.top = "37em";
star[3].style.left = "66em";
star[4].style.top = "21em";
star[4].style.left = "75em";
star[5].style.top = "51em";
star[5].style.left = "38em";
} else {
mainButton.style.transform = "translateX(0em)";
daytimeBackground[0].style.transform = "translateX(0em)";
daytimeBackground[1].style.transform = "translateX(0em)";
daytimeBackground[2].style.transform = "translateX(0em)";
cloudList[0].style.right = "-20em";
cloudList[0].style.bottom = "10em";
cloudList[1].style.right = "-10em";
cloudList[1].style.bottom = "-25em";
cloudList[2].style.right = "20em";
cloudList[2].style.bottom = "-40em";
cloudList[3].style.right = "50em";
cloudList[3].style.bottom = "-35em";
cloudList[4].style.right = "75em";
cloudList[4].style.bottom = "-60em";
cloudList[5].style.right = "110em";
cloudList[5].style.bottom = "-50em";
cloudList[6].style.right = "-20em";
cloudList[6].style.bottom = "10em";
cloudList[7].style.right = "-10em";
cloudList[7].style.bottom = "-25em";
cloudList[8].style.right = "20em";
cloudList[8].style.bottom = "-40em";
cloudList[9].style.right = "50em";
cloudList[9].style.bottom = "-35em";
cloudList[10].style.right = "75em";
cloudList[10].style.bottom = "-60em";
cloudList[11].style.right = "110em";
cloudList[11].style.bottom = "-50em";
}
});
const getRandomDirection = () => {
const directions = ["2em", "-2em"];
return directions[Math.floor(Math.random() * directions.length)];
};
const moveElementRandomly = (element) => {
const randomDirectionX = getRandomDirection();
const randomDirectionY = getRandomDirection();
element.style.transform = `translate(${randomDirectionX}, ${randomDirectionY})`;
};
const cloudSons = root.querySelectorAll(".cloud-son");
setInterval(() => {
cloudSons.forEach(moveElementRandomly);
}, 1000);
if (initTheme === "dark") {
components.onclick();
}
};
class ThemeButton extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const initTheme = this.getAttribute("value") || "light";
const size = +this.getAttribute("size") || 3;
const shadow = this.attachShadow({ mode: "closed" });
const container = document.createElement("div");
container.setAttribute("class", "container");
container.setAttribute("style", `font-size: ${(size / 3).toFixed(2)}px`);
container.innerHTML =
'<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>';
const style = document.createElement("style");
style.textContent =
"* { 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); }";
const changeTheme = (detail) => {
this.dispatchEvent(new CustomEvent("change", { detail }));
};
func(container, initTheme, changeTheme);
shadow.appendChild(style);
shadow.appendChild(container);
}
}
customElements.define("theme-button", ThemeButton);
})();
... ...