licos-dev-sdk 0.1.0__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.
@@ -0,0 +1,44 @@
1
+ # Rust
2
+ /target/
3
+ **/*.rs.bk
4
+ Cargo.lock
5
+
6
+ # Node
7
+ node_modules/
8
+ packages/*/dist/
9
+
10
+ # IDE
11
+ .idea/
12
+ .vscode/
13
+ *.swp
14
+ *.swo
15
+
16
+ # OS
17
+ .DS_Store
18
+ Thumbs.db
19
+
20
+ # Environment
21
+ .env
22
+ .env.local
23
+
24
+ # Workspace
25
+ /workspace/
26
+
27
+ # Test runtime data
28
+ /test/licos-data/
29
+ /test/screenshots/
30
+ /test/vue-app/
31
+
32
+ # Build
33
+ *.log
34
+ .licos
35
+
36
+ /tmp
37
+
38
+ /Docs/hermes-agent
39
+ /Docs/OpenCode
40
+ /Docs/平台API
41
+
42
+ *.codex-*
43
+
44
+ project-20260423_130440
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: licos-dev-sdk
3
+ Version: 0.1.0
4
+ Summary: LICOS file generation SDK — PDF, DOCX, XLSX, PPTX, charts, diagrams, QR codes, and more
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: graphviz>=0.20
7
+ Requires-Dist: jinja2>=3.1
8
+ Requires-Dist: matplotlib>=3.9
9
+ Requires-Dist: mistune>=3.0
10
+ Requires-Dist: openpyxl>=3.1
11
+ Requires-Dist: pillow>=10.0
12
+ Requires-Dist: python-barcode>=0.15
13
+ Requires-Dist: python-docx>=1.1
14
+ Requires-Dist: python-pptx>=1.0
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: qrcode[pil]>=8.0
17
+ Requires-Dist: weasyprint>=62.0
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "licos-dev-sdk"
7
+ version = "0.1.0"
8
+ description = "LICOS file generation SDK — PDF, DOCX, XLSX, PPTX, charts, diagrams, QR codes, and more"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "weasyprint>=62.0",
12
+ "python-docx>=1.1",
13
+ "openpyxl>=3.1",
14
+ "python-pptx>=1.0",
15
+ "matplotlib>=3.9",
16
+ "graphviz>=0.20",
17
+ "qrcode[pil]>=8.0",
18
+ "python-barcode>=0.15",
19
+ "Pillow>=10.0",
20
+ "Jinja2>=3.1",
21
+ "mistune>=3.0",
22
+ "PyYAML>=6.0",
23
+ ]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/licos_dev_sdk"]
@@ -0,0 +1,57 @@
1
+ """LICOS Dev SDK — file generation toolkit.
2
+
3
+ Modules with heavy native dependencies (weasyprint, graphviz, matplotlib)
4
+ are lazy-imported to avoid errors in minimal environments (e.g., node-base).
5
+ """
6
+
7
+
8
+ def __getattr__(name: str):
9
+ """Lazy import — only load modules when accessed."""
10
+ _map = {
11
+ # data
12
+ "create_json": ("data", "create_json"),
13
+ "create_xml": ("data", "create_xml"),
14
+ "create_yaml": ("data", "create_yaml"),
15
+ # archive
16
+ "create_zip": ("archive", "create_zip"),
17
+ "create_tar_gz": ("archive", "create_tar_gz"),
18
+ # image
19
+ "create_qrcode": ("image", "create_qrcode"),
20
+ "create_barcode": ("image", "create_barcode"),
21
+ "create_watermark": ("image", "create_watermark"),
22
+ # web
23
+ "create_html": ("web", "create_html"),
24
+ "create_markdown": ("web", "create_markdown"),
25
+ "markdown_to_html": ("web", "markdown_to_html"),
26
+ # document
27
+ "create_pdf": ("document", "create_pdf"),
28
+ "create_docx": ("document", "create_docx"),
29
+ # spreadsheet
30
+ "create_xlsx": ("spreadsheet", "create_xlsx"),
31
+ "create_csv": ("spreadsheet", "create_csv"),
32
+ # chart
33
+ "create_chart": ("chart", "create_chart"),
34
+ # diagram
35
+ "create_diagram": ("diagram", "create_diagram"),
36
+ # presentation
37
+ "create_pptx": ("presentation", "create_pptx"),
38
+ }
39
+ if name in _map:
40
+ mod_name, attr = _map[name]
41
+ import importlib
42
+ mod = importlib.import_module(f".{mod_name}", __name__)
43
+ return getattr(mod, attr)
44
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
45
+
46
+
47
+ __all__ = [
48
+ "create_json", "create_xml", "create_yaml",
49
+ "create_zip", "create_tar_gz",
50
+ "create_qrcode", "create_barcode", "create_watermark",
51
+ "create_html", "create_markdown", "markdown_to_html",
52
+ "create_pdf", "create_docx",
53
+ "create_xlsx", "create_csv",
54
+ "create_chart",
55
+ "create_diagram",
56
+ "create_pptx",
57
+ ]
@@ -0,0 +1,34 @@
1
+ """Shared utilities for output path resolution and file naming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+
10
+ def resolve_output_dir(output_dir: str | None = None) -> Path:
11
+ """Resolve the output directory.
12
+
13
+ Priority: explicit arg > $LICOS_WORKSPACE_PATH > cwd
14
+ """
15
+ if output_dir:
16
+ p = Path(output_dir)
17
+ else:
18
+ env = os.environ.get("LICOS_WORKSPACE_PATH", "")
19
+ p = Path(env) if env else Path.cwd()
20
+ p.mkdir(parents=True, exist_ok=True)
21
+ return p
22
+
23
+
24
+ def resolve_output_path(filename: str, ext: str, output_dir: str | None = None) -> Path:
25
+ """Build the full output path, auto-adding extension and deduplicating."""
26
+ d = resolve_output_dir(output_dir)
27
+ # Ensure ext starts with dot
28
+ if not ext.startswith("."):
29
+ ext = f".{ext}"
30
+ path = d / f"{filename}{ext}"
31
+ if path.exists():
32
+ ts = datetime.now().strftime("%Y%m%d%H%M%S")
33
+ path = d / f"{filename}_{ts}{ext}"
34
+ return path
@@ -0,0 +1,34 @@
1
+ """Archive generation — ZIP, TAR.GZ."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tarfile
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ from ._utils import resolve_output_path
10
+
11
+
12
+ def create_zip(source_paths: list[str], filename: str, *, output_dir: str | None = None) -> str:
13
+ """Create a ZIP archive from a list of files/directories. Returns absolute path."""
14
+ path = resolve_output_path(filename, ".zip", output_dir)
15
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
16
+ for src in source_paths:
17
+ p = Path(src)
18
+ if p.is_dir():
19
+ for child in p.rglob("*"):
20
+ if child.is_file():
21
+ zf.write(child, child.relative_to(p.parent))
22
+ elif p.is_file():
23
+ zf.write(p, p.name)
24
+ return str(path.resolve())
25
+
26
+
27
+ def create_tar_gz(source_paths: list[str], filename: str, *, output_dir: str | None = None) -> str:
28
+ """Create a TAR.GZ archive from a list of files/directories. Returns absolute path."""
29
+ path = resolve_output_path(filename, ".tar.gz", output_dir)
30
+ with tarfile.open(path, "w:gz") as tf:
31
+ for src in source_paths:
32
+ p = Path(src)
33
+ tf.add(p, arcname=p.name)
34
+ return str(path.resolve())
@@ -0,0 +1,80 @@
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())
@@ -0,0 +1,56 @@
1
+ """Data file generation — JSON, XML, YAML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import csv as csv_mod
7
+ import io
8
+ from typing import Any
9
+ from xml.etree.ElementTree import Element, SubElement, tostring
10
+ from xml.dom.minidom import parseString
11
+
12
+ import yaml
13
+
14
+ from ._utils import resolve_output_path
15
+
16
+
17
+ def create_json(data: Any, filename: str, *, output_dir: str | None = None, indent: int = 2) -> str:
18
+ """Write data as a formatted JSON file. Returns absolute path."""
19
+ path = resolve_output_path(filename, ".json", output_dir)
20
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=indent), encoding="utf-8")
21
+ return str(path.resolve())
22
+
23
+
24
+ def create_yaml(data: Any, filename: str, *, output_dir: str | None = None) -> str:
25
+ """Write data as a YAML file. Returns absolute path."""
26
+ path = resolve_output_path(filename, ".yaml", output_dir)
27
+ path.write_text(yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False), encoding="utf-8")
28
+ return str(path.resolve())
29
+
30
+
31
+ def create_xml(data: dict, filename: str, *, output_dir: str | None = None, root_tag: str = "root") -> str:
32
+ """Write a dict as an XML file. Returns absolute path."""
33
+ path = resolve_output_path(filename, ".xml", output_dir)
34
+
35
+ def _dict_to_xml(parent: Element, d: dict | list | Any) -> None:
36
+ if isinstance(d, dict):
37
+ for key, val in d.items():
38
+ child = SubElement(parent, str(key))
39
+ _dict_to_xml(child, val)
40
+ elif isinstance(d, list):
41
+ for item in d:
42
+ child = SubElement(parent, "item")
43
+ _dict_to_xml(child, item)
44
+ else:
45
+ parent.text = str(d)
46
+
47
+ root = Element(root_tag)
48
+ _dict_to_xml(root, data)
49
+ raw = tostring(root, encoding="unicode")
50
+ pretty = parseString(raw).toprettyxml(indent=" ")
51
+ # Remove extra xml declaration line
52
+ lines = pretty.split("\n")
53
+ if lines and lines[0].startswith("<?xml"):
54
+ lines[0] = '<?xml version="1.0" encoding="UTF-8"?>'
55
+ path.write_text("\n".join(lines), encoding="utf-8")
56
+ return str(path.resolve())
@@ -0,0 +1,22 @@
1
+ """Diagram generation — Graphviz DOT → PNG/SVG."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import graphviz
6
+
7
+ from ._utils import resolve_output_path
8
+
9
+
10
+ def create_diagram(dot_source: str, filename: str, *,
11
+ output_dir: str | None = None, format: str = "png") -> str:
12
+ """Render a Graphviz DOT source to an image. Returns absolute path.
13
+
14
+ Args:
15
+ dot_source: DOT language string (e.g., 'digraph { A -> B }')
16
+ format: "png" | "svg" | "pdf"
17
+ """
18
+ path = resolve_output_path(filename, f".{format}", output_dir)
19
+ src = graphviz.Source(dot_source)
20
+ # graphviz renders to {filename}.{format} — render to parent dir with stem
21
+ rendered = src.render(filename=str(path.with_suffix("")), format=format, cleanup=True)
22
+ return str(path.resolve())
@@ -0,0 +1,98 @@
1
+ """Document generation — PDF, DOCX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mistune
6
+ from weasyprint import HTML
7
+ from docx import Document
8
+ from docx.shared import Pt, Inches
9
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
10
+
11
+ from ._utils import resolve_output_path
12
+
13
+ _PDF_CSS = """
14
+ @page { size: {page_size}; margin: 2cm; }
15
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans CJK SC", sans-serif;
16
+ font-size: 11pt; line-height: 1.6; color: #333; }
17
+ h1 { font-size: 22pt; margin-top: 0; }
18
+ h2 { font-size: 16pt; }
19
+ h3 { font-size: 13pt; }
20
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
21
+ th, td { border: 1px solid #ccc; padding: 6px 10px; text-align: left; }
22
+ th { background: #f0f0f0; font-weight: 600; }
23
+ code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }
24
+ pre { background: #f4f4f4; padding: 12px; border-radius: 4px; overflow-x: auto; }
25
+ blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 1em; color: #666; }
26
+ """
27
+
28
+
29
+ def create_pdf(content: str, filename: str, *, content_type: str = "markdown",
30
+ output_dir: str | None = None, page_size: str = "A4") -> str:
31
+ """Generate a PDF file from Markdown or HTML. Returns absolute path."""
32
+ path = resolve_output_path(filename, ".pdf", output_dir)
33
+ html_body = mistune.html(content) if content_type == "markdown" else content
34
+ css = _PDF_CSS.replace("{page_size}", page_size)
35
+ full_html = f"<html><head><style>{css}</style></head><body>{html_body}</body></html>"
36
+ HTML(string=full_html).write_pdf(str(path))
37
+ return str(path.resolve())
38
+
39
+
40
+ def create_docx(content: str, filename: str, *, content_type: str = "markdown",
41
+ output_dir: str | None = None, font_name: str = "Arial", font_size: int = 11) -> str:
42
+ """Generate a DOCX file from Markdown or HTML. Returns absolute path.
43
+
44
+ Converts Markdown to a simple DOCX with headings, paragraphs, and lists.
45
+ For complex HTML, consider generating PDF instead.
46
+ """
47
+ path = resolve_output_path(filename, ".docx", output_dir)
48
+
49
+ if content_type == "html":
50
+ # For HTML input, convert to markdown-like text first
51
+ import re
52
+ text = re.sub(r"<[^>]+>", "", content)
53
+ lines = [l.strip() for l in text.split("\n") if l.strip()]
54
+ else:
55
+ lines = content.split("\n")
56
+
57
+ doc = Document()
58
+ # Set default font
59
+ style = doc.styles["Normal"]
60
+ style.font.name = font_name
61
+ style.font.size = Pt(font_size)
62
+
63
+ for line in lines:
64
+ stripped = line.strip()
65
+ if not stripped:
66
+ continue
67
+ # Headings
68
+ if stripped.startswith("######"):
69
+ doc.add_heading(stripped.lstrip("#").strip(), level=6)
70
+ elif stripped.startswith("#####"):
71
+ doc.add_heading(stripped.lstrip("#").strip(), level=5)
72
+ elif stripped.startswith("####"):
73
+ doc.add_heading(stripped.lstrip("#").strip(), level=4)
74
+ elif stripped.startswith("###"):
75
+ doc.add_heading(stripped.lstrip("#").strip(), level=3)
76
+ elif stripped.startswith("##"):
77
+ doc.add_heading(stripped.lstrip("#").strip(), level=2)
78
+ elif stripped.startswith("#"):
79
+ doc.add_heading(stripped.lstrip("#").strip(), level=1)
80
+ # Unordered list
81
+ elif stripped.startswith("- ") or stripped.startswith("* "):
82
+ doc.add_paragraph(stripped[2:], style="List Bullet")
83
+ # Ordered list
84
+ elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1] in (".", ")"):
85
+ doc.add_paragraph(stripped[2:].strip(), style="List Number")
86
+ # Blockquote
87
+ elif stripped.startswith("> "):
88
+ p = doc.add_paragraph(stripped[2:])
89
+ p.paragraph_format.left_indent = Inches(0.5)
90
+ p.style.font.italic = True
91
+ # Horizontal rule
92
+ elif stripped in ("---", "***", "___"):
93
+ doc.add_paragraph("─" * 50)
94
+ else:
95
+ doc.add_paragraph(stripped)
96
+
97
+ doc.save(str(path))
98
+ return str(path.resolve())
@@ -0,0 +1,60 @@
1
+ """Image generation — QR code, barcode, watermark."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import qrcode
8
+ import barcode as barcode_lib
9
+ from barcode.writer import ImageWriter
10
+ from PIL import Image, ImageDraw, ImageFont
11
+
12
+ from ._utils import resolve_output_path
13
+
14
+
15
+ def create_qrcode(data: str, filename: str, *, output_dir: str | None = None, size: int = 300) -> str:
16
+ """Generate a QR code PNG image. Returns absolute path."""
17
+ path = resolve_output_path(filename, ".png", output_dir)
18
+ qr = qrcode.QRCode(box_size=10, border=2)
19
+ qr.add_data(data)
20
+ qr.make(fit=True)
21
+ img = qr.make_image(fill_color="black", back_color="white")
22
+ img = img.resize((size, size))
23
+ img.save(str(path))
24
+ return str(path.resolve())
25
+
26
+
27
+ def create_barcode(data: str, filename: str, *, output_dir: str | None = None, barcode_type: str = "code128") -> str:
28
+ """Generate a barcode PNG image. Returns absolute path."""
29
+ path = resolve_output_path(filename, ".png", output_dir)
30
+ bc_class = barcode_lib.get_barcode_class(barcode_type)
31
+ bc = bc_class(data, writer=ImageWriter())
32
+ # python-barcode appends extension, save without it
33
+ saved = bc.save(str(path.with_suffix("")))
34
+ return str(Path(saved).resolve())
35
+
36
+
37
+ def create_watermark(text: str, filename: str, *, output_dir: str | None = None,
38
+ width: int = 800, height: int = 600, opacity: float = 0.3) -> str:
39
+ """Generate a semi-transparent watermark PNG image. Returns absolute path."""
40
+ path = resolve_output_path(filename, ".png", output_dir)
41
+ img = Image.new("RGBA", (width, height), (255, 255, 255, 0))
42
+ draw = ImageDraw.Draw(img)
43
+
44
+ # Use default font, scale size to image
45
+ font_size = max(width, height) // 10
46
+ try:
47
+ font = ImageFont.truetype("arial.ttf", font_size)
48
+ except (OSError, IOError):
49
+ font = ImageFont.load_default()
50
+
51
+ alpha = int(255 * opacity)
52
+ fill = (128, 128, 128, alpha)
53
+
54
+ # Tile the watermark text diagonally
55
+ for y in range(-height, height * 2, font_size * 3):
56
+ for x in range(-width, width * 2, font_size * len(text)):
57
+ draw.text((x, y), text, fill=fill, font=font)
58
+
59
+ img.save(str(path), "PNG")
60
+ return str(path.resolve())
@@ -0,0 +1,103 @@
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())
@@ -0,0 +1,75 @@
1
+ """Spreadsheet generation — XLSX, CSV."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ from typing import Any
8
+
9
+ from openpyxl import Workbook
10
+ from openpyxl.styles import Font, PatternFill, Alignment
11
+
12
+ from ._utils import resolve_output_path
13
+
14
+
15
+ def create_xlsx(data: list[dict] | list[list], filename: str, *,
16
+ output_dir: str | None = None, sheet_name: str = "Sheet1",
17
+ header_color: str = "4472C4") -> str:
18
+ """Generate an XLSX file from data. Returns absolute path.
19
+
20
+ Args:
21
+ data: List of dicts (keys as headers) or 2D list (first row as headers).
22
+ """
23
+ path = resolve_output_path(filename, ".xlsx", output_dir)
24
+ wb = Workbook()
25
+ ws = wb.active
26
+ ws.title = sheet_name
27
+
28
+ header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
29
+ header_font = Font(bold=True, color="FFFFFF")
30
+
31
+ if data and isinstance(data[0], dict):
32
+ headers = list(data[0].keys())
33
+ ws.append(headers)
34
+ for row in data:
35
+ ws.append([row.get(h) for h in headers])
36
+ elif data and isinstance(data[0], list):
37
+ for row in data:
38
+ ws.append(row)
39
+ else:
40
+ wb.save(str(path))
41
+ return str(path.resolve())
42
+
43
+ # Style header row
44
+ for cell in ws[1]:
45
+ cell.fill = header_fill
46
+ cell.font = header_font
47
+ cell.alignment = Alignment(horizontal="center")
48
+
49
+ # Auto-width columns
50
+ for col in ws.columns:
51
+ max_len = 0
52
+ col_letter = col[0].column_letter
53
+ for cell in col:
54
+ val = str(cell.value) if cell.value is not None else ""
55
+ max_len = max(max_len, len(val))
56
+ ws.column_dimensions[col_letter].width = min(max_len + 4, 50)
57
+
58
+ wb.save(str(path))
59
+ return str(path.resolve())
60
+
61
+
62
+ def create_csv(data: list[dict] | list[list], filename: str, *, output_dir: str | None = None) -> str:
63
+ """Generate a CSV file from data. Returns absolute path."""
64
+ path = resolve_output_path(filename, ".csv", output_dir)
65
+
66
+ with open(path, "w", newline="", encoding="utf-8-sig") as f:
67
+ if data and isinstance(data[0], dict):
68
+ writer = csv.DictWriter(f, fieldnames=data[0].keys())
69
+ writer.writeheader()
70
+ writer.writerows(data)
71
+ elif data and isinstance(data[0], list):
72
+ writer = csv.writer(f)
73
+ writer.writerows(data)
74
+
75
+ return str(path.resolve())
@@ -0,0 +1,61 @@
1
+ """Web file generation — HTML, Markdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mistune
6
+ from jinja2 import Template
7
+
8
+ from ._utils import resolve_output_path
9
+
10
+ _DEFAULT_HTML_TEMPLATE = """\
11
+ <!DOCTYPE html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="UTF-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
16
+ <title>{{ title }}</title>
17
+ <style>
18
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
19
+ max-width: 800px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; color: #333; }
20
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
21
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
22
+ th { background-color: #f4f4f4; }
23
+ code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
24
+ pre { background: #f4f4f4; padding: 1rem; border-radius: 6px; overflow-x: auto; }
25
+ blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 1rem; color: #666; }
26
+ </style>
27
+ </head>
28
+ <body>
29
+ {{ body }}
30
+ </body>
31
+ </html>
32
+ """
33
+
34
+
35
+ def markdown_to_html(markdown_content: str) -> str:
36
+ """Convert Markdown to HTML fragment."""
37
+ return mistune.html(markdown_content)
38
+
39
+
40
+ def create_html(content: str, filename: str, *, output_dir: str | None = None,
41
+ template: str | None = None, content_type: str = "markdown") -> str:
42
+ """Generate an HTML file. Returns absolute path.
43
+
44
+ Args:
45
+ content: Markdown or raw HTML content.
46
+ content_type: "markdown" (default) or "html".
47
+ template: Custom Jinja2 template string. Must contain {{ body }}.
48
+ """
49
+ path = resolve_output_path(filename, ".html", output_dir)
50
+ body = markdown_to_html(content) if content_type == "markdown" else content
51
+ tmpl = Template(template or _DEFAULT_HTML_TEMPLATE)
52
+ html = tmpl.render(title=filename, body=body)
53
+ path.write_text(html, encoding="utf-8")
54
+ return str(path.resolve())
55
+
56
+
57
+ def create_markdown(content: str, filename: str, *, output_dir: str | None = None) -> str:
58
+ """Write a Markdown file. Returns absolute path."""
59
+ path = resolve_output_path(filename, ".md", output_dir)
60
+ path.write_text(content, encoding="utf-8")
61
+ return str(path.resolve())