feat: add Chinese office productivity plugins
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.DS_Store
|
||||
@@ -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` 插件。
|
||||
|
||||
40
README.md
40
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 和签名链接。
|
||||
|
||||
29
plugins/cjysa-documents/.codex-plugin/plugin.json
Normal file
29
plugins/cjysa-documents/.codex-plugin/plugin.json
Normal file
@@ -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 文档是否存在结构或排版问题。"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: create-edit-documents
|
||||
description: 创建、读取、编辑、审阅和验证 DOCX/Word 文档。用户要求制作报告、方案、备忘录、合同草稿、修改已有 Word 文件、提取文档结构、检查排版或保留模板格式时使用。
|
||||
---
|
||||
|
||||
# CJYSA 文档
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 先判断任务是只读、创建还是编辑;只读任务不要改写文件。
|
||||
- 创建新文档时优先使用 Codex 工作区依赖加载器返回的 Python 和 `python-docx`。
|
||||
- 编辑已有文档时保留原文件,输出新副本;除非用户明确要求重排,只做局部修改。
|
||||
- 任何交付都先做结构检查;具备 DOCX 渲染能力时,再逐页检查预览。
|
||||
- 不复制或依赖 OpenAI 官方 Documents 插件中的脚本、模板或素材。
|
||||
|
||||
## 标准流程
|
||||
|
||||
1. 调用工作区依赖加载器,使用其返回的 Python 路径,不猜测运行时位置。
|
||||
2. 对输入文档运行:
|
||||
|
||||
```powershell
|
||||
& "<bundled-python>" "<skill-dir>\scripts\docx_tool.py" inspect "<input.docx>"
|
||||
```
|
||||
|
||||
3. 创建文档时,先确定页面尺寸、边距、标题层级、字体、段落间距和表格样式,再编写任务专用 builder。简单文本可直接使用:
|
||||
|
||||
```powershell
|
||||
& "<bundled-python>" "<skill-dir>\scripts\docx_tool.py" create `
|
||||
--input "<content.txt>" --output "<output.docx>" --title "标题"
|
||||
```
|
||||
|
||||
4. 简单文字替换可使用 `replace`;涉及跨段、批注、修订、目录、页眉页脚或复杂表格时,编写任务专用 OOXML/python-docx 脚本并保留备份。
|
||||
5. 再次运行 `inspect`,确认文档可打开、段落和表格数量合理、无意外空白内容。
|
||||
6. 若环境提供 Word、WPS、LibreOffice 或其他可靠渲染方式,导出 PDF/PNG 并逐页查看;不可渲染时明确说明只完成了结构验证。
|
||||
|
||||
## 编辑约束
|
||||
|
||||
- 不覆盖用户原文件,除非用户明确授权。
|
||||
- 尽量保留段落样式、分节、页眉页脚、图片关系和表格宽度。
|
||||
- 不把所有内容重新写成一个段落来完成局部修改。
|
||||
- 涉及敏感信息时,不在日志中输出全文;只输出统计或脱敏摘要。
|
||||
- 交付前按 [quality-checks.md](references/quality-checks.md) 检查。
|
||||
|
||||
## 输出
|
||||
|
||||
最终只链接用户要求的 DOCX,不主动附带内部检查日志或预览文件。
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
# DOCX 交付检查
|
||||
|
||||
- 文件可正常打开,页数和内容量符合预期。
|
||||
- 标题层级连续,正文样式统一,无意外空段落。
|
||||
- 表格没有明显缺列、空表或极端列宽。
|
||||
- 页眉、页脚、页码和分节未被局部编辑破坏。
|
||||
- 图片关系有效,替代文本在需要无障碍交付时存在。
|
||||
- 若完成渲染:逐页确认无裁切、重叠、乱码和异常空白页。
|
||||
- 若未完成渲染:在交付说明中明确只通过结构检查。
|
||||
@@ -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()
|
||||
29
plugins/cjysa-pdf/.codex-plugin/plugin.json
Normal file
29
plugins/cjysa-pdf/.codex-plugin/plugin.json
Normal file
@@ -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。"
|
||||
]
|
||||
}
|
||||
}
|
||||
37
plugins/cjysa-pdf/skills/work-with-pdf/SKILL.md
Normal file
37
plugins/cjysa-pdf/skills/work-with-pdf/SKILL.md
Normal file
@@ -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
|
||||
& "<bundled-python>" "<skill-dir>\scripts\pdf_tool.py" info "<input.pdf>"
|
||||
& "<bundled-python>" "<skill-dir>\scripts\pdf_tool.py" extract "<input.pdf>" --output "<text.txt>"
|
||||
& "<bundled-python>" "<skill-dir>\scripts\pdf_tool.py" merge "<a.pdf>" "<b.pdf>" --output "<merged.pdf>"
|
||||
& "<bundled-python>" "<skill-dir>\scripts\pdf_tool.py" split "<input.pdf>" --pages "1-3,7" --output "<part.pdf>"
|
||||
& "<bundled-python>" "<skill-dir>\scripts\pdf_tool.py" rotate "<input.pdf>" --degrees 90 --output "<rotated.pdf>"
|
||||
```
|
||||
|
||||
## 约束
|
||||
|
||||
- 不覆盖原文件,除非用户明确授权。
|
||||
- 对加密 PDF 不尝试绕过密码。
|
||||
- 文本提取不能替代视觉检查;表格、图形和阅读顺序必须结合页面图像判断。
|
||||
- 不复制或依赖 OpenAI 官方 PDF 插件中的素材或实现。
|
||||
- 交付前按 [quality-checks.md](references/quality-checks.md) 检查。
|
||||
|
||||
## 输出
|
||||
|
||||
最终只链接用户要求的 PDF;除非用户要求,不提供中间拆分页、渲染图和日志。
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
# PDF 交付检查
|
||||
|
||||
- 文件未损坏,页数、页面顺序和旋转方向正确。
|
||||
- 页面尺寸符合预期,没有意外混用尺寸。
|
||||
- 可搜索文档具有可用文本层;扫描件明确标记 OCR 状态。
|
||||
- 书签、链接、表单和附件如在任务范围内,均单独检查。
|
||||
- 渲染全部页面,确认无裁切、重叠、乱码、黑块和空白页。
|
||||
- 生成文件不包含临时路径、调试文本或工具占位符。
|
||||
182
plugins/cjysa-pdf/skills/work-with-pdf/scripts/pdf_tool.py
Normal file
182
plugins/cjysa-pdf/skills/work-with-pdf/scripts/pdf_tool.py
Normal file
@@ -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", "<br/>"), 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()
|
||||
29
plugins/cjysa-presentations/.codex-plugin/plugin.json
Normal file
29
plugins/cjysa-presentations/.codex-plugin/plugin.json
Normal file
@@ -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 是否存在文字溢出或空白页。"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
& "<bundled-python>" "<skill-dir>\scripts\pptx_tool.py" inspect "<input.pptx>"
|
||||
```
|
||||
|
||||
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、拼图或检查日志。
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
# PPTX 交付检查
|
||||
|
||||
- 故事线完整,每页有明确结论或作用。
|
||||
- 无空白页、未替换占位符、重复标题或内部制作说明。
|
||||
- 所有对象位于画布内,无非预期重叠和裁切。
|
||||
- 标题不意外换行,正文最小字号仍可阅读。
|
||||
- 图表数据、单位、时间范围和来源一致。
|
||||
- 逐页全尺寸检查;拼图只用于整体节奏检查。
|
||||
- 若无法渲染,明确说明只通过结构和边界检查。
|
||||
@@ -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()
|
||||
29
plugins/cjysa-spreadsheets/.codex-plugin/plugin.json
Normal file
29
plugins/cjysa-spreadsheets/.codex-plugin/plugin.json
Normal file
@@ -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。",
|
||||
"分析这个工作簿并解释关键指标。",
|
||||
"修改这个表格,同时保留原有公式和格式。"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
& "<bundled-python>" "<skill-dir>\scripts\xlsx_tool.py" inspect "<input.xlsx>"
|
||||
```
|
||||
|
||||
2. 编辑已有工作簿前检查相关单元格的值、公式、数字格式、合并区域、冻结窗格、条件格式和图表。
|
||||
3. 创建工作簿时把输入、计算和输出分区;派生数据使用可审计公式,不把结果硬编码。
|
||||
4. 简单 CSV 转 XLSX 可运行:
|
||||
|
||||
```powershell
|
||||
& "<bundled-python>" "<skill-dir>\scripts\xlsx_tool.py" csv-to-xlsx `
|
||||
"<input.csv>" --output "<output.xlsx>"
|
||||
```
|
||||
|
||||
5. 运行 `formula-scan` 检查明显错误,再读取关键范围核对结果。
|
||||
6. 使用 artifact-tool、Excel、WPS 或可靠渲染器查看所有工作表;不可渲染时明确只完成结构和公式检查。
|
||||
|
||||
## 表格规则
|
||||
|
||||
- 数字、日期、百分比和货币使用真实类型,不写成带符号的文本。
|
||||
- 公式引用保持一致,跨表引用对含空格的工作表名加单引号。
|
||||
- 编辑已有表格时做最小改动,不对整张表盲目自动宽度或重设样式。
|
||||
- 重要假设集中放置并标注;复杂公式拆成辅助行/列。
|
||||
- 不复制 OpenAI 官方 Spreadsheets 插件、内部 API 文档或 App 集成。
|
||||
- 交付前按 [quality-checks.md](references/quality-checks.md) 检查。
|
||||
|
||||
## 输出
|
||||
|
||||
最终只链接用户要求的 XLSX/CSV/TSV,不主动附带 builder、预览图或检查日志。
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
# 表格交付检查
|
||||
|
||||
- 工作表名称、可见性、冻结窗格和筛选范围正确。
|
||||
- 输入、公式和输出区域清晰,关键公式可追溯。
|
||||
- 扫描 `#REF!`、`#DIV/0!`、`#VALUE!`、`#NAME?` 和 `#N/A`。
|
||||
- 日期、数字、百分比和货币使用正确的数据类型与格式。
|
||||
- 合并单元格、条件格式、图表和打印区域未被意外破坏。
|
||||
- 逐表查看预览,确认标题、关键数字和图表未裁切。
|
||||
- 若无法重新计算公式,明确说明结果由 Excel/WPS 打开后计算。
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user