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.
Files changed (29) hide show
  1. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/PKG-INFO +1 -1
  2. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/pyproject.toml +1 -1
  3. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/__init__.py +3 -1
  4. licos_dev_sdk-0.3.4/src/licos_dev_sdk/chart.py +114 -0
  5. licos_dev_sdk-0.3.4/src/licos_dev_sdk/presentation.py +645 -0
  6. licos_dev_sdk-0.3.4/src/licos_dev_sdk/presentation_inspect.py +107 -0
  7. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/tests/test_document_spreadsheet.py +28 -1
  8. licos_dev_sdk-0.3.4/tests/test_presentation.py +132 -0
  9. licos_dev_sdk-0.3.2/src/licos_dev_sdk/chart.py +0 -80
  10. licos_dev_sdk-0.3.2/src/licos_dev_sdk/presentation.py +0 -103
  11. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/.gitignore +0 -0
  12. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/_utils.py +0 -0
  13. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/archive.py +0 -0
  14. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/data.py +0 -0
  15. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/diagram.py +0 -0
  16. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/document.py +0 -0
  17. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/document_common.py +0 -0
  18. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/document_docx.py +0 -0
  19. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/document_docx_layout.py +0 -0
  20. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/image.py +0 -0
  21. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/model.py +0 -0
  22. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/observability.py +0 -0
  23. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/pdf_inspect.py +0 -0
  24. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/spreadsheet.py +0 -0
  25. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/spreadsheet_inspect.py +0 -0
  26. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/src/licos_dev_sdk/web.py +0 -0
  27. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/tests/test_model.py +0 -0
  28. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/tests/test_observability.py +0 -0
  29. {licos_dev_sdk-0.3.2 → licos_dev_sdk-0.3.4}/tests/test_output_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-sdk
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: LICOS Dev SDK - file generation and model capability clients
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: beautifulsoup4>=4.12
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-dev-sdk"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "LICOS Dev SDK - file generation and model capability clients"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -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