From 1b674e31dab9800e0ac92840bce00376da84d3bf Mon Sep 17 00:00:00 2001 From: CJYSEX <302750122+CJYSEX@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:15:04 +0800 Subject: [PATCH] feat: add Chinese office productivity plugins --- .agents/plugins/marketplace.json | 48 +++++ .gitignore | 3 + CHANGELOG.md | 7 + README.md | 40 +++- .../cjysa-documents/.codex-plugin/plugin.json | 29 +++ .../skills/create-edit-documents/SKILL.md | 46 +++++ .../create-edit-documents/agents/openai.yaml | 7 + .../references/quality-checks.md | 9 + .../scripts/docx_tool.py | 160 +++++++++++++++ plugins/cjysa-pdf/.codex-plugin/plugin.json | 29 +++ .../cjysa-pdf/skills/work-with-pdf/SKILL.md | 37 ++++ .../skills/work-with-pdf/agents/openai.yaml | 7 + .../references/quality-checks.md | 8 + .../skills/work-with-pdf/scripts/pdf_tool.py | 182 ++++++++++++++++++ .../.codex-plugin/plugin.json | 29 +++ .../skills/build-presentations/SKILL.md | 40 ++++ .../build-presentations/agents/openai.yaml | 7 + .../references/quality-checks.md | 9 + .../build-presentations/scripts/pptx_tool.py | 99 ++++++++++ .../.codex-plugin/plugin.json | 29 +++ .../skills/build-spreadsheets/SKILL.md | 45 +++++ .../build-spreadsheets/agents/openai.yaml | 7 + .../references/quality-checks.md | 9 + .../build-spreadsheets/scripts/xlsx_tool.py | 117 +++++++++++ 24 files changed, 1001 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 plugins/cjysa-documents/.codex-plugin/plugin.json create mode 100644 plugins/cjysa-documents/skills/create-edit-documents/SKILL.md create mode 100644 plugins/cjysa-documents/skills/create-edit-documents/agents/openai.yaml create mode 100644 plugins/cjysa-documents/skills/create-edit-documents/references/quality-checks.md create mode 100644 plugins/cjysa-documents/skills/create-edit-documents/scripts/docx_tool.py create mode 100644 plugins/cjysa-pdf/.codex-plugin/plugin.json create mode 100644 plugins/cjysa-pdf/skills/work-with-pdf/SKILL.md create mode 100644 plugins/cjysa-pdf/skills/work-with-pdf/agents/openai.yaml create mode 100644 plugins/cjysa-pdf/skills/work-with-pdf/references/quality-checks.md create mode 100644 plugins/cjysa-pdf/skills/work-with-pdf/scripts/pdf_tool.py create mode 100644 plugins/cjysa-presentations/.codex-plugin/plugin.json create mode 100644 plugins/cjysa-presentations/skills/build-presentations/SKILL.md create mode 100644 plugins/cjysa-presentations/skills/build-presentations/agents/openai.yaml create mode 100644 plugins/cjysa-presentations/skills/build-presentations/references/quality-checks.md create mode 100644 plugins/cjysa-presentations/skills/build-presentations/scripts/pptx_tool.py create mode 100644 plugins/cjysa-spreadsheets/.codex-plugin/plugin.json create mode 100644 plugins/cjysa-spreadsheets/skills/build-spreadsheets/SKILL.md create mode 100644 plugins/cjysa-spreadsheets/skills/build-spreadsheets/agents/openai.yaml create mode 100644 plugins/cjysa-spreadsheets/skills/build-spreadsheets/references/quality-checks.md create mode 100644 plugins/cjysa-spreadsheets/skills/build-spreadsheets/scripts/xlsx_tool.py diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 5f60d3c..44e6fa5 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -27,6 +27,54 @@ "authentication": "ON_INSTALL" }, "category": "Developer Tools" + }, + { + "name": "cjysa-documents", + "source": { + "source": "local", + "path": "./plugins/cjysa-documents" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "cjysa-pdf", + "source": { + "source": "local", + "path": "./plugins/cjysa-pdf" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "cjysa-spreadsheets", + "source": { + "source": "local", + "path": "./plugins/cjysa-spreadsheets" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + }, + { + "name": "cjysa-presentations", + "source": { + "source": "local", + "path": "./plugins/cjysa-presentations" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" } ] } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a5bb25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d7843e..8593ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.0 - 2026-07-23 + +- 添加原创 `cjysa-documents`、`cjysa-pdf`、`cjysa-spreadsheets` 和 `cjysa-presentations`。 +- 为四类办公文件提供中文工作流、结构检查和可执行辅助脚本。 +- 明确采用功能等价实现,不复制官方插件脚本、模板、素材或内部文档。 +- 更新免费市场安装地址为自建 Gitea。 + ## 0.2.0 - 2026-07-22 - 添加原创 `git-tools` 插件。 diff --git a/README.md b/README.md index cbc343c..e2e4eed 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ CJYSA 面向 Codex 用户提供的免费插件市场。 在 Codex 中添加以下市场来源: ```text -CJYSEX/codex- +https://git.cjysa.top/CJYSA/codex-free-market.git ``` -添加后安装 `cjysa-relay-basic`。 +Git 引用填写 `main`,稀疏路径留空。添加后按需安装插件。 ## 当前插件 @@ -34,6 +34,42 @@ CJYSEX/codex- - SSH、HTTPS 和权限故障排查 - 凭据脱敏与本地修改保护 +### cjysa-documents + +原创中文 Word 工作流,支持: + +- DOCX 创建、读取、局部编辑和结构检查 +- 标题、段落、表格、分节和图片关系概览 +- 简单文本生成 DOCX 和安全替换 +- 可用时执行逐页渲染检查 + +### cjysa-pdf + +原创中文 PDF 工作流,支持: + +- PDF 信息与文本提取 +- 合并、拆分、旋转和基础文本 PDF 生成 +- Poppler 页面渲染与视觉检查 +- 加密文件和扫描件的安全边界 + +### cjysa-spreadsheets + +原创中文表格工作流,支持: + +- XLSX、CSV、TSV 创建、读取和编辑 +- CSV/TSV 转带基础格式的 XLSX +- 工作表结构、公式数量和常见错误扫描 +- 使用可审计公式并保留现有格式 + +### cjysa-presentations + +原创中文演示文稿工作流,支持: + +- PPTX 创建、读取、编辑和模板跟随 +- 幻灯片结构、标题、图片和文字概览 +- 空白页、越界对象和过小字号检查 +- 逐页预览、叙事和版式交付检查 + ## 安全 本仓库不包含任何客户 API Key、服务器密码或中转上游凭据。提交问题或诊断日志前,请先删除授权头、令牌、Cookie 和签名链接。 diff --git a/plugins/cjysa-documents/.codex-plugin/plugin.json b/plugins/cjysa-documents/.codex-plugin/plugin.json new file mode 100644 index 0000000..2eae077 --- /dev/null +++ b/plugins/cjysa-documents/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "cjysa-documents", + "version": "0.1.0", + "description": "面向中文 Codex 用户的原创 Word 文档创建、编辑、检查与验证插件。", + "author": { + "name": "CJYSEX", + "url": "https://git.cjysa.top/CJYSA" + }, + "homepage": "https://git.cjysa.top/CJYSA/codex-free-market", + "repository": "https://git.cjysa.top/CJYSA/codex-free-market", + "license": "Apache-2.0", + "keywords": ["docx", "word", "document", "中文", "office"], + "skills": "./skills/", + "interface": { + "displayName": "CJYSA 文档", + "shortDescription": "创建、编辑、审阅并验证 Word 文档。", + "longDescription": "读取、创建和编辑 DOCX 文档,优先保留原格式,并通过结构检查和可用的渲染工具完成交付前验证。", + "developerName": "CJYSEX", + "category": "Productivity", + "capabilities": ["Interactive", "Read", "Write"], + "websiteURL": "https://git.cjysa.top/CJYSA/codex-free-market", + "brandColor": "#2563EB", + "defaultPrompt": [ + "把这份内容整理成一份专业的 Word 文档。", + "在保留格式的前提下修改这个 DOCX。", + "检查这份 Word 文档是否存在结构或排版问题。" + ] + } +} diff --git a/plugins/cjysa-documents/skills/create-edit-documents/SKILL.md b/plugins/cjysa-documents/skills/create-edit-documents/SKILL.md new file mode 100644 index 0000000..3476bf0 --- /dev/null +++ b/plugins/cjysa-documents/skills/create-edit-documents/SKILL.md @@ -0,0 +1,46 @@ +--- +name: create-edit-documents +description: 创建、读取、编辑、审阅和验证 DOCX/Word 文档。用户要求制作报告、方案、备忘录、合同草稿、修改已有 Word 文件、提取文档结构、检查排版或保留模板格式时使用。 +--- + +# CJYSA 文档 + +## 工作原则 + +- 先判断任务是只读、创建还是编辑;只读任务不要改写文件。 +- 创建新文档时优先使用 Codex 工作区依赖加载器返回的 Python 和 `python-docx`。 +- 编辑已有文档时保留原文件,输出新副本;除非用户明确要求重排,只做局部修改。 +- 任何交付都先做结构检查;具备 DOCX 渲染能力时,再逐页检查预览。 +- 不复制或依赖 OpenAI 官方 Documents 插件中的脚本、模板或素材。 + +## 标准流程 + +1. 调用工作区依赖加载器,使用其返回的 Python 路径,不猜测运行时位置。 +2. 对输入文档运行: + +```powershell +& "" "\scripts\docx_tool.py" inspect "" +``` + +3. 创建文档时,先确定页面尺寸、边距、标题层级、字体、段落间距和表格样式,再编写任务专用 builder。简单文本可直接使用: + +```powershell +& "" "\scripts\docx_tool.py" create ` + --input "" --output "" --title "标题" +``` + +4. 简单文字替换可使用 `replace`;涉及跨段、批注、修订、目录、页眉页脚或复杂表格时,编写任务专用 OOXML/python-docx 脚本并保留备份。 +5. 再次运行 `inspect`,确认文档可打开、段落和表格数量合理、无意外空白内容。 +6. 若环境提供 Word、WPS、LibreOffice 或其他可靠渲染方式,导出 PDF/PNG 并逐页查看;不可渲染时明确说明只完成了结构验证。 + +## 编辑约束 + +- 不覆盖用户原文件,除非用户明确授权。 +- 尽量保留段落样式、分节、页眉页脚、图片关系和表格宽度。 +- 不把所有内容重新写成一个段落来完成局部修改。 +- 涉及敏感信息时,不在日志中输出全文;只输出统计或脱敏摘要。 +- 交付前按 [quality-checks.md](references/quality-checks.md) 检查。 + +## 输出 + +最终只链接用户要求的 DOCX,不主动附带内部检查日志或预览文件。 diff --git a/plugins/cjysa-documents/skills/create-edit-documents/agents/openai.yaml b/plugins/cjysa-documents/skills/create-edit-documents/agents/openai.yaml new file mode 100644 index 0000000..9eff8be --- /dev/null +++ b/plugins/cjysa-documents/skills/create-edit-documents/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "CJYSA 文档" + short_description: "创建、编辑、审阅并验证专业 Word 文档文件" + default_prompt: "Use $create-edit-documents to create or edit a polished DOCX file." + +policy: + allow_implicit_invocation: true diff --git a/plugins/cjysa-documents/skills/create-edit-documents/references/quality-checks.md b/plugins/cjysa-documents/skills/create-edit-documents/references/quality-checks.md new file mode 100644 index 0000000..9a00f22 --- /dev/null +++ b/plugins/cjysa-documents/skills/create-edit-documents/references/quality-checks.md @@ -0,0 +1,9 @@ +# DOCX 交付检查 + +- 文件可正常打开,页数和内容量符合预期。 +- 标题层级连续,正文样式统一,无意外空段落。 +- 表格没有明显缺列、空表或极端列宽。 +- 页眉、页脚、页码和分节未被局部编辑破坏。 +- 图片关系有效,替代文本在需要无障碍交付时存在。 +- 若完成渲染:逐页确认无裁切、重叠、乱码和异常空白页。 +- 若未完成渲染:在交付说明中明确只通过结构检查。 diff --git a/plugins/cjysa-documents/skills/create-edit-documents/scripts/docx_tool.py b/plugins/cjysa-documents/skills/create-edit-documents/scripts/docx_tool.py new file mode 100644 index 0000000..bd05409 --- /dev/null +++ b/plugins/cjysa-documents/skills/create-edit-documents/scripts/docx_tool.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Small, original DOCX helper for structural inspection and simple edits.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.shared import Cm, Pt + + +def ensure_distinct(source: Path, output: Path) -> None: + if source.resolve() == output.resolve(): + raise SystemExit("Refusing to overwrite the input file; choose a different output path.") + + +def inspect_docx(path: Path) -> None: + doc = Document(path) + headings = [] + nonempty = 0 + for index, paragraph in enumerate(doc.paragraphs, start=1): + text = paragraph.text.strip() + if text: + nonempty += 1 + if text and paragraph.style and paragraph.style.name.lower().startswith("heading"): + headings.append({"paragraph": index, "style": paragraph.style.name, "text": text[:160]}) + + tables = [] + for index, table in enumerate(doc.tables, start=1): + tables.append( + { + "table": index, + "rows": len(table.rows), + "columns": max((len(row.cells) for row in table.rows), default=0), + } + ) + + relationships = doc.part.rels.values() + images = sum(1 for rel in relationships if "image" in rel.reltype) + report = { + "file": str(path.resolve()), + "paragraphs": len(doc.paragraphs), + "nonempty_paragraphs": nonempty, + "tables": tables, + "sections": len(doc.sections), + "images": images, + "headings": headings, + } + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +def add_content(doc: Document, text: str) -> None: + for raw in text.splitlines(): + line = raw.rstrip() + if not line: + doc.add_paragraph() + elif line.startswith("### "): + doc.add_heading(line[4:].strip(), level=3) + elif line.startswith("## "): + doc.add_heading(line[3:].strip(), level=2) + elif line.startswith("# "): + doc.add_heading(line[2:].strip(), level=1) + elif line.startswith(("- ", "* ")): + doc.add_paragraph(line[2:].strip(), style="List Bullet") + elif len(line) > 3 and line[:3].rstrip(".").isdigit() and line[1:3] in {". ", "、"}: + doc.add_paragraph(line[3:].strip(), style="List Number") + else: + doc.add_paragraph(line) + + +def create_docx(input_path: Path, output: Path, title: str | None) -> None: + doc = Document() + section = doc.sections[0] + section.top_margin = Cm(2.3) + section.bottom_margin = Cm(2.3) + section.left_margin = Cm(2.5) + section.right_margin = Cm(2.5) + + normal = doc.styles["Normal"] + normal.font.name = "Microsoft YaHei" + normal.font.size = Pt(10.5) + normal.paragraph_format.space_after = Pt(6) + normal.paragraph_format.line_spacing = 1.35 + + if title: + paragraph = doc.add_paragraph() + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = paragraph.add_run(title) + run.bold = True + run.font.name = "Microsoft YaHei" + run.font.size = Pt(22) + paragraph.paragraph_format.space_after = Pt(18) + + add_content(doc, input_path.read_text(encoding="utf-8")) + output.parent.mkdir(parents=True, exist_ok=True) + doc.save(output) + inspect_docx(output) + + +def replace_text(source: Path, output: Path, old: str, new: str) -> None: + ensure_distinct(source, output) + doc = Document(source) + replacements = 0 + for paragraph in doc.paragraphs: + if old in paragraph.text: + original = paragraph.text + for run in paragraph.runs: + run.text = "" + paragraph.add_run(original.replace(old, new)) + replacements += original.count(old) + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for paragraph in cell.paragraphs: + if old in paragraph.text: + original = paragraph.text + for run in paragraph.runs: + run.text = "" + paragraph.add_run(original.replace(old, new)) + replacements += original.count(old) + output.parent.mkdir(parents=True, exist_ok=True) + doc.save(output) + print(json.dumps({"output": str(output.resolve()), "replacements": replacements}, ensure_ascii=False)) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + + inspect_cmd = sub.add_parser("inspect") + inspect_cmd.add_argument("input", type=Path) + + create_cmd = sub.add_parser("create") + create_cmd.add_argument("--input", required=True, type=Path) + create_cmd.add_argument("--output", required=True, type=Path) + create_cmd.add_argument("--title") + + replace_cmd = sub.add_parser("replace") + replace_cmd.add_argument("input", type=Path) + replace_cmd.add_argument("--output", required=True, type=Path) + replace_cmd.add_argument("--old", required=True) + replace_cmd.add_argument("--new", required=True) + return parser + + +def main() -> None: + args = build_parser().parse_args() + if args.command == "inspect": + inspect_docx(args.input) + elif args.command == "create": + create_docx(args.input, args.output, args.title) + elif args.command == "replace": + replace_text(args.input, args.output, args.old, args.new) + + +if __name__ == "__main__": + main() diff --git a/plugins/cjysa-pdf/.codex-plugin/plugin.json b/plugins/cjysa-pdf/.codex-plugin/plugin.json new file mode 100644 index 0000000..2046564 --- /dev/null +++ b/plugins/cjysa-pdf/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "cjysa-pdf", + "version": "0.1.0", + "description": "面向中文 Codex 用户的原创 PDF 读取、生成、处理与验证插件。", + "author": { + "name": "CJYSEX", + "url": "https://git.cjysa.top/CJYSA" + }, + "homepage": "https://git.cjysa.top/CJYSA/codex-free-market", + "repository": "https://git.cjysa.top/CJYSA/codex-free-market", + "license": "Apache-2.0", + "keywords": ["pdf", "extract", "merge", "split", "render", "中文"], + "skills": "./skills/", + "interface": { + "displayName": "CJYSA PDF", + "shortDescription": "读取、生成、合并、拆分并验证 PDF。", + "longDescription": "支持 PDF 文本提取、页面信息检查、合并、拆分、旋转和基础生成,并要求在可用时渲染页面进行视觉检查。", + "developerName": "CJYSEX", + "category": "Productivity", + "capabilities": ["Interactive", "Read", "Write"], + "websiteURL": "https://git.cjysa.top/CJYSA/codex-free-market", + "brandColor": "#DC2626", + "defaultPrompt": [ + "提取并总结这个 PDF 的内容。", + "把这些 PDF 合并并检查页面是否正常。", + "根据这份内容生成一份排版清晰的 PDF。" + ] + } +} diff --git a/plugins/cjysa-pdf/skills/work-with-pdf/SKILL.md b/plugins/cjysa-pdf/skills/work-with-pdf/SKILL.md new file mode 100644 index 0000000..ba7b44c --- /dev/null +++ b/plugins/cjysa-pdf/skills/work-with-pdf/SKILL.md @@ -0,0 +1,37 @@ +--- +name: work-with-pdf +description: 读取、提取、生成、合并、拆分、旋转和验证 PDF 文件。用户要求总结 PDF、提取页面文本、重排页面、制作 PDF 报告或检查 PDF 排版时使用。 +--- + +# CJYSA PDF + +## 标准流程 + +1. 调用工作区依赖加载器,优先使用返回的 Python、Poppler 和相关包。 +2. 先运行 `info` 获取页数、页面尺寸、元数据和加密状态。 +3. 读取内容时使用 `extract`;遇到扫描件且无文本层时,再选择 OCR 工具,不把空文本误判为无内容。 +4. 修改文件时输出新副本,使用 `merge`、`split` 或 `rotate`。 +5. 创建 PDF 时优先使用任务专用 ReportLab builder;简单纯文本可用 `create-text`。 +6. 用 `pdftoppm` 或等价工具渲染所有页面并逐页检查;至少确认无裁切、重叠、黑块、乱码和空白页。 + +## 命令 + +```powershell +& "" "\scripts\pdf_tool.py" info "" +& "" "\scripts\pdf_tool.py" extract "" --output "" +& "" "\scripts\pdf_tool.py" merge "" "" --output "" +& "" "\scripts\pdf_tool.py" split "" --pages "1-3,7" --output "" +& "" "\scripts\pdf_tool.py" rotate "" --degrees 90 --output "" +``` + +## 约束 + +- 不覆盖原文件,除非用户明确授权。 +- 对加密 PDF 不尝试绕过密码。 +- 文本提取不能替代视觉检查;表格、图形和阅读顺序必须结合页面图像判断。 +- 不复制或依赖 OpenAI 官方 PDF 插件中的素材或实现。 +- 交付前按 [quality-checks.md](references/quality-checks.md) 检查。 + +## 输出 + +最终只链接用户要求的 PDF;除非用户要求,不提供中间拆分页、渲染图和日志。 diff --git a/plugins/cjysa-pdf/skills/work-with-pdf/agents/openai.yaml b/plugins/cjysa-pdf/skills/work-with-pdf/agents/openai.yaml new file mode 100644 index 0000000..516980e --- /dev/null +++ b/plugins/cjysa-pdf/skills/work-with-pdf/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "CJYSA PDF" + short_description: "读取、生成、合并、拆分并验证 PDF 文件内容" + default_prompt: "Use $work-with-pdf to inspect, process, or create a PDF file." + +policy: + allow_implicit_invocation: true diff --git a/plugins/cjysa-pdf/skills/work-with-pdf/references/quality-checks.md b/plugins/cjysa-pdf/skills/work-with-pdf/references/quality-checks.md new file mode 100644 index 0000000..1feba1c --- /dev/null +++ b/plugins/cjysa-pdf/skills/work-with-pdf/references/quality-checks.md @@ -0,0 +1,8 @@ +# PDF 交付检查 + +- 文件未损坏,页数、页面顺序和旋转方向正确。 +- 页面尺寸符合预期,没有意外混用尺寸。 +- 可搜索文档具有可用文本层;扫描件明确标记 OCR 状态。 +- 书签、链接、表单和附件如在任务范围内,均单独检查。 +- 渲染全部页面,确认无裁切、重叠、乱码、黑块和空白页。 +- 生成文件不包含临时路径、调试文本或工具占位符。 diff --git a/plugins/cjysa-pdf/skills/work-with-pdf/scripts/pdf_tool.py b/plugins/cjysa-pdf/skills/work-with-pdf/scripts/pdf_tool.py new file mode 100644 index 0000000..d855cd5 --- /dev/null +++ b/plugins/cjysa-pdf/skills/work-with-pdf/scripts/pdf_tool.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Original PDF helper covering common deterministic operations.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import pdfplumber +from pypdf import PdfReader, PdfWriter +from reportlab.lib.enums import TA_LEFT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.pdfbase.cidfonts import UnicodeCIDFont +from reportlab.pdfbase.pdfmetrics import registerFont +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer + + +def page_numbers(spec: str, total: int) -> list[int]: + result: list[int] = [] + for chunk in spec.split(","): + part = chunk.strip() + if not part: + continue + if "-" in part: + start_text, end_text = part.split("-", 1) + start, end = int(start_text), int(end_text) + result.extend(range(start, end + 1)) + else: + result.append(int(part)) + if not result or any(number < 1 or number > total for number in result): + raise SystemExit(f"Invalid page selection for a {total}-page PDF: {spec}") + return result + + +def write_pdf(writer: PdfWriter, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("wb") as handle: + writer.write(handle) + print(json.dumps({"output": str(output.resolve()), "pages": len(writer.pages)}, ensure_ascii=False)) + + +def info(path: Path) -> None: + reader = PdfReader(path) + sizes = [] + for index, page in enumerate(reader.pages, start=1): + sizes.append( + { + "page": index, + "width_points": round(float(page.mediabox.width), 2), + "height_points": round(float(page.mediabox.height), 2), + "rotation": int(page.rotation or 0), + } + ) + metadata = {str(key): str(value) for key, value in (reader.metadata or {}).items()} + report = { + "file": str(path.resolve()), + "pages": len(reader.pages), + "encrypted": reader.is_encrypted, + "metadata": metadata, + "page_sizes": sizes, + } + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +def extract(path: Path, output: Path) -> None: + pages = [] + with pdfplumber.open(path) as document: + for index, page in enumerate(document.pages, start=1): + pages.append(f"===== Page {index} =====\n{page.extract_text() or ''}".rstrip()) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n\n".join(pages) + "\n", encoding="utf-8") + print(json.dumps({"output": str(output.resolve()), "pages": len(pages)}, ensure_ascii=False)) + + +def merge(inputs: list[Path], output: Path) -> None: + writer = PdfWriter() + for path in inputs: + reader = PdfReader(path) + for page in reader.pages: + writer.add_page(page) + write_pdf(writer, output) + + +def split(source: Path, selection: str, output: Path) -> None: + reader = PdfReader(source) + writer = PdfWriter() + for number in page_numbers(selection, len(reader.pages)): + writer.add_page(reader.pages[number - 1]) + write_pdf(writer, output) + + +def rotate(source: Path, degrees: int, output: Path) -> None: + if degrees not in {90, 180, 270}: + raise SystemExit("Rotation must be 90, 180, or 270 degrees.") + reader = PdfReader(source) + writer = PdfWriter() + for page in reader.pages: + page.rotate(degrees) + writer.add_page(page) + write_pdf(writer, output) + + +def create_text(input_path: Path, output: Path, title: str | None) -> None: + registerFont(UnicodeCIDFont("STSong-Light")) + styles = getSampleStyleSheet() + body = ParagraphStyle( + "ChineseBody", + parent=styles["BodyText"], + fontName="STSong-Light", + fontSize=10.5, + leading=16, + alignment=TA_LEFT, + spaceAfter=8, + ) + heading = ParagraphStyle( + "ChineseTitle", + parent=body, + fontSize=20, + leading=26, + spaceAfter=18, + ) + story = [] + if title: + story.append(Paragraph(title.replace("&", "&"), heading)) + for block in input_path.read_text(encoding="utf-8").split("\n\n"): + value = block.strip() + if value: + escaped = value.replace("&", "&").replace("<", "<").replace(">", ">") + story.append(Paragraph(escaped.replace("\n", "
"), body)) + story.append(Spacer(1, 4)) + output.parent.mkdir(parents=True, exist_ok=True) + document = SimpleDocTemplate(str(output), pagesize=A4, leftMargin=54, rightMargin=54, topMargin=54, bottomMargin=54) + document.build(story) + info(output) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + info_cmd = sub.add_parser("info") + info_cmd.add_argument("input", type=Path) + extract_cmd = sub.add_parser("extract") + extract_cmd.add_argument("input", type=Path) + extract_cmd.add_argument("--output", required=True, type=Path) + merge_cmd = sub.add_parser("merge") + merge_cmd.add_argument("inputs", nargs="+", type=Path) + merge_cmd.add_argument("--output", required=True, type=Path) + split_cmd = sub.add_parser("split") + split_cmd.add_argument("input", type=Path) + split_cmd.add_argument("--pages", required=True) + split_cmd.add_argument("--output", required=True, type=Path) + rotate_cmd = sub.add_parser("rotate") + rotate_cmd.add_argument("input", type=Path) + rotate_cmd.add_argument("--degrees", required=True, type=int) + rotate_cmd.add_argument("--output", required=True, type=Path) + create_cmd = sub.add_parser("create-text") + create_cmd.add_argument("--input", required=True, type=Path) + create_cmd.add_argument("--output", required=True, type=Path) + create_cmd.add_argument("--title") + return parser + + +def main() -> None: + args = build_parser().parse_args() + if args.command == "info": + info(args.input) + elif args.command == "extract": + extract(args.input, args.output) + elif args.command == "merge": + merge(args.inputs, args.output) + elif args.command == "split": + split(args.input, args.pages, args.output) + elif args.command == "rotate": + rotate(args.input, args.degrees, args.output) + elif args.command == "create-text": + create_text(args.input, args.output, args.title) + + +if __name__ == "__main__": + main() diff --git a/plugins/cjysa-presentations/.codex-plugin/plugin.json b/plugins/cjysa-presentations/.codex-plugin/plugin.json new file mode 100644 index 0000000..0569ad4 --- /dev/null +++ b/plugins/cjysa-presentations/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "cjysa-presentations", + "version": "0.1.0", + "description": "面向中文 Codex 用户的原创 PowerPoint 创建、编辑、检查与验证插件。", + "author": { + "name": "CJYSEX", + "url": "https://git.cjysa.top/CJYSA" + }, + "homepage": "https://git.cjysa.top/CJYSA/codex-free-market", + "repository": "https://git.cjysa.top/CJYSA/codex-free-market", + "license": "Apache-2.0", + "keywords": ["pptx", "powerpoint", "slides", "presentation", "中文"], + "skills": "./skills/", + "interface": { + "displayName": "CJYSA 演示文稿", + "shortDescription": "创建、编辑、检查并验证 PowerPoint。", + "longDescription": "创建或修改 PPTX 演示文稿,优先保证叙事、可读性和模板一致性,并通过结构检查与逐页预览减少溢出和重叠。", + "developerName": "CJYSEX", + "category": "Productivity", + "capabilities": ["Interactive", "Read", "Write"], + "websiteURL": "https://git.cjysa.top/CJYSA/codex-free-market", + "brandColor": "#C43E1C", + "defaultPrompt": [ + "根据这份材料制作一套结构清晰的 PPT。", + "保留模板风格并修改这个演示文稿。", + "检查这份 PPT 是否存在文字溢出或空白页。" + ] + } +} diff --git a/plugins/cjysa-presentations/skills/build-presentations/SKILL.md b/plugins/cjysa-presentations/skills/build-presentations/SKILL.md new file mode 100644 index 0000000..90c0e46 --- /dev/null +++ b/plugins/cjysa-presentations/skills/build-presentations/SKILL.md @@ -0,0 +1,40 @@ +--- +name: build-presentations +description: 创建、读取、编辑和验证 PPTX/PowerPoint 演示文稿。用户要求制作幻灯片、改写已有 PPT、沿用模板、提取演示内容、检查文字溢出或逐页优化叙事时使用。 +--- + +# CJYSA 演示文稿 + +## 先规划再制作 + +1. 明确受众、目的、演讲时长、页数和视觉风格。 +2. 先写一行式故事线:背景 → 问题/机会 → 证据 → 方案 → 行动。 +3. 每页只承担一个主要任务;标题应表达结论,而不是只写主题名称。 +4. 有模板时以模板为唯一视觉基准;没有模板时使用统一的字体、色板、网格和留白。 + +## 实现流程 + +1. 调用工作区依赖加载器。优先使用 `@oai/artifact-tool`;不可用时使用其 Python 和 `python-pptx`。 +2. 先检查输入演示: + +```powershell +& "" "\scripts\pptx_tool.py" inspect "" +``` + +3. 创建或编辑 PPTX,保留原文件并输出新副本。 +4. 运行 `validate`,检查空白页、超出画布的对象、过小字号和疑似标题换行。 +5. 使用 artifact-tool、PowerPoint、WPS 或可靠转换器渲染全部幻灯片;逐页全尺寸检查,不把缩略图拼图当作最终验证。 +6. 修复重叠、裁切、断行、低对比度、空占位符和数据不一致后再交付。 + +## 设计底线 + +- 无模板时,标题页标题通常不小于 40pt,普通页标题不小于 28pt,正文通常不小于 16pt。 +- 内容过多时先删减或拆页,不优先缩小字体。 +- 图表必须标注单位、时间范围和来源;不要用装饰图替代证据。 +- 不复制 OpenAI 官方 Presentations 插件的模板、脚本、素材或内部文档。 +- 图片生成/搜索能力不可用时,使用用户素材、可授权本地素材或清晰的占位说明,不伪造已生成图片。 +- 交付前按 [quality-checks.md](references/quality-checks.md) 检查。 + +## 输出 + +最终只链接用户要求的 PPTX,不主动附带 builder、逐页 PNG、拼图或检查日志。 diff --git a/plugins/cjysa-presentations/skills/build-presentations/agents/openai.yaml b/plugins/cjysa-presentations/skills/build-presentations/agents/openai.yaml new file mode 100644 index 0000000..6754c40 --- /dev/null +++ b/plugins/cjysa-presentations/skills/build-presentations/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "CJYSA 演示文稿" + short_description: "创建、编辑、检查并验证专业 PowerPoint 演示文稿" + default_prompt: "Use $build-presentations to create or edit a clear PowerPoint deck." + +policy: + allow_implicit_invocation: true diff --git a/plugins/cjysa-presentations/skills/build-presentations/references/quality-checks.md b/plugins/cjysa-presentations/skills/build-presentations/references/quality-checks.md new file mode 100644 index 0000000..13371bf --- /dev/null +++ b/plugins/cjysa-presentations/skills/build-presentations/references/quality-checks.md @@ -0,0 +1,9 @@ +# PPTX 交付检查 + +- 故事线完整,每页有明确结论或作用。 +- 无空白页、未替换占位符、重复标题或内部制作说明。 +- 所有对象位于画布内,无非预期重叠和裁切。 +- 标题不意外换行,正文最小字号仍可阅读。 +- 图表数据、单位、时间范围和来源一致。 +- 逐页全尺寸检查;拼图只用于整体节奏检查。 +- 若无法渲染,明确说明只通过结构和边界检查。 diff --git a/plugins/cjysa-presentations/skills/build-presentations/scripts/pptx_tool.py b/plugins/cjysa-presentations/skills/build-presentations/scripts/pptx_tool.py new file mode 100644 index 0000000..444dae1 --- /dev/null +++ b/plugins/cjysa-presentations/skills/build-presentations/scripts/pptx_tool.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Original PPTX helper for structural inspection and common QA warnings.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE_TYPE +from pptx.util import Pt + + +def shape_text(shape) -> str: + if not getattr(shape, "has_text_frame", False): + return "" + return "\n".join(paragraph.text for paragraph in shape.text_frame.paragraphs).strip() + + +def inspect(path: Path) -> None: + deck = Presentation(path) + slides = [] + for index, slide in enumerate(deck.slides, start=1): + texts = [shape_text(shape) for shape in slide.shapes] + nonempty_texts = [text for text in texts if text] + slides.append( + { + "slide": index, + "shapes": len(slide.shapes), + "text_shapes": len(nonempty_texts), + "images": sum(1 for shape in slide.shapes if shape.shape_type == MSO_SHAPE_TYPE.PICTURE), + "title": (slide.shapes.title.text.strip() if slide.shapes.title and slide.shapes.title.text else None), + "text_preview": " | ".join(nonempty_texts)[:300], + } + ) + report = { + "file": str(path.resolve()), + "slides": len(deck.slides), + "width_inches": round(deck.slide_width / 914400, 3), + "height_inches": round(deck.slide_height / 914400, 3), + "slide_details": slides, + } + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +def validate(path: Path, minimum_font_pt: float) -> None: + deck = Presentation(path) + warnings = [] + for slide_index, slide in enumerate(deck.slides, start=1): + meaningful = 0 + for shape_index, shape in enumerate(slide.shapes, start=1): + text = shape_text(shape) + if text or shape.shape_type == MSO_SHAPE_TYPE.PICTURE: + meaningful += 1 + if shape.left < 0 or shape.top < 0 or shape.left + shape.width > deck.slide_width or shape.top + shape.height > deck.slide_height: + warnings.append({"slide": slide_index, "shape": shape_index, "kind": "outside_slide"}) + if getattr(shape, "has_text_frame", False): + for paragraph in shape.text_frame.paragraphs: + for run in paragraph.runs: + if run.font.size and run.font.size < Pt(minimum_font_pt): + warnings.append( + { + "slide": slide_index, + "shape": shape_index, + "kind": "small_font", + "font_pt": round(run.font.size.pt, 1), + "text": run.text[:80], + } + ) + if meaningful == 0: + warnings.append({"slide": slide_index, "kind": "blank_slide"}) + title = slide.shapes.title + if title and title.has_text_frame and "\n" in title.text.strip(): + warnings.append({"slide": slide_index, "kind": "multiline_title", "text": title.text.strip()[:160]}) + print(json.dumps({"file": str(path.resolve()), "warnings": warnings, "count": len(warnings)}, ensure_ascii=False, indent=2)) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + inspect_cmd = sub.add_parser("inspect") + inspect_cmd.add_argument("input", type=Path) + validate_cmd = sub.add_parser("validate") + validate_cmd.add_argument("input", type=Path) + validate_cmd.add_argument("--minimum-font-pt", type=float, default=14.0) + return parser + + +def main() -> None: + args = build_parser().parse_args() + if args.command == "inspect": + inspect(args.input) + elif args.command == "validate": + validate(args.input, args.minimum_font_pt) + + +if __name__ == "__main__": + main() diff --git a/plugins/cjysa-spreadsheets/.codex-plugin/plugin.json b/plugins/cjysa-spreadsheets/.codex-plugin/plugin.json new file mode 100644 index 0000000..77f29d9 --- /dev/null +++ b/plugins/cjysa-spreadsheets/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "cjysa-spreadsheets", + "version": "0.1.0", + "description": "面向中文 Codex 用户的原创 Excel 表格创建、编辑、分析与验证插件。", + "author": { + "name": "CJYSEX", + "url": "https://git.cjysa.top/CJYSA" + }, + "homepage": "https://git.cjysa.top/CJYSA/codex-free-market", + "repository": "https://git.cjysa.top/CJYSA/codex-free-market", + "license": "Apache-2.0", + "keywords": ["xlsx", "excel", "csv", "spreadsheet", "formula", "中文"], + "skills": "./skills/", + "interface": { + "displayName": "CJYSA 表格", + "shortDescription": "创建、编辑、分析并验证 Excel 表格。", + "longDescription": "处理 XLSX、CSV 和 TSV 文件,保留现有工作簿结构,使用可审计公式,并在交付前检查关键范围、公式错误和视觉布局。", + "developerName": "CJYSEX", + "category": "Productivity", + "capabilities": ["Interactive", "Read", "Write"], + "websiteURL": "https://git.cjysa.top/CJYSA/codex-free-market", + "brandColor": "#107C41", + "defaultPrompt": [ + "把这份 CSV 整理成带公式和格式的 Excel。", + "分析这个工作簿并解释关键指标。", + "修改这个表格,同时保留原有公式和格式。" + ] + } +} diff --git a/plugins/cjysa-spreadsheets/skills/build-spreadsheets/SKILL.md b/plugins/cjysa-spreadsheets/skills/build-spreadsheets/SKILL.md new file mode 100644 index 0000000..4088bc4 --- /dev/null +++ b/plugins/cjysa-spreadsheets/skills/build-spreadsheets/SKILL.md @@ -0,0 +1,45 @@ +--- +name: build-spreadsheets +description: 创建、读取、编辑、分析和验证 XLSX、CSV、TSV 表格文件。用户要求制作 Excel、整理 CSV、加入公式和图表、分析工作簿、修复公式或保留模板格式时使用;不用于控制正在 Excel 中打开的实时工作簿。 +--- + +# CJYSA 表格 + +## 路由 + +- 对 CSV/TSV 做纯数据清洗时可直接用 Python。 +- 对 XLSX 创建和编辑,优先使用工作区依赖加载器提供的 `@oai/artifact-tool`;不可用时使用其 Python 和 `openpyxl`。 +- 用户要求控制当前打开的 Excel 窗口时,不使用本技能;需要 Excel/WPS 实时控制能力。 + +## 标准流程 + +1. 先运行结构检查: + +```powershell +& "" "\scripts\xlsx_tool.py" inspect "" +``` + +2. 编辑已有工作簿前检查相关单元格的值、公式、数字格式、合并区域、冻结窗格、条件格式和图表。 +3. 创建工作簿时把输入、计算和输出分区;派生数据使用可审计公式,不把结果硬编码。 +4. 简单 CSV 转 XLSX 可运行: + +```powershell +& "" "\scripts\xlsx_tool.py" csv-to-xlsx ` + "" --output "" +``` + +5. 运行 `formula-scan` 检查明显错误,再读取关键范围核对结果。 +6. 使用 artifact-tool、Excel、WPS 或可靠渲染器查看所有工作表;不可渲染时明确只完成结构和公式检查。 + +## 表格规则 + +- 数字、日期、百分比和货币使用真实类型,不写成带符号的文本。 +- 公式引用保持一致,跨表引用对含空格的工作表名加单引号。 +- 编辑已有表格时做最小改动,不对整张表盲目自动宽度或重设样式。 +- 重要假设集中放置并标注;复杂公式拆成辅助行/列。 +- 不复制 OpenAI 官方 Spreadsheets 插件、内部 API 文档或 App 集成。 +- 交付前按 [quality-checks.md](references/quality-checks.md) 检查。 + +## 输出 + +最终只链接用户要求的 XLSX/CSV/TSV,不主动附带 builder、预览图或检查日志。 diff --git a/plugins/cjysa-spreadsheets/skills/build-spreadsheets/agents/openai.yaml b/plugins/cjysa-spreadsheets/skills/build-spreadsheets/agents/openai.yaml new file mode 100644 index 0000000..7169171 --- /dev/null +++ b/plugins/cjysa-spreadsheets/skills/build-spreadsheets/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "CJYSA 表格" + short_description: "创建、编辑、分析并验证 Excel 工作簿和数据表" + default_prompt: "Use $build-spreadsheets to create or edit an auditable XLSX workbook." + +policy: + allow_implicit_invocation: true diff --git a/plugins/cjysa-spreadsheets/skills/build-spreadsheets/references/quality-checks.md b/plugins/cjysa-spreadsheets/skills/build-spreadsheets/references/quality-checks.md new file mode 100644 index 0000000..84dd49c --- /dev/null +++ b/plugins/cjysa-spreadsheets/skills/build-spreadsheets/references/quality-checks.md @@ -0,0 +1,9 @@ +# 表格交付检查 + +- 工作表名称、可见性、冻结窗格和筛选范围正确。 +- 输入、公式和输出区域清晰,关键公式可追溯。 +- 扫描 `#REF!`、`#DIV/0!`、`#VALUE!`、`#NAME?` 和 `#N/A`。 +- 日期、数字、百分比和货币使用正确的数据类型与格式。 +- 合并单元格、条件格式、图表和打印区域未被意外破坏。 +- 逐表查看预览,确认标题、关键数字和图表未裁切。 +- 若无法重新计算公式,明确说明结果由 Excel/WPS 打开后计算。 diff --git a/plugins/cjysa-spreadsheets/skills/build-spreadsheets/scripts/xlsx_tool.py b/plugins/cjysa-spreadsheets/skills/build-spreadsheets/scripts/xlsx_tool.py new file mode 100644 index 0000000..5d3f031 --- /dev/null +++ b/plugins/cjysa-spreadsheets/skills/build-spreadsheets/scripts/xlsx_tool.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Original XLSX helper for inspection, error scans, and CSV conversion.""" + +from __future__ import annotations + +import argparse +import csv +import json +from pathlib import Path + +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Font, PatternFill +from openpyxl.utils import get_column_letter + + +ERROR_TOKENS = ("#REF!", "#DIV/0!", "#VALUE!", "#NAME?", "#N/A", "#NUM!", "#NULL!") + + +def inspect(path: Path) -> None: + workbook = load_workbook(path, data_only=False, read_only=False) + sheets = [] + for sheet in workbook.worksheets: + formula_count = 0 + nonempty = 0 + for row in sheet.iter_rows(): + for cell in row: + if cell.value is not None: + nonempty += 1 + if isinstance(cell.value, str) and cell.value.startswith("="): + formula_count += 1 + sheets.append( + { + "name": sheet.title, + "state": sheet.sheet_state, + "max_row": sheet.max_row, + "max_column": sheet.max_column, + "nonempty_cells": nonempty, + "formulas": formula_count, + "merged_ranges": len(sheet.merged_cells.ranges), + "charts": len(sheet._charts), + "freeze_panes": str(sheet.freeze_panes) if sheet.freeze_panes else None, + } + ) + print(json.dumps({"file": str(path.resolve()), "sheets": sheets}, ensure_ascii=False, indent=2)) + + +def formula_scan(path: Path) -> None: + formula_book = load_workbook(path, data_only=False, read_only=False) + value_book = load_workbook(path, data_only=True, read_only=False) + findings = [] + for formula_sheet in formula_book.worksheets: + value_sheet = value_book[formula_sheet.title] + for row in formula_sheet.iter_rows(): + for cell in row: + formula = cell.value + cached = value_sheet[cell.coordinate].value + if isinstance(formula, str) and formula.startswith("="): + formula_upper = formula.upper() + if any(token in formula_upper for token in ERROR_TOKENS): + findings.append({"sheet": formula_sheet.title, "cell": cell.coordinate, "kind": "formula_text", "value": formula}) + if isinstance(cached, str) and cached.upper() in ERROR_TOKENS: + findings.append({"sheet": formula_sheet.title, "cell": cell.coordinate, "kind": "cached_error", "value": cached}) + print(json.dumps({"file": str(path.resolve()), "errors": findings, "count": len(findings)}, ensure_ascii=False, indent=2)) + + +def csv_to_xlsx(source: Path, output: Path, delimiter: str | None) -> None: + chosen = delimiter + if chosen is None: + chosen = "\t" if source.suffix.lower() == ".tsv" else "," + workbook = Workbook() + sheet = workbook.active + sheet.title = "Data" + with source.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.reader(handle, delimiter=chosen) + for row_index, row in enumerate(reader, start=1): + for column_index, value in enumerate(row, start=1): + sheet.cell(row=row_index, column=column_index, value=value) + if sheet.max_row >= 1: + for cell in sheet[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="1F4E78") + sheet.freeze_panes = "A2" + sheet.auto_filter.ref = sheet.dimensions + for column in range(1, sheet.max_column + 1): + width = max((len(str(sheet.cell(row=row, column=column).value or "")) for row in range(1, min(sheet.max_row, 200) + 1)), default=8) + sheet.column_dimensions[get_column_letter(column)].width = min(max(width + 2, 10), 45) + output.parent.mkdir(parents=True, exist_ok=True) + workbook.save(output) + inspect(output) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + inspect_cmd = sub.add_parser("inspect") + inspect_cmd.add_argument("input", type=Path) + scan_cmd = sub.add_parser("formula-scan") + scan_cmd.add_argument("input", type=Path) + convert_cmd = sub.add_parser("csv-to-xlsx") + convert_cmd.add_argument("input", type=Path) + convert_cmd.add_argument("--output", required=True, type=Path) + convert_cmd.add_argument("--delimiter") + return parser + + +def main() -> None: + args = build_parser().parse_args() + if args.command == "inspect": + inspect(args.input) + elif args.command == "formula-scan": + formula_scan(args.input) + elif args.command == "csv-to-xlsx": + csv_to_xlsx(args.input, args.output, args.delimiter) + + +if __name__ == "__main__": + main()