licos-dev-sdk 0.3.2__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/PKG-INFO +1 -1
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/pyproject.toml +1 -1
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/__init__.py +3 -1
- licos_dev_sdk-0.3.4/src/licos_dev_sdk/chart.py +114 -0
- licos_dev_sdk-0.3.4/src/licos_dev_sdk/presentation.py +645 -0
- licos_dev_sdk-0.3.4/src/licos_dev_sdk/presentation_inspect.py +107 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/tests/test_document_spreadsheet.py +28 -1
- licos_dev_sdk-0.3.4/tests/test_presentation.py +132 -0
- licos_dev_sdk-0.3.2/src/licos_dev_sdk/chart.py +0 -80
- licos_dev_sdk-0.3.2/src/licos_dev_sdk/presentation.py +0 -103
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/.gitignore +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/_utils.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/archive.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/data.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/diagram.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/document.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/document_common.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/document_docx.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/document_docx_layout.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/image.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/model.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/observability.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/pdf_inspect.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/spreadsheet.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/web.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/tests/test_model.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/tests/test_observability.py +0 -0
- {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/tests/test_output_paths.py +0 -0
|
@@ -39,6 +39,8 @@ def __getattr__(name: str):
|
|
|
39
39
|
"create_diagram": ("diagram", "create_diagram"),
|
|
40
40
|
# presentation
|
|
41
41
|
"create_pptx": ("presentation", "create_pptx"),
|
|
42
|
+
"create_pptx_deck": ("presentation", "create_pptx_deck"),
|
|
43
|
+
"inspect_pptx": ("presentation_inspect", "inspect_pptx"),
|
|
42
44
|
# model catalog and model tools
|
|
43
45
|
"ModelRuntime": ("model", "ModelRuntime"),
|
|
44
46
|
"ModelEndpoint": ("model", "ModelEndpoint"),
|
|
@@ -96,7 +98,7 @@ __all__ = [
|
|
|
96
98
|
"create_xlsx", "create_xlsx_workbook", "create_csv", "inspect_xlsx",
|
|
97
99
|
"create_chart",
|
|
98
100
|
"create_diagram",
|
|
99
|
-
"create_pptx",
|
|
101
|
+
"create_pptx", "create_pptx_deck", "inspect_pptx",
|
|
100
102
|
"ModelRuntime", "ModelEndpoint", "ModelResult",
|
|
101
103
|
"ApiError", "ConfigurationError",
|
|
102
104
|
"LLMClient", "create_chat_openai", "VisionClient", "VisionUnderstandingClient", "ImageGenerationClient", "VideoGenerationClient",
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Chart generation — bar, line, pie, scatter, heatmap via matplotlib."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
|
|
7
|
+
import matplotlib
|
|
8
|
+
matplotlib.use("Agg") # Non-interactive backend
|
|
9
|
+
from matplotlib import font_manager
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ._utils import resolve_output_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_CJK_FONT_CANDIDATES = (
|
|
17
|
+
"Noto Sans CJK SC",
|
|
18
|
+
"Noto Sans SC",
|
|
19
|
+
"Microsoft YaHei",
|
|
20
|
+
"SimHei",
|
|
21
|
+
"WenQuanYi Zen Hei",
|
|
22
|
+
"Source Han Sans CN",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache(maxsize=1)
|
|
27
|
+
def _resolve_cjk_font() -> str:
|
|
28
|
+
installed_fonts = {font.name.casefold(): font.name for font in font_manager.fontManager.ttflist}
|
|
29
|
+
for candidate in _CJK_FONT_CANDIDATES:
|
|
30
|
+
installed_name = installed_fonts.get(candidate.casefold())
|
|
31
|
+
if installed_name:
|
|
32
|
+
return installed_name
|
|
33
|
+
choices = ", ".join(_CJK_FONT_CANDIDATES)
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"Chinese chart font is unavailable. Install a CJK font before generating charts "
|
|
36
|
+
f"(supported families: {choices})."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_chart(chart_type: str, data: dict, filename: str, *,
|
|
41
|
+
output_dir: str | None = None, format: str = "png",
|
|
42
|
+
title: str = "", width: int = 800, height: int = 600) -> str:
|
|
43
|
+
"""Generate a chart image (PNG/SVG). Returns absolute path.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
chart_type: "bar" | "line" | "pie" | "scatter" | "heatmap"
|
|
47
|
+
data: {
|
|
48
|
+
"labels": ["A", "B", "C"],
|
|
49
|
+
"datasets": [{"label": "Series 1", "values": [10, 20, 30]}]
|
|
50
|
+
}
|
|
51
|
+
For pie: only first dataset is used.
|
|
52
|
+
For heatmap: data["matrix"] = [[1,2],[3,4]], data["x_labels"], data["y_labels"]
|
|
53
|
+
"""
|
|
54
|
+
path = resolve_output_path(filename, f".{format}", output_dir)
|
|
55
|
+
dpi = 100
|
|
56
|
+
font_name = _resolve_cjk_font()
|
|
57
|
+
chart_style = {
|
|
58
|
+
"font.family": "sans-serif",
|
|
59
|
+
"font.sans-serif": [font_name],
|
|
60
|
+
"axes.unicode_minus": False,
|
|
61
|
+
}
|
|
62
|
+
with matplotlib.rc_context(chart_style):
|
|
63
|
+
fig, ax = plt.subplots(figsize=(width / dpi, height / dpi), dpi=dpi)
|
|
64
|
+
|
|
65
|
+
labels = data.get("labels", [])
|
|
66
|
+
datasets = data.get("datasets", [])
|
|
67
|
+
|
|
68
|
+
if chart_type == "bar":
|
|
69
|
+
x = np.arange(len(labels))
|
|
70
|
+
n = len(datasets)
|
|
71
|
+
bar_width = 0.8 / max(n, 1)
|
|
72
|
+
for i, ds in enumerate(datasets):
|
|
73
|
+
offset = (i - n / 2 + 0.5) * bar_width
|
|
74
|
+
ax.bar(x + offset, ds["values"], bar_width, label=ds.get("label", ""))
|
|
75
|
+
ax.set_xticks(x)
|
|
76
|
+
ax.set_xticklabels(labels)
|
|
77
|
+
if n > 1:
|
|
78
|
+
ax.legend()
|
|
79
|
+
|
|
80
|
+
elif chart_type == "line":
|
|
81
|
+
for ds in datasets:
|
|
82
|
+
ax.plot(labels, ds["values"], marker="o", label=ds.get("label", ""))
|
|
83
|
+
if len(datasets) > 1:
|
|
84
|
+
ax.legend()
|
|
85
|
+
|
|
86
|
+
elif chart_type == "pie":
|
|
87
|
+
values = datasets[0]["values"] if datasets else []
|
|
88
|
+
ax.pie(values, labels=labels, autopct="%1.1f%%", startangle=90)
|
|
89
|
+
ax.axis("equal")
|
|
90
|
+
|
|
91
|
+
elif chart_type == "scatter":
|
|
92
|
+
for ds in datasets:
|
|
93
|
+
x_vals = ds.get("x", list(range(len(ds["values"]))))
|
|
94
|
+
ax.scatter(x_vals, ds["values"], label=ds.get("label", ""), alpha=0.7)
|
|
95
|
+
if len(datasets) > 1:
|
|
96
|
+
ax.legend()
|
|
97
|
+
|
|
98
|
+
elif chart_type == "heatmap":
|
|
99
|
+
matrix = np.array(data.get("matrix", [[]]))
|
|
100
|
+
im = ax.imshow(matrix, cmap="YlOrRd", aspect="auto")
|
|
101
|
+
fig.colorbar(im, ax=ax)
|
|
102
|
+
if "x_labels" in data:
|
|
103
|
+
ax.set_xticks(range(len(data["x_labels"])))
|
|
104
|
+
ax.set_xticklabels(data["x_labels"])
|
|
105
|
+
if "y_labels" in data:
|
|
106
|
+
ax.set_yticks(range(len(data["y_labels"])))
|
|
107
|
+
ax.set_yticklabels(data["y_labels"])
|
|
108
|
+
|
|
109
|
+
if title:
|
|
110
|
+
ax.set_title(title)
|
|
111
|
+
fig.tight_layout()
|
|
112
|
+
fig.savefig(str(path), format=format, bbox_inches="tight")
|
|
113
|
+
plt.close(fig)
|
|
114
|
+
return str(path.resolve())
|
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""PowerPoint generation from Markdown or a structured presentation spec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from pptx import Presentation
|
|
12
|
+
from pptx.chart.data import ChartData
|
|
13
|
+
from pptx.dml.color import RGBColor
|
|
14
|
+
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
|
|
15
|
+
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
|
|
16
|
+
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
|
17
|
+
from pptx.util import Inches, Pt
|
|
18
|
+
|
|
19
|
+
from ._utils import resolve_output_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_SLIDE_WIDTH = 13.333
|
|
23
|
+
_SLIDE_HEIGHT = 7.5
|
|
24
|
+
_FONT = "Microsoft YaHei"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class _Theme:
|
|
29
|
+
primary: str
|
|
30
|
+
secondary: str
|
|
31
|
+
accent: str
|
|
32
|
+
background: str
|
|
33
|
+
surface: str
|
|
34
|
+
text: str
|
|
35
|
+
muted: str
|
|
36
|
+
border: str
|
|
37
|
+
positive: str
|
|
38
|
+
negative: str
|
|
39
|
+
font: str = _FONT
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_THEMES: dict[str, _Theme] = {
|
|
43
|
+
"professional-blue": _Theme(
|
|
44
|
+
primary="16324F", secondary="2F6B8A", accent="20A39E",
|
|
45
|
+
background="F5F8FA", surface="FFFFFF", text="17212B",
|
|
46
|
+
muted="657786", border="D8E1E8", positive="2E8B57", negative="C84B4B",
|
|
47
|
+
),
|
|
48
|
+
"executive-dark": _Theme(
|
|
49
|
+
primary="17202A", secondary="34495E", accent="D5A021",
|
|
50
|
+
background="F4F5F6", surface="FFFFFF", text="17202A",
|
|
51
|
+
muted="66727D", border="D5D9DD", positive="2D936C", negative="C85555",
|
|
52
|
+
),
|
|
53
|
+
"industrial": _Theme(
|
|
54
|
+
primary="243746", secondary="3F6678", accent="E0A11B",
|
|
55
|
+
background="F3F5F4", surface="FFFFFF", text="1E2A32",
|
|
56
|
+
muted="66737B", border="D8DEDF", positive="39845B", negative="C75050",
|
|
57
|
+
),
|
|
58
|
+
"neutral": _Theme(
|
|
59
|
+
primary="30353B", secondary="66717C", accent="2E8B8B",
|
|
60
|
+
background="F6F6F5", surface="FFFFFF", text="24282C",
|
|
61
|
+
muted="747B82", border="DADDDD", positive="3A8555", negative="BE4C4C",
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_pptx(
|
|
67
|
+
content: str,
|
|
68
|
+
filename: str,
|
|
69
|
+
*,
|
|
70
|
+
content_type: str = "markdown",
|
|
71
|
+
output_dir: str | None = None,
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Generate a styled PPTX from Markdown while preserving the legacy API.
|
|
74
|
+
|
|
75
|
+
Slides are separated by a line containing ``---``. The first heading on a
|
|
76
|
+
slide is its title; remaining lines are rendered as body text or bullets.
|
|
77
|
+
"""
|
|
78
|
+
if content_type not in {"markdown", "html"}:
|
|
79
|
+
raise ValueError("content_type must be 'markdown' or 'html'")
|
|
80
|
+
if content_type == "html":
|
|
81
|
+
content = re.sub(r"<[^>]+>", "", content)
|
|
82
|
+
|
|
83
|
+
slides: list[dict[str, Any]] = []
|
|
84
|
+
for raw_slide in re.split(r"\n\s*---\s*(?:\n|$)", content.strip()):
|
|
85
|
+
lines = [line.rstrip() for line in raw_slide.splitlines() if line.strip()]
|
|
86
|
+
if not lines:
|
|
87
|
+
continue
|
|
88
|
+
title = ""
|
|
89
|
+
bullets: list[dict[str, Any]] = []
|
|
90
|
+
paragraphs: list[str] = []
|
|
91
|
+
for line in lines:
|
|
92
|
+
stripped = line.strip()
|
|
93
|
+
indent = len(line) - len(line.lstrip())
|
|
94
|
+
if not title and stripped.startswith("#"):
|
|
95
|
+
title = stripped.lstrip("#").strip()
|
|
96
|
+
elif stripped.startswith(("- ", "* ")):
|
|
97
|
+
bullets.append({"text": stripped[2:].strip(), "level": 1 if indent >= 2 else 0})
|
|
98
|
+
else:
|
|
99
|
+
paragraphs.append(stripped.lstrip("#").strip())
|
|
100
|
+
items: list[Any] = bullets or paragraphs
|
|
101
|
+
slides.append({"type": "bullets", "title": title or "演示内容", "items": items})
|
|
102
|
+
|
|
103
|
+
return create_pptx_deck(
|
|
104
|
+
{"theme": "professional-blue", "slides": slides},
|
|
105
|
+
filename,
|
|
106
|
+
output_dir=output_dir,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def create_pptx_deck(
|
|
111
|
+
spec: dict[str, Any],
|
|
112
|
+
filename: str,
|
|
113
|
+
*,
|
|
114
|
+
output_dir: str | None = None,
|
|
115
|
+
asset_base_path: str | None = None,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Generate a professional PPTX from a structured JSON-compatible spec.
|
|
118
|
+
|
|
119
|
+
Supported slide types are ``title``, ``section``, ``bullets``,
|
|
120
|
+
``two_column``, ``kpi``, ``table``, ``chart``, ``image``, ``quote``, and
|
|
121
|
+
``closing``. Unknown slide types fail fast instead of silently degrading.
|
|
122
|
+
"""
|
|
123
|
+
if not isinstance(spec, dict):
|
|
124
|
+
raise TypeError("presentation spec must be an object")
|
|
125
|
+
slides = spec.get("slides")
|
|
126
|
+
if not isinstance(slides, list) or not slides:
|
|
127
|
+
raise ValueError("presentation spec must contain a non-empty slides array")
|
|
128
|
+
|
|
129
|
+
theme = _resolve_theme(spec.get("theme", "professional-blue"))
|
|
130
|
+
path = resolve_output_path(filename, ".pptx", output_dir)
|
|
131
|
+
base_path = Path(asset_base_path).expanduser().resolve() if asset_base_path else Path.cwd()
|
|
132
|
+
|
|
133
|
+
prs = Presentation()
|
|
134
|
+
prs.slide_width = Inches(_SLIDE_WIDTH)
|
|
135
|
+
prs.slide_height = Inches(_SLIDE_HEIGHT)
|
|
136
|
+
prs.core_properties.title = str(spec.get("title", ""))
|
|
137
|
+
prs.core_properties.subject = str(spec.get("subject", ""))
|
|
138
|
+
prs.core_properties.author = str(spec.get("author", "LICOS AIOS"))
|
|
139
|
+
prs.core_properties.comments = "Generated by LICOS Dev SDK"
|
|
140
|
+
|
|
141
|
+
footer = str(spec.get("footer", "")).strip()
|
|
142
|
+
for index, slide_spec in enumerate(slides, start=1):
|
|
143
|
+
if not isinstance(slide_spec, dict):
|
|
144
|
+
raise TypeError(f"slides[{index - 1}] must be an object")
|
|
145
|
+
kind = str(slide_spec.get("type", slide_spec.get("layout", "bullets"))).lower()
|
|
146
|
+
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
147
|
+
if kind == "title":
|
|
148
|
+
_render_title_slide(slide, slide_spec, theme)
|
|
149
|
+
elif kind == "section":
|
|
150
|
+
_render_section_slide(slide, slide_spec, theme, index)
|
|
151
|
+
elif kind == "bullets":
|
|
152
|
+
_render_bullets_slide(slide, slide_spec, theme, base_path)
|
|
153
|
+
elif kind == "two_column":
|
|
154
|
+
_render_two_column_slide(slide, slide_spec, theme)
|
|
155
|
+
elif kind == "kpi":
|
|
156
|
+
_render_kpi_slide(slide, slide_spec, theme)
|
|
157
|
+
elif kind == "table":
|
|
158
|
+
_render_table_slide(slide, slide_spec, theme)
|
|
159
|
+
elif kind == "chart":
|
|
160
|
+
_render_chart_slide(slide, slide_spec, theme)
|
|
161
|
+
elif kind == "image":
|
|
162
|
+
_render_image_slide(slide, slide_spec, theme, base_path)
|
|
163
|
+
elif kind == "quote":
|
|
164
|
+
_render_quote_slide(slide, slide_spec, theme)
|
|
165
|
+
elif kind == "closing":
|
|
166
|
+
_render_closing_slide(slide, slide_spec, theme)
|
|
167
|
+
else:
|
|
168
|
+
raise ValueError(f"unsupported slide type at slides[{index - 1}]: {kind}")
|
|
169
|
+
|
|
170
|
+
if kind not in {"title", "closing"}:
|
|
171
|
+
_add_footer(slide, footer, index, theme)
|
|
172
|
+
|
|
173
|
+
prs.save(str(path))
|
|
174
|
+
return str(path.resolve())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_theme(raw: Any) -> _Theme:
|
|
178
|
+
if isinstance(raw, str):
|
|
179
|
+
try:
|
|
180
|
+
return _THEMES[raw]
|
|
181
|
+
except KeyError as exc:
|
|
182
|
+
raise ValueError(f"unknown presentation theme: {raw}") from exc
|
|
183
|
+
if not isinstance(raw, dict):
|
|
184
|
+
raise TypeError("theme must be a theme name or an object")
|
|
185
|
+
name = str(raw.get("name", "professional-blue"))
|
|
186
|
+
try:
|
|
187
|
+
base = _THEMES[name]
|
|
188
|
+
except KeyError as exc:
|
|
189
|
+
raise ValueError(f"unknown presentation theme: {name}") from exc
|
|
190
|
+
colors = raw.get("colors", {})
|
|
191
|
+
if not isinstance(colors, dict):
|
|
192
|
+
raise TypeError("theme.colors must be an object")
|
|
193
|
+
values = {field: getattr(base, field) for field in base.__dataclass_fields__}
|
|
194
|
+
for key, value in colors.items():
|
|
195
|
+
if key not in values or key == "font":
|
|
196
|
+
raise ValueError(f"unsupported theme color: {key}")
|
|
197
|
+
values[key] = _normalize_hex(value)
|
|
198
|
+
if raw.get("font"):
|
|
199
|
+
values["font"] = str(raw["font"])
|
|
200
|
+
return _Theme(**values)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _normalize_hex(value: Any) -> str:
|
|
204
|
+
text = str(value).strip().lstrip("#").upper()
|
|
205
|
+
if not re.fullmatch(r"[0-9A-F]{6}", text):
|
|
206
|
+
raise ValueError(f"invalid RGB color: {value}")
|
|
207
|
+
return text
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _rgb(value: str) -> RGBColor:
|
|
211
|
+
return RGBColor.from_string(value)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _set_background(slide: Any, color: str) -> None:
|
|
215
|
+
fill = slide.background.fill
|
|
216
|
+
fill.solid()
|
|
217
|
+
fill.fore_color.rgb = _rgb(color)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _add_rect(
|
|
221
|
+
slide: Any,
|
|
222
|
+
left: float,
|
|
223
|
+
top: float,
|
|
224
|
+
width: float,
|
|
225
|
+
height: float,
|
|
226
|
+
fill: str,
|
|
227
|
+
*,
|
|
228
|
+
radius: bool = False,
|
|
229
|
+
line: str | None = None,
|
|
230
|
+
) -> Any:
|
|
231
|
+
shape_type = MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE if radius else MSO_AUTO_SHAPE_TYPE.RECTANGLE
|
|
232
|
+
shape = slide.shapes.add_shape(shape_type, Inches(left), Inches(top), Inches(width), Inches(height))
|
|
233
|
+
shape.fill.solid()
|
|
234
|
+
shape.fill.fore_color.rgb = _rgb(fill)
|
|
235
|
+
if line:
|
|
236
|
+
shape.line.color.rgb = _rgb(line)
|
|
237
|
+
else:
|
|
238
|
+
shape.line.fill.background()
|
|
239
|
+
return shape
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _add_text(
|
|
243
|
+
slide: Any,
|
|
244
|
+
text: Any,
|
|
245
|
+
left: float,
|
|
246
|
+
top: float,
|
|
247
|
+
width: float,
|
|
248
|
+
height: float,
|
|
249
|
+
*,
|
|
250
|
+
size: float,
|
|
251
|
+
color: str,
|
|
252
|
+
theme: _Theme,
|
|
253
|
+
bold: bool = False,
|
|
254
|
+
align: PP_ALIGN = PP_ALIGN.LEFT,
|
|
255
|
+
valign: MSO_ANCHOR = MSO_ANCHOR.TOP,
|
|
256
|
+
name: str | None = None,
|
|
257
|
+
margin: float = 0,
|
|
258
|
+
) -> Any:
|
|
259
|
+
box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
|
|
260
|
+
if name:
|
|
261
|
+
box.name = name
|
|
262
|
+
frame = box.text_frame
|
|
263
|
+
frame.clear()
|
|
264
|
+
frame.word_wrap = True
|
|
265
|
+
frame.margin_left = Inches(margin)
|
|
266
|
+
frame.margin_right = Inches(margin)
|
|
267
|
+
frame.margin_top = Inches(margin)
|
|
268
|
+
frame.margin_bottom = Inches(margin)
|
|
269
|
+
frame.vertical_anchor = valign
|
|
270
|
+
paragraph = frame.paragraphs[0]
|
|
271
|
+
paragraph.text = str(text or "")
|
|
272
|
+
paragraph.alignment = align
|
|
273
|
+
paragraph.font.name = theme.font
|
|
274
|
+
paragraph.font.size = Pt(size)
|
|
275
|
+
paragraph.font.bold = bold
|
|
276
|
+
paragraph.font.color.rgb = _rgb(color)
|
|
277
|
+
return box
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _add_standard_header(slide: Any, spec: dict[str, Any], theme: _Theme) -> None:
|
|
281
|
+
_set_background(slide, theme.background)
|
|
282
|
+
_add_rect(slide, 0, 0, 0.13, _SLIDE_HEIGHT, theme.accent)
|
|
283
|
+
_add_text(
|
|
284
|
+
slide, spec.get("title", ""), 0.72, 0.42, 11.85, 0.62,
|
|
285
|
+
size=28, color=theme.primary, theme=theme, bold=True, name="LICOS Slide Title",
|
|
286
|
+
)
|
|
287
|
+
subtitle = str(spec.get("subtitle", "")).strip()
|
|
288
|
+
if subtitle:
|
|
289
|
+
_add_text(slide, subtitle, 0.75, 1.03, 11.7, 0.35, size=11, color=theme.muted, theme=theme)
|
|
290
|
+
_add_rect(slide, 0.74, 1.36, 11.83, 0.02, theme.border)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _add_footer(slide: Any, footer: str, page: int, theme: _Theme) -> None:
|
|
294
|
+
_add_rect(slide, 0.74, 7.03, 11.83, 0.01, theme.border)
|
|
295
|
+
if footer:
|
|
296
|
+
_add_text(
|
|
297
|
+
slide, footer, 0.75, 7.08, 10.5, 0.22,
|
|
298
|
+
size=8, color=theme.muted, theme=theme, name="LICOS Footer",
|
|
299
|
+
)
|
|
300
|
+
_add_text(
|
|
301
|
+
slide, f"{page:02d}", 11.85, 7.06, 0.7, 0.23,
|
|
302
|
+
size=8, color=theme.muted, theme=theme, align=PP_ALIGN.RIGHT,
|
|
303
|
+
name="LICOS Page Number",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _render_title_slide(slide: Any, spec: dict[str, Any], theme: _Theme) -> None:
|
|
308
|
+
_set_background(slide, theme.primary)
|
|
309
|
+
_add_rect(slide, 0, 0, _SLIDE_WIDTH, 0.12, theme.accent)
|
|
310
|
+
eyebrow = str(spec.get("eyebrow", "")).strip()
|
|
311
|
+
if eyebrow:
|
|
312
|
+
_add_text(slide, eyebrow.upper(), 0.9, 0.95, 10.8, 0.35, size=11, color=theme.accent, theme=theme, bold=True)
|
|
313
|
+
_add_text(
|
|
314
|
+
slide, spec.get("title", ""), 0.9, 1.45, 11.5, 1.65,
|
|
315
|
+
size=42, color="FFFFFF", theme=theme, bold=True, valign=MSO_ANCHOR.MIDDLE,
|
|
316
|
+
name="LICOS Slide Title",
|
|
317
|
+
)
|
|
318
|
+
subtitle = str(spec.get("subtitle", "")).strip()
|
|
319
|
+
if subtitle:
|
|
320
|
+
_add_text(slide, subtitle, 0.92, 3.35, 10.8, 0.9, size=19, color="DDE7ED", theme=theme)
|
|
321
|
+
meta = " | ".join(str(value) for value in (spec.get("author"), spec.get("date")) if value)
|
|
322
|
+
if meta:
|
|
323
|
+
_add_text(slide, meta, 0.92, 6.52, 10.8, 0.3, size=10, color="B9C8D2", theme=theme)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _render_section_slide(slide: Any, spec: dict[str, Any], theme: _Theme, index: int) -> None:
|
|
327
|
+
_set_background(slide, theme.background)
|
|
328
|
+
_add_rect(slide, 0, 0, 3.0, _SLIDE_HEIGHT, theme.primary)
|
|
329
|
+
_add_text(slide, f"{index:02d}", 0.65, 2.25, 1.75, 1.0, size=42, color=theme.accent, theme=theme, bold=True)
|
|
330
|
+
_add_text(
|
|
331
|
+
slide, spec.get("title", ""), 3.65, 2.15, 8.6, 1.25,
|
|
332
|
+
size=36, color=theme.primary, theme=theme, bold=True,
|
|
333
|
+
valign=MSO_ANCHOR.MIDDLE, name="LICOS Slide Title",
|
|
334
|
+
)
|
|
335
|
+
subtitle = str(spec.get("subtitle", "")).strip()
|
|
336
|
+
if subtitle:
|
|
337
|
+
_add_text(slide, subtitle, 3.68, 3.55, 8.3, 0.8, size=16, color=theme.muted, theme=theme)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _render_bullets_slide(slide: Any, spec: dict[str, Any], theme: _Theme, base_path: Path) -> None:
|
|
341
|
+
_add_standard_header(slide, spec, theme)
|
|
342
|
+
image_path = spec.get("image")
|
|
343
|
+
body_width = 7.0 if image_path else 11.45
|
|
344
|
+
items = spec.get("items", spec.get("bullets", []))
|
|
345
|
+
if not isinstance(items, list):
|
|
346
|
+
raise TypeError("bullets slide items must be an array")
|
|
347
|
+
_add_bullet_list(slide, items, 0.78, 1.65, body_width, 4.95, theme)
|
|
348
|
+
if image_path:
|
|
349
|
+
image = _resolve_asset(image_path, base_path)
|
|
350
|
+
_add_picture(slide, image, 8.2, 1.72, 4.35, 4.68, str(spec.get("image_fit", "contain")))
|
|
351
|
+
caption = str(spec.get("caption", "")).strip()
|
|
352
|
+
if caption:
|
|
353
|
+
_add_text(slide, caption, 8.25, 6.47, 4.2, 0.3, size=9, color=theme.muted, theme=theme, align=PP_ALIGN.CENTER)
|
|
354
|
+
_add_summary(slide, spec.get("summary"), theme)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _add_bullet_list(
|
|
358
|
+
slide: Any,
|
|
359
|
+
items: list[Any],
|
|
360
|
+
left: float,
|
|
361
|
+
top: float,
|
|
362
|
+
width: float,
|
|
363
|
+
height: float,
|
|
364
|
+
theme: _Theme,
|
|
365
|
+
) -> None:
|
|
366
|
+
box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
|
|
367
|
+
frame = box.text_frame
|
|
368
|
+
frame.clear()
|
|
369
|
+
frame.word_wrap = True
|
|
370
|
+
frame.margin_left = Inches(0.08)
|
|
371
|
+
frame.margin_right = Inches(0.08)
|
|
372
|
+
for index, raw in enumerate(items):
|
|
373
|
+
if isinstance(raw, dict):
|
|
374
|
+
text = str(raw.get("text", ""))
|
|
375
|
+
level = int(raw.get("level", 0))
|
|
376
|
+
emphasis = bool(raw.get("emphasis", False))
|
|
377
|
+
else:
|
|
378
|
+
text, level, emphasis = str(raw), 0, False
|
|
379
|
+
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
|
|
380
|
+
paragraph.text = f"{'◦' if level else '•'} {text}"
|
|
381
|
+
paragraph.level = min(max(level, 0), 2)
|
|
382
|
+
paragraph.font.name = theme.font
|
|
383
|
+
paragraph.font.size = Pt(17 if level == 0 else 14)
|
|
384
|
+
paragraph.font.bold = emphasis
|
|
385
|
+
paragraph.font.color.rgb = _rgb(theme.primary if emphasis else theme.text)
|
|
386
|
+
paragraph.space_after = Pt(10 if level == 0 else 6)
|
|
387
|
+
paragraph.line_spacing = 1.12
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _render_two_column_slide(slide: Any, spec: dict[str, Any], theme: _Theme) -> None:
|
|
391
|
+
_add_standard_header(slide, spec, theme)
|
|
392
|
+
columns = [spec.get("left", {}), spec.get("right", {})]
|
|
393
|
+
for index, column in enumerate(columns):
|
|
394
|
+
if not isinstance(column, dict):
|
|
395
|
+
raise TypeError("two_column left and right must be objects")
|
|
396
|
+
left = 0.75 + index * 6.05
|
|
397
|
+
_add_rect(slide, left, 1.65, 5.72, 4.95, theme.surface, radius=True, line=theme.border)
|
|
398
|
+
_add_rect(slide, left, 1.65, 0.08, 4.95, theme.accent if index == 0 else theme.secondary)
|
|
399
|
+
_add_text(slide, column.get("title", ""), left + 0.35, 1.93, 5.05, 0.5, size=19, color=theme.primary, theme=theme, bold=True)
|
|
400
|
+
items = column.get("items", [])
|
|
401
|
+
if not isinstance(items, list):
|
|
402
|
+
raise TypeError("two_column items must be arrays")
|
|
403
|
+
_add_bullet_list(slide, items, left + 0.3, 2.55, 5.05, 3.65, theme)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _render_kpi_slide(slide: Any, spec: dict[str, Any], theme: _Theme) -> None:
|
|
407
|
+
_add_standard_header(slide, spec, theme)
|
|
408
|
+
items = spec.get("items", [])
|
|
409
|
+
if not isinstance(items, list) or not items:
|
|
410
|
+
raise ValueError("kpi slide requires a non-empty items array")
|
|
411
|
+
if len(items) > 8:
|
|
412
|
+
raise ValueError("kpi slide supports at most 8 items")
|
|
413
|
+
columns = min(len(items), 4)
|
|
414
|
+
rows = 1 if len(items) <= 4 else 2
|
|
415
|
+
gap = 0.22
|
|
416
|
+
card_width = (11.8 - gap * (columns - 1)) / columns
|
|
417
|
+
card_height = 4.55 if rows == 1 else 2.15
|
|
418
|
+
for index, raw in enumerate(items):
|
|
419
|
+
if not isinstance(raw, dict):
|
|
420
|
+
raise TypeError("kpi items must be objects")
|
|
421
|
+
row, col = divmod(index, columns)
|
|
422
|
+
left = 0.75 + col * (card_width + gap)
|
|
423
|
+
top = 1.68 + row * (card_height + 0.25)
|
|
424
|
+
_add_rect(slide, left, top, card_width, card_height, theme.surface, radius=True, line=theme.border)
|
|
425
|
+
_add_rect(slide, left, top, card_width, 0.08, theme.accent if index % 2 == 0 else theme.secondary)
|
|
426
|
+
value_size = 32 if rows == 1 else 26
|
|
427
|
+
_add_text(slide, raw.get("value", ""), left + 0.25, top + 0.45, card_width - 0.5, 0.75, size=value_size, color=theme.primary, theme=theme, bold=True)
|
|
428
|
+
_add_text(slide, raw.get("label", ""), left + 0.25, top + 1.28, card_width - 0.5, 0.45, size=13, color=theme.text, theme=theme, bold=True)
|
|
429
|
+
change = str(raw.get("change", "")).strip()
|
|
430
|
+
if change:
|
|
431
|
+
change_color = theme.negative if change.startswith("-") else theme.positive
|
|
432
|
+
_add_text(slide, change, left + 0.25, top + 1.82, card_width - 0.5, 0.32, size=11, color=change_color, theme=theme, bold=True)
|
|
433
|
+
note = str(raw.get("note", "")).strip()
|
|
434
|
+
if note:
|
|
435
|
+
_add_text(slide, note, left + 0.25, top + card_height - 0.75, card_width - 0.5, 0.48, size=9, color=theme.muted, theme=theme)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _render_table_slide(slide: Any, spec: dict[str, Any], theme: _Theme) -> None:
|
|
439
|
+
_add_standard_header(slide, spec, theme)
|
|
440
|
+
columns = spec.get("columns", [])
|
|
441
|
+
rows = spec.get("rows", [])
|
|
442
|
+
if not isinstance(columns, list) or not columns:
|
|
443
|
+
raise ValueError("table slide requires a non-empty columns array")
|
|
444
|
+
if not isinstance(rows, list):
|
|
445
|
+
raise TypeError("table slide rows must be an array")
|
|
446
|
+
if len(columns) > 10 or len(rows) > 16:
|
|
447
|
+
raise ValueError("table slide supports at most 10 columns and 16 data rows")
|
|
448
|
+
table_height = 4.92 if not spec.get("note") else 4.4
|
|
449
|
+
table = slide.shapes.add_table(
|
|
450
|
+
len(rows) + 1, len(columns), Inches(0.75), Inches(1.65), Inches(11.8), Inches(table_height)
|
|
451
|
+
).table
|
|
452
|
+
widths = spec.get("column_widths")
|
|
453
|
+
if widths is not None:
|
|
454
|
+
if not isinstance(widths, list) or len(widths) != len(columns):
|
|
455
|
+
raise ValueError("column_widths length must match columns")
|
|
456
|
+
total = sum(float(value) for value in widths)
|
|
457
|
+
for index, value in enumerate(widths):
|
|
458
|
+
table.columns[index].width = Inches(11.8 * float(value) / total)
|
|
459
|
+
|
|
460
|
+
for col, value in enumerate(columns):
|
|
461
|
+
_style_table_cell(table.cell(0, col), value, theme.primary, "FFFFFF", theme, True)
|
|
462
|
+
for row_index, row in enumerate(rows, start=1):
|
|
463
|
+
values = row if isinstance(row, list) else [row.get(str(column), row.get(column, "")) for column in columns] if isinstance(row, dict) else [row]
|
|
464
|
+
if len(values) != len(columns):
|
|
465
|
+
raise ValueError(f"table row {row_index} length does not match columns")
|
|
466
|
+
fill = theme.surface if row_index % 2 else theme.background
|
|
467
|
+
for col, value in enumerate(values):
|
|
468
|
+
_style_table_cell(table.cell(row_index, col), value, fill, theme.text, theme, False)
|
|
469
|
+
if spec.get("note"):
|
|
470
|
+
_add_text(slide, spec["note"], 0.8, 6.22, 11.55, 0.45, size=9, color=theme.muted, theme=theme)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _style_table_cell(cell: Any, value: Any, fill: str, color: str, theme: _Theme, bold: bool) -> None:
|
|
474
|
+
cell.fill.solid()
|
|
475
|
+
cell.fill.fore_color.rgb = _rgb(fill)
|
|
476
|
+
cell.margin_left = Inches(0.08)
|
|
477
|
+
cell.margin_right = Inches(0.08)
|
|
478
|
+
cell.margin_top = Inches(0.04)
|
|
479
|
+
cell.margin_bottom = Inches(0.04)
|
|
480
|
+
frame = cell.text_frame
|
|
481
|
+
frame.clear()
|
|
482
|
+
frame.word_wrap = True
|
|
483
|
+
frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
|
484
|
+
paragraph = frame.paragraphs[0]
|
|
485
|
+
paragraph.text = str(value if value is not None else "")
|
|
486
|
+
paragraph.font.name = theme.font
|
|
487
|
+
paragraph.font.size = Pt(10)
|
|
488
|
+
paragraph.font.bold = bold
|
|
489
|
+
paragraph.font.color.rgb = _rgb(color)
|
|
490
|
+
paragraph.alignment = PP_ALIGN.CENTER
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _render_chart_slide(slide: Any, spec: dict[str, Any], theme: _Theme) -> None:
|
|
494
|
+
_add_standard_header(slide, spec, theme)
|
|
495
|
+
categories = spec.get("categories", [])
|
|
496
|
+
series = spec.get("series", [])
|
|
497
|
+
if not isinstance(categories, list) or not categories:
|
|
498
|
+
raise ValueError("chart slide requires a non-empty categories array")
|
|
499
|
+
if not isinstance(series, list) or not series:
|
|
500
|
+
raise ValueError("chart slide requires a non-empty series array")
|
|
501
|
+
data = ChartData()
|
|
502
|
+
data.categories = [str(value) for value in categories]
|
|
503
|
+
for index, item in enumerate(series):
|
|
504
|
+
if not isinstance(item, dict) or not isinstance(item.get("values"), list):
|
|
505
|
+
raise TypeError("chart series items require name and values")
|
|
506
|
+
if len(item["values"]) != len(categories):
|
|
507
|
+
raise ValueError(f"chart series {index} value count must match categories")
|
|
508
|
+
data.add_series(str(item.get("name", f"系列 {index + 1}")), item["values"])
|
|
509
|
+
|
|
510
|
+
chart_type_name = str(spec.get("chart_type", "column")).lower()
|
|
511
|
+
chart_types = {
|
|
512
|
+
"column": XL_CHART_TYPE.COLUMN_CLUSTERED,
|
|
513
|
+
"bar": XL_CHART_TYPE.BAR_CLUSTERED,
|
|
514
|
+
"line": XL_CHART_TYPE.LINE_MARKERS,
|
|
515
|
+
"pie": XL_CHART_TYPE.PIE,
|
|
516
|
+
"doughnut": XL_CHART_TYPE.DOUGHNUT,
|
|
517
|
+
"area": XL_CHART_TYPE.AREA,
|
|
518
|
+
}
|
|
519
|
+
if chart_type_name not in chart_types:
|
|
520
|
+
raise ValueError(f"unsupported chart_type: {chart_type_name}")
|
|
521
|
+
chart_width = 8.55 if spec.get("insight") else 11.75
|
|
522
|
+
chart = slide.shapes.add_chart(
|
|
523
|
+
chart_types[chart_type_name], Inches(0.75), Inches(1.65), Inches(chart_width), Inches(4.95), data
|
|
524
|
+
).chart
|
|
525
|
+
chart.has_title = False
|
|
526
|
+
chart.has_legend = len(series) > 1 or chart_type_name in {"pie", "doughnut"}
|
|
527
|
+
if chart.has_legend:
|
|
528
|
+
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
|
|
529
|
+
chart.legend.include_in_layout = False
|
|
530
|
+
chart.legend.font.name = theme.font
|
|
531
|
+
chart.legend.font.size = Pt(9)
|
|
532
|
+
palette = [theme.accent, theme.secondary, "D5A021", "7A6FA8", theme.positive, theme.negative]
|
|
533
|
+
for index, chart_series in enumerate(chart.series):
|
|
534
|
+
color = _rgb(palette[index % len(palette)])
|
|
535
|
+
if chart_type_name == "line":
|
|
536
|
+
chart_series.format.line.color.rgb = color
|
|
537
|
+
chart_series.format.line.width = Pt(2.25)
|
|
538
|
+
else:
|
|
539
|
+
chart_series.format.fill.solid()
|
|
540
|
+
chart_series.format.fill.fore_color.rgb = color
|
|
541
|
+
if chart_type_name not in {"pie", "doughnut"}:
|
|
542
|
+
chart.value_axis.has_major_gridlines = True
|
|
543
|
+
chart.value_axis.major_gridlines.format.line.color.rgb = _rgb(theme.border)
|
|
544
|
+
chart.value_axis.tick_labels.font.name = theme.font
|
|
545
|
+
chart.value_axis.tick_labels.font.size = Pt(9)
|
|
546
|
+
chart.category_axis.tick_labels.font.name = theme.font
|
|
547
|
+
chart.category_axis.tick_labels.font.size = Pt(9)
|
|
548
|
+
insight = str(spec.get("insight", "")).strip()
|
|
549
|
+
if insight:
|
|
550
|
+
_add_rect(slide, 9.58, 1.65, 2.97, 4.95, theme.surface, radius=True, line=theme.border)
|
|
551
|
+
_add_text(slide, "关键洞察", 9.88, 1.95, 2.35, 0.45, size=16, color=theme.primary, theme=theme, bold=True)
|
|
552
|
+
_add_text(slide, insight, 9.88, 2.55, 2.35, 3.55, size=12, color=theme.text, theme=theme)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _render_image_slide(slide: Any, spec: dict[str, Any], theme: _Theme, base_path: Path) -> None:
|
|
556
|
+
_add_standard_header(slide, spec, theme)
|
|
557
|
+
if not spec.get("image"):
|
|
558
|
+
raise ValueError("image slide requires image")
|
|
559
|
+
image = _resolve_asset(spec["image"], base_path)
|
|
560
|
+
caption = str(spec.get("caption", "")).strip()
|
|
561
|
+
height = 4.65 if caption else 5.05
|
|
562
|
+
_add_picture(slide, image, 0.75, 1.62, 11.8, height, str(spec.get("fit", "contain")))
|
|
563
|
+
if caption:
|
|
564
|
+
_add_text(slide, caption, 0.9, 6.42, 11.5, 0.35, size=10, color=theme.muted, theme=theme, align=PP_ALIGN.CENTER)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _resolve_asset(value: Any, base_path: Path) -> Path:
|
|
568
|
+
raw = str(value).strip()
|
|
569
|
+
if raw.startswith(("http://", "https://")):
|
|
570
|
+
raise ValueError("remote image URLs are not supported; download the image and use a local path")
|
|
571
|
+
path = Path(raw).expanduser()
|
|
572
|
+
if not path.is_absolute():
|
|
573
|
+
path = base_path / path
|
|
574
|
+
path = path.resolve()
|
|
575
|
+
if not path.is_file():
|
|
576
|
+
raise FileNotFoundError(f"presentation image not found: {path}")
|
|
577
|
+
return path
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _add_picture(slide: Any, path: Path, left: float, top: float, width: float, height: float, fit: str) -> Any:
|
|
581
|
+
fit = fit.lower()
|
|
582
|
+
if fit not in {"contain", "cover"}:
|
|
583
|
+
raise ValueError("image fit must be 'contain' or 'cover'")
|
|
584
|
+
with Image.open(path) as image:
|
|
585
|
+
image_width, image_height = image.size
|
|
586
|
+
image_ratio = image_width / image_height
|
|
587
|
+
box_ratio = width / height
|
|
588
|
+
if fit == "contain":
|
|
589
|
+
if image_ratio > box_ratio:
|
|
590
|
+
rendered_width, rendered_height = width, width / image_ratio
|
|
591
|
+
else:
|
|
592
|
+
rendered_width, rendered_height = height * image_ratio, height
|
|
593
|
+
rendered_left = left + (width - rendered_width) / 2
|
|
594
|
+
rendered_top = top + (height - rendered_height) / 2
|
|
595
|
+
return slide.shapes.add_picture(
|
|
596
|
+
str(path), Inches(rendered_left), Inches(rendered_top), Inches(rendered_width), Inches(rendered_height)
|
|
597
|
+
)
|
|
598
|
+
picture = slide.shapes.add_picture(str(path), Inches(left), Inches(top), Inches(width), Inches(height))
|
|
599
|
+
if image_ratio > box_ratio:
|
|
600
|
+
crop = (1 - box_ratio / image_ratio) / 2
|
|
601
|
+
picture.crop_left = crop
|
|
602
|
+
picture.crop_right = crop
|
|
603
|
+
else:
|
|
604
|
+
crop = (1 - image_ratio / box_ratio) / 2
|
|
605
|
+
picture.crop_top = crop
|
|
606
|
+
picture.crop_bottom = crop
|
|
607
|
+
return picture
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _render_quote_slide(slide: Any, spec: dict[str, Any], theme: _Theme) -> None:
|
|
611
|
+
_set_background(slide, theme.background)
|
|
612
|
+
_add_rect(slide, 0, 0, 0.14, _SLIDE_HEIGHT, theme.accent)
|
|
613
|
+
_add_text(slide, "“", 0.8, 0.72, 1.0, 1.0, size=58, color=theme.accent, theme=theme, bold=True)
|
|
614
|
+
_add_text(
|
|
615
|
+
slide, spec.get("quote", spec.get("title", "")), 1.4, 1.45, 10.7, 2.9,
|
|
616
|
+
size=29, color=theme.primary, theme=theme, bold=True, valign=MSO_ANCHOR.MIDDLE,
|
|
617
|
+
name="LICOS Slide Title",
|
|
618
|
+
)
|
|
619
|
+
attribution = str(spec.get("attribution", "")).strip()
|
|
620
|
+
if attribution:
|
|
621
|
+
_add_text(slide, f"— {attribution}", 1.45, 4.85, 9.8, 0.45, size=13, color=theme.muted, theme=theme)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _render_closing_slide(slide: Any, spec: dict[str, Any], theme: _Theme) -> None:
|
|
625
|
+
_set_background(slide, theme.primary)
|
|
626
|
+
_add_rect(slide, 0, 7.38, _SLIDE_WIDTH, 0.12, theme.accent)
|
|
627
|
+
_add_text(
|
|
628
|
+
slide, spec.get("title", "谢谢"), 1.0, 2.05, 11.3, 1.2,
|
|
629
|
+
size=42, color="FFFFFF", theme=theme, bold=True,
|
|
630
|
+
align=PP_ALIGN.CENTER, valign=MSO_ANCHOR.MIDDLE, name="LICOS Slide Title",
|
|
631
|
+
)
|
|
632
|
+
subtitle = str(spec.get("subtitle", "")).strip()
|
|
633
|
+
if subtitle:
|
|
634
|
+
_add_text(slide, subtitle, 1.2, 3.55, 10.9, 0.65, size=17, color="DDE7ED", theme=theme, align=PP_ALIGN.CENTER)
|
|
635
|
+
contact = str(spec.get("contact", "")).strip()
|
|
636
|
+
if contact:
|
|
637
|
+
_add_text(slide, contact, 1.2, 5.85, 10.9, 0.35, size=10, color="B9C8D2", theme=theme, align=PP_ALIGN.CENTER)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _add_summary(slide: Any, value: Any, theme: _Theme) -> None:
|
|
641
|
+
text = str(value or "").strip()
|
|
642
|
+
if not text:
|
|
643
|
+
return
|
|
644
|
+
_add_rect(slide, 0.78, 6.25, 11.7, 0.48, theme.surface, radius=True, line=theme.border)
|
|
645
|
+
_add_text(slide, text, 1.0, 6.34, 11.2, 0.27, size=10, color=theme.primary, theme=theme, bold=True)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Structural and visual-density checks for generated PPTX files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pptx import Presentation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def inspect_pptx(
|
|
12
|
+
path: str | Path,
|
|
13
|
+
*,
|
|
14
|
+
min_slides: int | None = None,
|
|
15
|
+
max_slides: int | None = None,
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""Inspect a PPTX and return a machine-readable quality report."""
|
|
18
|
+
presentation_path = Path(path).expanduser().resolve()
|
|
19
|
+
if not presentation_path.is_file():
|
|
20
|
+
raise FileNotFoundError(f"PPTX file not found: {presentation_path}")
|
|
21
|
+
if min_slides is not None and min_slides < 1:
|
|
22
|
+
raise ValueError("min_slides must be at least 1")
|
|
23
|
+
if max_slides is not None and max_slides < 1:
|
|
24
|
+
raise ValueError("max_slides must be at least 1")
|
|
25
|
+
if min_slides is not None and max_slides is not None and min_slides > max_slides:
|
|
26
|
+
raise ValueError("min_slides cannot exceed max_slides")
|
|
27
|
+
|
|
28
|
+
deck = Presentation(str(presentation_path))
|
|
29
|
+
slides: list[dict[str, Any]] = []
|
|
30
|
+
blank_slides: list[int] = []
|
|
31
|
+
titleless_slides: list[int] = []
|
|
32
|
+
crowded_slides: list[int] = []
|
|
33
|
+
totals = {"text_characters": 0, "pictures": 0, "tables": 0, "charts": 0, "shapes": 0}
|
|
34
|
+
|
|
35
|
+
for index, slide in enumerate(deck.slides, start=1):
|
|
36
|
+
texts: list[str] = []
|
|
37
|
+
title = ""
|
|
38
|
+
has_named_title = False
|
|
39
|
+
pictures = tables = charts = 0
|
|
40
|
+
for shape in slide.shapes:
|
|
41
|
+
if getattr(shape, "has_text_frame", False):
|
|
42
|
+
text = shape.text.strip()
|
|
43
|
+
if shape.name == "LICOS Slide Title":
|
|
44
|
+
has_named_title = True
|
|
45
|
+
title = text
|
|
46
|
+
if text and shape.name not in {"LICOS Footer", "LICOS Page Number"}:
|
|
47
|
+
texts.append(text)
|
|
48
|
+
if getattr(shape, "has_table", False):
|
|
49
|
+
tables += 1
|
|
50
|
+
if getattr(shape, "has_chart", False):
|
|
51
|
+
charts += 1
|
|
52
|
+
if getattr(shape, "shape_type", None) == 13:
|
|
53
|
+
pictures += 1
|
|
54
|
+
if not has_named_title and texts:
|
|
55
|
+
title = texts[0].splitlines()[0]
|
|
56
|
+
character_count = sum(len(text) for text in texts)
|
|
57
|
+
shape_count = len(slide.shapes)
|
|
58
|
+
if character_count == 0 and pictures == 0 and tables == 0 and charts == 0:
|
|
59
|
+
blank_slides.append(index)
|
|
60
|
+
if not title:
|
|
61
|
+
titleless_slides.append(index)
|
|
62
|
+
if character_count > 1200 or shape_count > 24:
|
|
63
|
+
crowded_slides.append(index)
|
|
64
|
+
|
|
65
|
+
slide_report = {
|
|
66
|
+
"number": index,
|
|
67
|
+
"title": title,
|
|
68
|
+
"text_characters": character_count,
|
|
69
|
+
"shape_count": shape_count,
|
|
70
|
+
"picture_count": pictures,
|
|
71
|
+
"table_count": tables,
|
|
72
|
+
"chart_count": charts,
|
|
73
|
+
}
|
|
74
|
+
slides.append(slide_report)
|
|
75
|
+
totals["text_characters"] += character_count
|
|
76
|
+
totals["pictures"] += pictures
|
|
77
|
+
totals["tables"] += tables
|
|
78
|
+
totals["charts"] += charts
|
|
79
|
+
totals["shapes"] += shape_count
|
|
80
|
+
|
|
81
|
+
slide_count = len(slides)
|
|
82
|
+
issues: list[str] = []
|
|
83
|
+
if slide_count == 0:
|
|
84
|
+
issues.append("presentation contains no slides")
|
|
85
|
+
if min_slides is not None and slide_count < min_slides:
|
|
86
|
+
issues.append(f"slide count {slide_count} is below minimum {min_slides}")
|
|
87
|
+
if max_slides is not None and slide_count > max_slides:
|
|
88
|
+
issues.append(f"slide count {slide_count} exceeds maximum {max_slides}")
|
|
89
|
+
if blank_slides:
|
|
90
|
+
issues.append(f"blank slides: {blank_slides}")
|
|
91
|
+
if titleless_slides:
|
|
92
|
+
issues.append(f"slides without detectable titles: {titleless_slides}")
|
|
93
|
+
if crowded_slides:
|
|
94
|
+
issues.append(f"potentially crowded slides: {crowded_slides}")
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
"path": str(presentation_path),
|
|
98
|
+
"size_bytes": presentation_path.stat().st_size,
|
|
99
|
+
"slide_count": slide_count,
|
|
100
|
+
"quality_passed": not issues,
|
|
101
|
+
"quality_issues": issues,
|
|
102
|
+
"blank_slides": blank_slides,
|
|
103
|
+
"titleless_slides": titleless_slides,
|
|
104
|
+
"crowded_slides": crowded_slides,
|
|
105
|
+
"totals": totals,
|
|
106
|
+
"slides": slides,
|
|
107
|
+
}
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import sys
|
|
4
4
|
import tempfile
|
|
5
5
|
import unittest
|
|
6
|
+
import warnings
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
@@ -11,10 +12,36 @@ from docx import Document
|
|
|
11
12
|
from openpyxl import load_workbook
|
|
12
13
|
from PIL import Image
|
|
13
14
|
|
|
14
|
-
from licos_dev_sdk import create_docx, create_docx_from_template, create_xlsx_workbook
|
|
15
|
+
from licos_dev_sdk import create_chart, create_docx, create_docx_from_template, create_xlsx_workbook
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class DocumentSpreadsheetTests(unittest.TestCase):
|
|
19
|
+
def test_create_chart_renders_chinese_text_without_missing_glyphs(self) -> None:
|
|
20
|
+
with tempfile.TemporaryDirectory() as tmp, warnings.catch_warnings(record=True) as caught:
|
|
21
|
+
warnings.simplefilter("always")
|
|
22
|
+
output = create_chart(
|
|
23
|
+
"bar",
|
|
24
|
+
{
|
|
25
|
+
"labels": ["华东地区", "华中地区", "华南地区"],
|
|
26
|
+
"datasets": [
|
|
27
|
+
{"label": "预计收入(万元)", "values": [4800, 3200, 2600]},
|
|
28
|
+
{"label": "项目ROI(%)", "values": [18, 23, 16]},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
"chinese-chart",
|
|
32
|
+
output_dir=tmp,
|
|
33
|
+
title="重点行业客户投资回报分析",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
self.assertTrue(Path(output).is_file())
|
|
37
|
+
self.assertGreater(Path(output).stat().st_size, 10_000)
|
|
38
|
+
missing_glyph_warnings = [
|
|
39
|
+
str(item.message)
|
|
40
|
+
for item in caught
|
|
41
|
+
if "Glyph" in str(item.message) and "missing from font" in str(item.message)
|
|
42
|
+
]
|
|
43
|
+
self.assertEqual(missing_glyph_warnings, [])
|
|
44
|
+
|
|
18
45
|
def test_create_docx_resolves_assets_from_explicit_base_path(self) -> None:
|
|
19
46
|
with tempfile.TemporaryDirectory() as tmp:
|
|
20
47
|
tmp_path = Path(tmp)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from pptx import Presentation
|
|
12
|
+
|
|
13
|
+
from licos_dev_sdk import create_pptx, create_pptx_deck, inspect_pptx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PresentationTests(unittest.TestCase):
|
|
17
|
+
def test_create_structured_deck_with_editable_visuals(self) -> None:
|
|
18
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
19
|
+
root = Path(tmp)
|
|
20
|
+
Image.new("RGB", (640, 360), "#20A39E").save(root / "factory.png")
|
|
21
|
+
output = create_pptx_deck(
|
|
22
|
+
{
|
|
23
|
+
"title": "智能制造经营分析",
|
|
24
|
+
"author": "经营管理部",
|
|
25
|
+
"footer": "内部资料",
|
|
26
|
+
"theme": "industrial",
|
|
27
|
+
"slides": [
|
|
28
|
+
{
|
|
29
|
+
"type": "title",
|
|
30
|
+
"eyebrow": "MANAGEMENT REVIEW",
|
|
31
|
+
"title": "智能制造经营分析",
|
|
32
|
+
"subtitle": "经营质量、交付效率与行动计划",
|
|
33
|
+
"date": "2026-08",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"type": "bullets",
|
|
37
|
+
"title": "经营摘要",
|
|
38
|
+
"items": [
|
|
39
|
+
{"text": "核心业务保持增长", "emphasis": True},
|
|
40
|
+
"重点客户交付周期持续缩短",
|
|
41
|
+
"供应链波动仍需关注",
|
|
42
|
+
],
|
|
43
|
+
"image": "factory.png",
|
|
44
|
+
"caption": "智能工厂示意",
|
|
45
|
+
"summary": "下一阶段重点是规模化复制标准方案。",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"type": "kpi",
|
|
49
|
+
"title": "核心经营指标",
|
|
50
|
+
"items": [
|
|
51
|
+
{"label": "营业收入", "value": "8.6 亿元", "change": "+18.2%"},
|
|
52
|
+
{"label": "毛利率", "value": "36.4%", "change": "+2.1pct"},
|
|
53
|
+
{"label": "准时交付率", "value": "94.8%", "change": "+5.3pct"},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "chart",
|
|
58
|
+
"title": "季度收入趋势",
|
|
59
|
+
"chart_type": "column",
|
|
60
|
+
"categories": ["Q1", "Q2", "Q3", "Q4"],
|
|
61
|
+
"series": [
|
|
62
|
+
{"name": "收入", "values": [1.6, 1.9, 2.2, 2.9]},
|
|
63
|
+
{"name": "目标", "values": [1.5, 1.8, 2.1, 2.6]},
|
|
64
|
+
],
|
|
65
|
+
"insight": "实际收入连续四个季度高于目标,Q4 增长最明显。",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"type": "table",
|
|
69
|
+
"title": "重点行动计划",
|
|
70
|
+
"columns": ["行动", "负责人", "时间", "目标"],
|
|
71
|
+
"rows": [
|
|
72
|
+
["优化重点客户交付", "交付中心", "Q3", "周期缩短 15%"],
|
|
73
|
+
["提升标准模块复用", "产品中心", "Q4", "复用率达到 70%"],
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{"type": "closing", "title": "聚焦执行,持续增长", "subtitle": "谢谢"},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
"management-review",
|
|
80
|
+
output_dir=str(root),
|
|
81
|
+
asset_base_path=str(root),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
deck = Presentation(output)
|
|
85
|
+
self.assertEqual(len(deck.slides), 6)
|
|
86
|
+
report = inspect_pptx(output, min_slides=6, max_slides=8)
|
|
87
|
+
self.assertTrue(report["quality_passed"], report["quality_issues"])
|
|
88
|
+
self.assertGreaterEqual(report["totals"]["pictures"], 1)
|
|
89
|
+
self.assertEqual(report["totals"]["charts"], 1)
|
|
90
|
+
self.assertEqual(report["totals"]["tables"], 1)
|
|
91
|
+
|
|
92
|
+
def test_markdown_api_remains_supported(self) -> None:
|
|
93
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
94
|
+
output = create_pptx(
|
|
95
|
+
"# 项目概览\n- 目标明确\n- 进展正常\n---\n# 下一步\n- 完成验收",
|
|
96
|
+
"legacy-deck",
|
|
97
|
+
output_dir=tmp,
|
|
98
|
+
)
|
|
99
|
+
report = inspect_pptx(output, min_slides=2, max_slides=2)
|
|
100
|
+
self.assertTrue(report["quality_passed"], report["quality_issues"])
|
|
101
|
+
|
|
102
|
+
def test_unknown_slide_type_fails_fast(self) -> None:
|
|
103
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
104
|
+
with self.assertRaisesRegex(ValueError, "unsupported slide type"):
|
|
105
|
+
create_pptx_deck(
|
|
106
|
+
{"slides": [{"type": "unknown", "title": "无效页面"}]},
|
|
107
|
+
"invalid-deck",
|
|
108
|
+
output_dir=tmp,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def test_inspector_reports_slide_count_violation(self) -> None:
|
|
112
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
113
|
+
output = create_pptx("# 唯一页面\n内容", "one-slide", output_dir=tmp)
|
|
114
|
+
report = inspect_pptx(output, min_slides=2)
|
|
115
|
+
self.assertFalse(report["quality_passed"])
|
|
116
|
+
self.assertIn("below minimum", report["quality_issues"][0])
|
|
117
|
+
|
|
118
|
+
def test_inspector_ignores_footer_when_detecting_blank_slide(self) -> None:
|
|
119
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
120
|
+
output = create_pptx_deck(
|
|
121
|
+
{"footer": "内部资料", "slides": [{"type": "bullets", "title": "", "items": []}]},
|
|
122
|
+
"blank-slide",
|
|
123
|
+
output_dir=tmp,
|
|
124
|
+
)
|
|
125
|
+
report = inspect_pptx(output)
|
|
126
|
+
self.assertEqual(report["blank_slides"], [1])
|
|
127
|
+
self.assertEqual(report["titleless_slides"], [1])
|
|
128
|
+
self.assertFalse(report["quality_passed"])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
unittest.main()
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
"""Chart generation — bar, line, pie, scatter, heatmap via matplotlib."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import matplotlib
|
|
6
|
-
matplotlib.use("Agg") # Non-interactive backend
|
|
7
|
-
import matplotlib.pyplot as plt
|
|
8
|
-
import numpy as np
|
|
9
|
-
|
|
10
|
-
from ._utils import resolve_output_path
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def create_chart(chart_type: str, data: dict, filename: str, *,
|
|
14
|
-
output_dir: str | None = None, format: str = "png",
|
|
15
|
-
title: str = "", width: int = 800, height: int = 600) -> str:
|
|
16
|
-
"""Generate a chart image (PNG/SVG). Returns absolute path.
|
|
17
|
-
|
|
18
|
-
Args:
|
|
19
|
-
chart_type: "bar" | "line" | "pie" | "scatter" | "heatmap"
|
|
20
|
-
data: {
|
|
21
|
-
"labels": ["A", "B", "C"],
|
|
22
|
-
"datasets": [{"label": "Series 1", "values": [10, 20, 30]}]
|
|
23
|
-
}
|
|
24
|
-
For pie: only first dataset is used.
|
|
25
|
-
For heatmap: data["matrix"] = [[1,2],[3,4]], data["x_labels"], data["y_labels"]
|
|
26
|
-
"""
|
|
27
|
-
path = resolve_output_path(filename, f".{format}", output_dir)
|
|
28
|
-
dpi = 100
|
|
29
|
-
fig, ax = plt.subplots(figsize=(width / dpi, height / dpi), dpi=dpi)
|
|
30
|
-
|
|
31
|
-
labels = data.get("labels", [])
|
|
32
|
-
datasets = data.get("datasets", [])
|
|
33
|
-
|
|
34
|
-
if chart_type == "bar":
|
|
35
|
-
x = np.arange(len(labels))
|
|
36
|
-
n = len(datasets)
|
|
37
|
-
bar_width = 0.8 / max(n, 1)
|
|
38
|
-
for i, ds in enumerate(datasets):
|
|
39
|
-
offset = (i - n / 2 + 0.5) * bar_width
|
|
40
|
-
ax.bar(x + offset, ds["values"], bar_width, label=ds.get("label", ""))
|
|
41
|
-
ax.set_xticks(x)
|
|
42
|
-
ax.set_xticklabels(labels)
|
|
43
|
-
if n > 1:
|
|
44
|
-
ax.legend()
|
|
45
|
-
|
|
46
|
-
elif chart_type == "line":
|
|
47
|
-
for ds in datasets:
|
|
48
|
-
ax.plot(labels, ds["values"], marker="o", label=ds.get("label", ""))
|
|
49
|
-
if len(datasets) > 1:
|
|
50
|
-
ax.legend()
|
|
51
|
-
|
|
52
|
-
elif chart_type == "pie":
|
|
53
|
-
values = datasets[0]["values"] if datasets else []
|
|
54
|
-
ax.pie(values, labels=labels, autopct="%1.1f%%", startangle=90)
|
|
55
|
-
ax.axis("equal")
|
|
56
|
-
|
|
57
|
-
elif chart_type == "scatter":
|
|
58
|
-
for ds in datasets:
|
|
59
|
-
x_vals = ds.get("x", list(range(len(ds["values"]))))
|
|
60
|
-
ax.scatter(x_vals, ds["values"], label=ds.get("label", ""), alpha=0.7)
|
|
61
|
-
if len(datasets) > 1:
|
|
62
|
-
ax.legend()
|
|
63
|
-
|
|
64
|
-
elif chart_type == "heatmap":
|
|
65
|
-
matrix = np.array(data.get("matrix", [[]]))
|
|
66
|
-
im = ax.imshow(matrix, cmap="YlOrRd", aspect="auto")
|
|
67
|
-
fig.colorbar(im, ax=ax)
|
|
68
|
-
if "x_labels" in data:
|
|
69
|
-
ax.set_xticks(range(len(data["x_labels"])))
|
|
70
|
-
ax.set_xticklabels(data["x_labels"])
|
|
71
|
-
if "y_labels" in data:
|
|
72
|
-
ax.set_yticks(range(len(data["y_labels"])))
|
|
73
|
-
ax.set_yticklabels(data["y_labels"])
|
|
74
|
-
|
|
75
|
-
if title:
|
|
76
|
-
ax.set_title(title)
|
|
77
|
-
fig.tight_layout()
|
|
78
|
-
fig.savefig(str(path), format=format, bbox_inches="tight")
|
|
79
|
-
plt.close(fig)
|
|
80
|
-
return str(path.resolve())
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
"""Presentation generation — PPTX from Markdown."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pptx import Presentation
|
|
6
|
-
from pptx.util import Inches, Pt, Emu
|
|
7
|
-
from pptx.dml.color import RGBColor
|
|
8
|
-
from pptx.enum.text import PP_ALIGN
|
|
9
|
-
|
|
10
|
-
from ._utils import resolve_output_path
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def create_pptx(content: str, filename: str, *, content_type: str = "markdown",
|
|
14
|
-
output_dir: str | None = None) -> str:
|
|
15
|
-
"""Generate a PPTX file from Markdown. Returns absolute path.
|
|
16
|
-
|
|
17
|
-
Slides are separated by `---` in Markdown.
|
|
18
|
-
First `#` heading becomes the slide title; remaining content becomes bullet points.
|
|
19
|
-
"""
|
|
20
|
-
path = resolve_output_path(filename, ".pptx", output_dir)
|
|
21
|
-
prs = Presentation()
|
|
22
|
-
prs.slide_width = Inches(13.333)
|
|
23
|
-
prs.slide_height = Inches(7.5)
|
|
24
|
-
|
|
25
|
-
if content_type == "html":
|
|
26
|
-
# Strip tags for simple conversion
|
|
27
|
-
import re
|
|
28
|
-
content = re.sub(r"<[^>]+>", "", content)
|
|
29
|
-
|
|
30
|
-
slides_text = content.split("\n---\n") if "\n---\n" in content else content.split("\n---")
|
|
31
|
-
|
|
32
|
-
for slide_md in slides_text:
|
|
33
|
-
lines = [l for l in slide_md.strip().split("\n") if l.strip()]
|
|
34
|
-
if not lines:
|
|
35
|
-
continue
|
|
36
|
-
|
|
37
|
-
slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout
|
|
38
|
-
|
|
39
|
-
# Extract title (first # heading)
|
|
40
|
-
title_text = ""
|
|
41
|
-
body_lines = []
|
|
42
|
-
for line in lines:
|
|
43
|
-
stripped = line.strip()
|
|
44
|
-
if not title_text and stripped.startswith("#"):
|
|
45
|
-
title_text = stripped.lstrip("#").strip()
|
|
46
|
-
else:
|
|
47
|
-
body_lines.append(stripped)
|
|
48
|
-
|
|
49
|
-
# Add title
|
|
50
|
-
if title_text:
|
|
51
|
-
left = Inches(0.8)
|
|
52
|
-
top = Inches(0.5)
|
|
53
|
-
width = Inches(11.7)
|
|
54
|
-
height = Inches(1.2)
|
|
55
|
-
txBox = slide.shapes.add_textbox(left, top, width, height)
|
|
56
|
-
tf = txBox.text_frame
|
|
57
|
-
tf.word_wrap = True
|
|
58
|
-
p = tf.paragraphs[0]
|
|
59
|
-
p.text = title_text
|
|
60
|
-
p.font.size = Pt(36)
|
|
61
|
-
p.font.bold = True
|
|
62
|
-
p.font.color.rgb = RGBColor(0x1C, 0x28, 0x33)
|
|
63
|
-
|
|
64
|
-
# Add body content
|
|
65
|
-
if body_lines:
|
|
66
|
-
left = Inches(0.8)
|
|
67
|
-
top = Inches(2.0)
|
|
68
|
-
width = Inches(11.7)
|
|
69
|
-
height = Inches(4.5)
|
|
70
|
-
txBox = slide.shapes.add_textbox(left, top, width, height)
|
|
71
|
-
tf = txBox.text_frame
|
|
72
|
-
tf.word_wrap = True
|
|
73
|
-
|
|
74
|
-
for i, line in enumerate(body_lines):
|
|
75
|
-
if i == 0:
|
|
76
|
-
p = tf.paragraphs[0]
|
|
77
|
-
else:
|
|
78
|
-
p = tf.add_paragraph()
|
|
79
|
-
|
|
80
|
-
# Bullet points
|
|
81
|
-
if line.startswith("- ") or line.startswith("* "):
|
|
82
|
-
p.text = line[2:]
|
|
83
|
-
p.level = 0
|
|
84
|
-
p.space_before = Pt(6)
|
|
85
|
-
elif line.startswith(" - ") or line.startswith(" * "):
|
|
86
|
-
p.text = line[4:]
|
|
87
|
-
p.level = 1
|
|
88
|
-
p.space_before = Pt(4)
|
|
89
|
-
# Sub-headings
|
|
90
|
-
elif line.startswith("##"):
|
|
91
|
-
p.text = line.lstrip("#").strip()
|
|
92
|
-
p.font.size = Pt(24)
|
|
93
|
-
p.font.bold = True
|
|
94
|
-
p.space_before = Pt(12)
|
|
95
|
-
continue
|
|
96
|
-
else:
|
|
97
|
-
p.text = line
|
|
98
|
-
|
|
99
|
-
p.font.size = Pt(18)
|
|
100
|
-
p.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
|
|
101
|
-
|
|
102
|
-
prs.save(str(path))
|
|
103
|
-
return str(path.resolve())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|