mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-30 17:58:00 +08:00
Merge branch 'feat/3.0.0-beta2' into 3.0-vibe
This commit is contained in:
@@ -86,7 +86,7 @@ services:
|
||||
|
||||
backend:
|
||||
container_name: bisheng-backend
|
||||
image: dataelement/bisheng-backend:v2.6.0-fix2
|
||||
image: dataelement/bisheng-backend:v3.0.0-beta1
|
||||
ports:
|
||||
- "7860:7860"
|
||||
environment:
|
||||
@@ -128,7 +128,7 @@ services:
|
||||
|
||||
backend_worker:
|
||||
container_name: bisheng-backend-worker
|
||||
image: dataelement/bisheng-backend:v2.6.0-fix2
|
||||
image: dataelement/bisheng-backend:v3.0.0-beta1
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422"
|
||||
@@ -161,7 +161,7 @@ services:
|
||||
|
||||
frontend:
|
||||
container_name: bisheng-frontend
|
||||
image: dataelement/bisheng-frontend:v2.6.0-fix2
|
||||
image: dataelement/bisheng-frontend:v3.0.0-beta1
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
|
||||
@@ -4,7 +4,7 @@ from importlib import metadata
|
||||
|
||||
try:
|
||||
# SetujuciGo to automatic modification
|
||||
__version__ = '2.6.0-fix2'
|
||||
__version__ = '3.0.0-beta1'
|
||||
except metadata.PackageNotFoundError:
|
||||
# Case where package metadata is not available.
|
||||
__version__ = ''
|
||||
|
||||
@@ -7,7 +7,9 @@ description: >-
|
||||
「方案」「纪要」「公文」「通知」「说明书」「docx」「写一份 X」「整理成 Word」
|
||||
「导出成 Word」时触发。本技能给出 BiSheng 代码执行器里唯一可行的 python-docx 路径
|
||||
(该环境没有 Node/docx-js,中文字体必须显式写 w:eastAsia 才生效),
|
||||
以及中文排版规范、目录页码写法、交付前自检与渲染脚本。
|
||||
内置 GB/T 9704-2012 公文版式为默认档,另有一档给简历宣传稿这类非公文文档;
|
||||
还包括目录页码写法、交付前自检与渲染脚本。
|
||||
本技能只负责版式落地:同时勾选了公文写作类技能时,文种、结构、文风、措辞以那个技能为准。
|
||||
只是把已经写好的回答原样存成 Word、不要求目录页码表格版式的,用 export_docx 更省事。
|
||||
如果用户要的是 Excel 表格、PPT 或纯 Markdown,不要用本技能。
|
||||
metadata:
|
||||
@@ -79,7 +81,7 @@ print("pandoc :", shutil.which("pandoc") or "无(读 .docx 用 inspect_docx.py
|
||||
| 纸张 | **US Letter 21.6×27.9cm**(不是 A4),左右边距 3.2cm,改不了 | A4 纵/横随选,边距可调 |
|
||||
| 标题 | 用内置 Heading 样式,中文字体已设(黑体/楷体) | 同样用内置 Heading,字体字号全可控 |
|
||||
| 目录 / 页码 | **都没有**,也加不了 | `add_toc` + `add_page_number_footer` |
|
||||
| 表格 | 宽度 auto,无表头底纹、无列宽、不重复表头 | 列宽写到每个单元格、表头深底白字、跨页重复表头 |
|
||||
| 表格 | 宽度 auto,无表头底纹、无列宽、不重复表头 | 列宽写到每个单元格、表头按档(企业档深底白字 / 公文档素表加粗)、跨页重复表头 |
|
||||
| 图片 | 固定 5.7 英寸宽 | 按版心等比缩放 + 图注 |
|
||||
| 封面、分节、页眉、分隔线 | 没有 | 都有 |
|
||||
| 代价 | 一次调用,零风险 | 写脚本 + 体检,2–4 轮 |
|
||||
@@ -90,8 +92,41 @@ print("pandoc :", shutil.which("pandoc") or "无(读 .docx 用 inspect_docx.py
|
||||
| 用户要目录 / 页码 / A4 / 表格列宽 / 封面 / 特定字体字号 | §3 从零构建 |
|
||||
| 用户上传了 .docx 要改 | §5 改已有文件(`export_docx` 做不到) |
|
||||
| 用户上传了模板要按样式填 | 用 `Document("uploads/模板.docx")` 打开**模板本身**,样式自动继承 |
|
||||
| 还勾选了公文写作类技能 | §2.1:文种文风听它的,版式落地仍走本技能 |
|
||||
| 拿不准 | 按用户原话里的名词判:出现「正式/对外/打印/汇报稿/公文/目录/页码」走本技能,否则走 `export_docx` |
|
||||
|
||||
### 2.1 定版式档(决定全篇字体字号,只需定一次)
|
||||
|
||||
本技能自带两档。`apply_chinese_defaults()` 选定后,`add_body` / `add_heading_cn` /
|
||||
`add_table` / `add_toc` / 页码全部自动跟随,**不必逐处传字体**(逐处传的结果通常是
|
||||
「大部分对、少数几处漏了」,这是本技能历史上最常见的翻车方式)。
|
||||
|
||||
| 档 | 什么时候用 | 取值 |
|
||||
|---|---|---|
|
||||
| **`gongwen`(默认)** | 公文、通知、报告、请示、函、纪要、制度办法,以及任何"正式行文" | GB/T 9704-2012:标题二号小标宋、正文三号仿宋_GB2312、一级黑体、二级楷体、行距固定 28 磅 → 读 `/skills/bisheng-docx/references/gongwen-gbt9704.md` |
|
||||
| `modern` | 简历、宣传方案、对外提案、周报月报 —— **明确不是公文**的 | 微软雅黑 11pt 那一套 → 读 `/skills/bisheng-docx/references/design-zh.md` |
|
||||
|
||||
```python
|
||||
apply_chinese_defaults(doc) # 公文档,默认,什么都不用做
|
||||
apply_chinese_defaults(doc, profile="modern") # 简历 / 宣传稿 / 周报
|
||||
```
|
||||
|
||||
**同时勾选了其他公文技能时**(用户导入的某单位公文规范、gongwen-draft 之类),分工是硬的:
|
||||
|
||||
- **文种、结构、文风、措辞听它的;版式落地永远是本技能。**
|
||||
- 它若给出了明确的字体字号(本单位模板要求华文中宋之类),把取值翻成 dict 传进来,
|
||||
**只写有差异的项**,其余自动沿用默认档:
|
||||
|
||||
```python
|
||||
apply_chinese_defaults(doc, profile={"body": {"font": "华文中宋"},
|
||||
"headings": {1: {"font": "方正小标宋简体"}}})
|
||||
```
|
||||
|
||||
- 它没给版式取值就用默认公文档,**不要为此去翻它的一堆 references** —— 那会烧掉几轮还找不到。
|
||||
- ❌ **不要调用其他技能的 Word 导出脚本**(`generate_docx.py`、`export.py` 之类)。
|
||||
它们多是给 Windows 桌面环境写的,在这里会因为"系统未安装公文字体"直接退出 ——
|
||||
而这个检查在本环境毫无意义:字体名只写进 XML,渲染发生在用户的 Word 里。
|
||||
|
||||
## 3. 从零构建
|
||||
|
||||
**第 1 步 · 定结构**。先把提纲写到 `scratch/outline.md`。
|
||||
@@ -102,7 +137,8 @@ print("pandoc :", shutil.which("pandoc") or "无(读 .docx 用 inspect_docx.py
|
||||
**不要把整段代码塞进代码执行器的参数里** —— 参数过长会被截断,导致反复重试却总差一截。
|
||||
写法读 `/skills/bisheng-docx/references/python-docx-cookbook.md`
|
||||
(中文字体、标题、目录、页码、表格、图片、列表都有可直接抄的片段),
|
||||
排版规范读 `/skills/bisheng-docx/references/design-zh.md`。
|
||||
排版规范按 §2.1 定的档读:公文走 `references/gongwen-gbt9704.md`,
|
||||
非公文走 `references/design-zh.md`。
|
||||
|
||||
骨架长这样:
|
||||
|
||||
@@ -110,17 +146,23 @@ print("pandoc :", shutil.which("pandoc") or "无(读 .docx 用 inspect_docx.py
|
||||
import sys, os
|
||||
sys.path.insert(0, "skills/bisheng-docx/scripts")
|
||||
from docx_helpers import setup_page, apply_chinese_defaults, add_heading_cn, add_body, \
|
||||
add_table, add_toc, add_page_number_footer
|
||||
add_table, add_toc, add_page_number_footer, add_gongwen_title, \
|
||||
add_signature_block
|
||||
from docx import Document
|
||||
|
||||
os.makedirs("output", exist_ok=True)
|
||||
doc = Document()
|
||||
section = setup_page(doc) # A4 纵向
|
||||
apply_chinese_defaults(doc) # ★ 必须调:设好中文字体和标题样式
|
||||
add_heading_cn(doc, "标题", 1)
|
||||
add_toc(doc) # 3 页以上才需要
|
||||
add_heading_cn(doc, "一、xxx", 2)
|
||||
add_body(doc, "正文……")
|
||||
apply_chinese_defaults(doc) # ★ 必须调,且要在 setup_page 之前:定档(默认公文)
|
||||
section = setup_page(doc) # A4 纵向,页边距按档取(公文 3.7/3.5/2.8/2.6)
|
||||
|
||||
# —— 公文档 ——
|
||||
add_gongwen_title(doc, "关于××××的通知") # 标题:二号小标宋,不进目录
|
||||
add_body(doc, "各有关部门:", indent=False) # 主送机关顶格
|
||||
add_body(doc, "正文……") # 自动左空二字
|
||||
add_heading_cn(doc, "一、xxx", 1) # 一级:三号黑体
|
||||
add_heading_cn(doc, "(一)xxx", 2) # 二级:三号楷体
|
||||
add_signature_block(doc, "××××公司", "2026年8月27日")
|
||||
|
||||
add_page_number_footer(section)
|
||||
doc.save("output/xxx.docx")
|
||||
print("saved")
|
||||
@@ -148,13 +190,17 @@ print(r.stderr[-2000:] if r.stderr else "(no stderr)")
|
||||
|
||||
```python
|
||||
from docx_helpers import set_run_font
|
||||
set_run_font(run, "微软雅黑", size_pt=11, bold=True) # ✅
|
||||
run.font.name = "微软雅黑" # ❌ 中文不生效
|
||||
set_run_font(run, "仿宋_GB2312", size_pt=16, bold=True) # ✅
|
||||
run.font.name = "仿宋_GB2312" # ❌ 中文不生效
|
||||
```
|
||||
|
||||
`apply_chinese_defaults(doc)` 会把 Normal 和 Heading 1–4 一次性设好(顺带关掉 Word 内置
|
||||
Heading 4 的斜体)。用 `add_body` / `add_heading_cn` 写的内容自动就是对的。
|
||||
体检脚本专门抓这条。
|
||||
`apply_chinese_defaults(doc)` 会按 §2.1 定的档把 Normal 和 Heading 1–4 一次性设好
|
||||
(顺带关掉 Word 内置 Heading 4 的斜体)。用 `add_body` / `add_heading_cn` / `add_table`
|
||||
写的内容自动就是对的,**不需要也不应该逐处传字体名**。体检脚本专门抓这条。
|
||||
|
||||
⚠️ 「用户机器可能没装仿宋_GB2312 / 方正小标宋简体」**不是弃用它们的理由**:
|
||||
.docx 只把字体名写进 XML,渲染发生在用户的 Word 里,服务端装没装完全不影响产物。
|
||||
公文字体是国标强制项 —— 换成微软雅黑等于交了一份不合规的公文。
|
||||
|
||||
## 5. 改用户上传的文档
|
||||
|
||||
@@ -187,8 +233,9 @@ print(r.stderr[-2000:] if r.stderr else "(no stderr)")
|
||||
输出分两段:
|
||||
|
||||
- **`=== 内容 ===`**:页面尺寸、段落数、逐段样式与文本、逐表内容。用它核对错字、顺序、缺漏。
|
||||
- **`=== 体检 ===`**:ERROR 必须修完;WARN 逐条复核(确认无误可交付);INFO 是建议。
|
||||
覆盖中文字体缺 `w:eastAsia`(含样式继承链)、字号过小、标题层级跳级、表格超出版心、
|
||||
- **`=== 体检 ===`**:开头会先说明它**按哪个版式档校验**(公文 / 非公文,从文档自身推断)。
|
||||
ERROR 必须修完;WARN 逐条复核(确认无误可交付);INFO 是建议。
|
||||
覆盖公文文档混入通用字体、中文字体缺 `w:eastAsia`(含样式继承链)、字号过小、标题层级跳级、表格超出版心、
|
||||
表格首行不像表头、长表不重复表头、图片超宽、目录域没开 updateFields、目录域下没有 Heading、
|
||||
残留占位符、段落里塞 `\n`、连续空段落、长文档缺页码域。
|
||||
|
||||
@@ -233,6 +280,8 @@ print(r.stderr[-2000:] if r.stderr else "(no stderr)")
|
||||
`read_file` 读技能文档带斜杠(`/skills/bisheng-docx/references/design-zh.md`),
|
||||
代码执行器里的路径一律不带(`skills/bisheng-docx/scripts/inspect_docx.py`、`output/x.docx`)。
|
||||
- ❌ 只写 `run.font.name` 就以为中文字体设好了。
|
||||
- ❌ 因为「用户机器上可能没装仿宋_GB2312 / 方正小标宋简体」就换成微软雅黑、宋体。
|
||||
字体名只写进 XML,渲染在用户的 Word 里 —— 服务端装没装不影响产物,公文字体是国标强制项。
|
||||
- ❌ 用手写的 `•` / `1.` 当列表,用 `\n` 当换行。
|
||||
- ❌ 用单行表格画分隔线(用 `add_hr`)。
|
||||
- ❌ 把文档降级成 Markdown 或 PDF 交付。用户要的是 .docx。
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
# 中文文档排版规范
|
||||
# 非公文文档排版规范(modern 档)
|
||||
|
||||
给「要发出去给人看、可能还要打印」的 Word 文档用。
|
||||
给简历、宣传方案、对外提案、周报月报这类**明确不是公文**的 Word 用。
|
||||
构建脚本里显式切档:`apply_chinese_defaults(doc, profile="modern")`。
|
||||
|
||||
> **公文、通知、报告、请示、函、纪要 → 不看本文件**,走 `references/gongwen-gbt9704.md`
|
||||
> (国标版式,`apply_chinese_defaults(doc)` 的默认档)。选错档的代价是整份文档字体全错。
|
||||
|
||||
## 1. 页面与字号
|
||||
|
||||
| 项 | 取值 |
|
||||
|---|---|
|
||||
| 纸张 | A4 纵向(21 × 29.7cm);表格多、列宽的报告可用横向 |
|
||||
| 页边距 | 上下左右各 2.54cm(公文类可用 上3.7 下3.5 左2.8 右2.6) |
|
||||
| 页边距 | 上下左右各 2.54cm |
|
||||
| 正文 | 微软雅黑 / 宋体 11pt,行距 1.5,段后 6pt |
|
||||
| 一级标题 | 20pt 加粗 |
|
||||
| 一级标题 | 20pt 加粗 |
|
||||
| 二级标题 | 16pt 加粗 |
|
||||
| 三级标题 | 14pt 加粗 |
|
||||
| 四级标题 | 12pt 加粗(**记得关斜体**,Word 内置是斜体) |
|
||||
@@ -18,8 +22,11 @@
|
||||
|
||||
- **正文首行缩进 2 个字符**(`first_line_indent`),不要用空格或 Tab 凑。
|
||||
- 中文正文用两端对齐(JUSTIFY),标题左对齐;只有封面和大标题居中。
|
||||
- 字体只用**微软雅黑、宋体、黑体、等线**这类用户机器一定有的。渲染在用户那边完成,
|
||||
选个冷门字体等于赌它装了。
|
||||
- 字体在这一档用微软雅黑、宋体、黑体、等线即可,够用且不挑机器。
|
||||
**但不要把这条当成「冷门字体不能用」的通则**:.docx 只把字体名写进 XML,
|
||||
渲染发生在用户的 Word 里,服务端装没装那个字体完全不影响产物 ——
|
||||
公文四体(方正小标宋简体 / 仿宋_GB2312 / 黑体 / 楷体_GB2312)是国标强制项,
|
||||
在公文档里必须照写,不许替换成通用字体。
|
||||
|
||||
## 2. 结构
|
||||
|
||||
@@ -34,6 +41,7 @@
|
||||
- 列宽合计不能超过版心宽度(A4 + 2.54cm 边距 ≈ **15.9cm**),否则右侧列会被截出页面。
|
||||
用 `content_width_cm(section)` 取准确值。
|
||||
- 表头深底白字加粗居中;数据行 10pt;隔行浅底(`F2F6FA`)提升可读性。
|
||||
(公文档没有这层装饰,`add_table` 会按当前档自动切换。)
|
||||
- **数字右对齐,文字左对齐**,同一列对齐方式一致。
|
||||
- 数字带千分位;单位写在表头(`收入(万元)`),不要每格拼「万元」。
|
||||
- 表格上方给一句话说明它在说什么,下方必要时给数据来源。
|
||||
@@ -46,14 +54,7 @@
|
||||
- 每张图配图注(`图 1 xxx`),居中 9pt 灰字。
|
||||
- 图表优先用 matplotlib 生成 PNG 再插入;记得设中文字体,否则全是方框。
|
||||
|
||||
## 5. 语言
|
||||
|
||||
- 术语全篇统一。用户叫「营收」就别一会儿写「营业收入」一会儿写「主营收入」。
|
||||
- 数字、日期、单位格式统一(`2024-01-15` 或 `2024年1月15日`,挑一种)。
|
||||
- 中英文之间不用手动加空格(Word 会自动处理),但中文标点要用全角。
|
||||
- 不要出现「本文档由 AI 生成」之类的话,除非用户要求。
|
||||
|
||||
## 6. 交付前
|
||||
## 5. 交付前
|
||||
|
||||
跑 `inspect_docx.py`:ERROR 清零到打出「结论: 通过」,WARN 逐条复核。
|
||||
`render_docx.py` 渲成图看是**可选**的 —— ⚠️ 仅在你确知当前模型支持读图时才做,
|
||||
@@ -61,7 +62,7 @@
|
||||
即使能看:**预览的字体和用户 Word 里不一样**(服务端只有文泉驿正黑),
|
||||
行长松紧只作参考,不要为了预览效果反复微调字号。
|
||||
|
||||
## 7. 常见"一眼就知道是机器做的"
|
||||
## 6. 常见"一眼就知道是机器做的"
|
||||
|
||||
- 标题用手写加粗的普通段落,不是 Heading 样式 → 没有目录、没有导航。
|
||||
- 中文字体没设 `w:eastAsia`,正文一半宋体一半雅黑。
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# 公文版式规范(GB/T 9704-2012)
|
||||
|
||||
《党政机关公文格式》是国标,**这里的取值是强制项,不是审美偏好**。
|
||||
`apply_chinese_defaults(doc)` 默认就是这一档,照着 helper 写自然合规;
|
||||
只有用户或另一个公文技能给了本单位的模板取值时才覆盖它。
|
||||
|
||||
> 本文件只管**版式**。文种怎么选、正文怎么写、措辞轻重——那是公文写作技能的事,
|
||||
> 本技能不下判断。
|
||||
|
||||
## 1. 版式取值
|
||||
|
||||
| 项 | 国标取值 | 折算 |
|
||||
|---|---|---|
|
||||
| 用纸 | A4(210 × 297mm) | — |
|
||||
| 版心 | 上白边 37mm、下 35mm、左 28mm、右 26mm | 页边距 3.7 / 3.5 / 2.8 / 2.6 cm |
|
||||
| 行数字数 | 每面 22 行、每行 28 字 | 正文行距固定值 **28 磅**(`line_pt`,不是倍数) |
|
||||
| 标题 | 二号方正小标宋简体,居中 | 22pt,**不加粗**(小标宋本身就重,再加粗糊成一团) |
|
||||
| 主送机关 | 三号仿宋_GB2312,**顶格** | 16pt,`add_body(..., indent=False)` |
|
||||
| 正文 | 三号仿宋_GB2312,每自然段左空二字、回行顶格 | 16pt,首行缩进 2 字 |
|
||||
| 一级标题「一、」 | 三号**黑体** | 16pt,不加粗 |
|
||||
| 二级标题「(一)」 | 三号**楷体_GB2312** | 16pt,不加粗 |
|
||||
| 三级标题「1.」 | 三号仿宋_GB2312,可加粗 | 16pt |
|
||||
| 四级标题「(1)」 | 三号仿宋_GB2312,可加粗 | 16pt |
|
||||
| 附件说明 | 三号仿宋,正文下空一行、左空二字 | 「附件:」后接名称,全角冒号 |
|
||||
| 发文机关署名 | 右空**二**字 | 见 §2 |
|
||||
| 成文日期 | 右空**四**字,阿拉伯数字 | 「2026年8月27日」 |
|
||||
| 页码 | 四号半角宋体阿拉伯数字 | 14pt |
|
||||
| 表格文字 | 国标未规定,惯例四号或小四仿宋 | 14pt / 12pt,无深底白字 |
|
||||
|
||||
字号名对照:二号22 · 三号16 · 四号14 · 小四12 · 五号10.5 pt。
|
||||
|
||||
**四级标题全是三号,靠字体区分层级,不靠字号递减。** 这是公文和普通报告最大的排版差异,
|
||||
别把它「修正」成 20/16/14/12 的字号阶梯——那一眼就不是公文。
|
||||
|
||||
## 2. 格式要素排列
|
||||
|
||||
自上而下,缺项就整块省略,**不要用空段落占位**:
|
||||
|
||||
```
|
||||
关于××××的通知 ← 标题:二号小标宋,居中,回行不断词
|
||||
← 空一行
|
||||
各有关部门: ← 主送机关:三号仿宋,顶格,全角冒号
|
||||
××××××××××××××××××××××××××××。 ← 正文:三号仿宋,左空二字,回行顶格
|
||||
一、×××× ← 一级:三号黑体
|
||||
(一)×××× ← 二级:三号楷体
|
||||
1.×××× ← 三级:三号仿宋加粗
|
||||
← 空一行
|
||||
附件:1.×××× ← 附件说明:左空二字
|
||||
2.×××× ← 多个附件时序号对齐
|
||||
← 空一行
|
||||
××××××公司 ← 发文机关署名:右空二字
|
||||
2026年8月27日 ← 成文日期:右空四字
|
||||
```
|
||||
|
||||
对应 helper:
|
||||
|
||||
```python
|
||||
add_gongwen_title(doc, "关于××××的通知") # 标题,不进目录
|
||||
add_body(doc, "各有关部门:", indent=False) # 主送机关,顶格
|
||||
add_body(doc, "为规范……,现将……印发,请遵照执行。") # 正文,自动左空二字
|
||||
add_heading_cn(doc, "一、××××", 1) # 一级,黑体
|
||||
add_heading_cn(doc, "(一)××××", 2) # 二级,楷体
|
||||
add_body(doc, "附件:电力行业映射表 V2.0", indent=False)
|
||||
add_signature_block(doc, "××××××公司", "2026年8月27日") # 两个缩进国标不同,helper 已处理
|
||||
```
|
||||
|
||||
几条硬约束:
|
||||
|
||||
- **标题不能用 Heading 样式。** 公文标题不在标题层级里,用 `add_heading_cn(..., 1)` 会把它拉进目录。
|
||||
用 `add_gongwen_title()`。
|
||||
- **发文机关、发文字号、签发人、印章一律不得编造。** 用户没给就整块省略,
|
||||
或用 `[发文机关]` 这类方括号占位——体检脚本认得占位符,不会当成错误。
|
||||
- **附件另面编排**:附件正文另起一页,首行顶格写「附件1」(不带冒号)。
|
||||
- 页码国标要求单页居右、双页居左,`add_page_number_footer()` 默认居中。多数内部行文不深究;
|
||||
确需严格合规时开 `w:evenAndOddHeaders` 分别设奇偶页脚。
|
||||
|
||||
## 3. 常见误区
|
||||
|
||||
- **标题用黑体** —— 黑体是一级标题的字体,公文标题是**小标宋**。这是最常见的一处错。
|
||||
- **正文用宋体或微软雅黑** —— 正文是**仿宋_GB2312**。「用户机器可能没装这些字体」不成立:
|
||||
.docx 只把字体名写进 XML,渲染发生在用户的 Word 里,服务端装没装完全不影响产物。
|
||||
国标字体是强制项,不要替换成通用字体。
|
||||
- **一二级标题字号递减** —— 全是三号,见 §1。
|
||||
- **正文用 1.5 倍行距** —— 公文是固定值 28 磅。倍数行距在字号变化时撑不住每面 22 行。
|
||||
- **署名和日期缩进一样** —— 二字 / 四字,见 §2。
|
||||
- **表格套深底白字表头** —— 那是企业文档的做法。公文表格用素表,表头靠加粗区分。
|
||||
- **空段落凑版式** —— 空一行是版式要素、有确切位置;连打空段落会在分页后炸出大片空白。
|
||||
+29
-11
@@ -11,8 +11,10 @@ sys.path.insert(0, "skills/bisheng-docx/scripts")
|
||||
from docx_helpers import (
|
||||
setup_page, apply_chinese_defaults, set_run_font, add_heading_cn, add_body,
|
||||
add_table, add_toc, add_page_number_footer, add_hr, add_image_fitted,
|
||||
set_cell_shading, content_width_cm, first_line_indent, CN_FONT,
|
||||
set_cell_shading, content_width_cm, first_line_indent,
|
||||
add_gongwen_title, add_signature_block,
|
||||
)
|
||||
from style_profiles import active_profile # 需要取当前档的字体/字号时
|
||||
from docx import Document
|
||||
from docx.shared import Pt, Cm, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
@@ -21,8 +23,22 @@ os.makedirs("output", exist_ok=True)
|
||||
os.makedirs("scratch", exist_ok=True)
|
||||
|
||||
doc = Document()
|
||||
section = setup_page(doc) # A4 纵向,2.54cm 页边距
|
||||
apply_chinese_defaults(doc) # ★ 必须调,见下
|
||||
apply_chinese_defaults(doc) # ★ 必须调,且在 setup_page 之前:定档(默认公文)
|
||||
section = setup_page(doc) # A4 纵向,页边距按档取
|
||||
```
|
||||
|
||||
**版式档决定全篇字体字号**,`apply_chinese_defaults()` 定一次,下面所有 helper 自动跟随:
|
||||
|
||||
```python
|
||||
apply_chinese_defaults(doc) # 公文档(默认):三号仿宋_GB2312 / 黑体 / 楷体
|
||||
apply_chinese_defaults(doc, profile="modern") # 简历、宣传稿:微软雅黑 11pt
|
||||
```
|
||||
|
||||
要在片段里手工取当前档的取值(而不是写死字体名):
|
||||
|
||||
```python
|
||||
P = active_profile()
|
||||
body_font, body_pt = P["body"]["font"], P["body"]["pt"]
|
||||
```
|
||||
|
||||
---
|
||||
@@ -34,13 +50,14 @@ apply_chinese_defaults(doc) # ★ 必须调,见下
|
||||
用户打开一看就是"字体乱了"。
|
||||
|
||||
```python
|
||||
set_run_font(run, "微软雅黑", size_pt=11, bold=True, color="1F1F1F") # ✅ 三个都设
|
||||
run.font.name = "微软雅黑" # ❌ 中文不生效
|
||||
set_run_font(run, "仿宋_GB2312", size_pt=16, bold=True) # ✅ 三个字段一起设
|
||||
run.font.name = "仿宋_GB2312" # ❌ 中文不生效
|
||||
```
|
||||
|
||||
`apply_chinese_defaults(doc)` 会把 Normal 和 Heading 1–4 的 `w:eastAsia` 一次性设好
|
||||
`apply_chinese_defaults(doc)` 会按当前档把 Normal 和 Heading 1–4 的 `w:eastAsia` 一次性设好
|
||||
(顺带把 Word 内置 Heading 4 的**斜体**关掉 —— 不关的话四级标题会渲成斜体衬线)。
|
||||
之后用 `add_body` / `add_heading_cn` 写的内容就都是对的。
|
||||
之后用 `add_body` / `add_heading_cn` / `add_table` 写的内容就都是对的,
|
||||
**不必也不该逐处写死字体名** —— 写死就会在切档时留下几处漏网的。
|
||||
|
||||
体检脚本会专门抓这条。
|
||||
|
||||
@@ -82,11 +99,12 @@ add_body(doc, "这段不缩进。", indent=False)
|
||||
手写段落时:
|
||||
|
||||
```python
|
||||
P = active_profile()["body"]
|
||||
p = doc.add_paragraph()
|
||||
set_run_font(p.add_run("加粗片段"), CN_FONT, 11, bold=True)
|
||||
set_run_font(p.add_run(",普通片段。"), CN_FONT, 11)
|
||||
set_run_font(p.add_run("加粗片段"), P["font"], P["pt"], bold=True)
|
||||
set_run_font(p.add_run(",普通片段。"), P["font"], P["pt"])
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY # 中文正文常用两端对齐
|
||||
first_line_indent(p, 2, 11)
|
||||
first_line_indent(p) # 缩进字数与字号都按当前档
|
||||
```
|
||||
|
||||
> **绝不要用 `\n`**。python-docx 不会把它变成换行,要么用多个 `Paragraph`,
|
||||
@@ -186,7 +204,7 @@ new_section.page_width, new_section.page_height = section.page_height, section.p
|
||||
```python
|
||||
header = section.header
|
||||
p = header.paragraphs[0]
|
||||
set_run_font(p.add_run("XX 公司 · 内部资料"), CN_FONT, 9, color="808080")
|
||||
set_run_font(p.add_run("XX 公司 · 内部资料"), active_profile()["caption"]["font"], 9, color="808080")
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
```
|
||||
|
||||
|
||||
@@ -16,16 +16,34 @@ wrong or cannot express the construct at all:
|
||||
column object alone is silently ignored by Word.
|
||||
* A table of contents, page numbers, and a horizontal rule are all field codes
|
||||
or borders with no python-docx API at all.
|
||||
|
||||
**Which face and size a helper renders in lives in ``style_profiles.py``, not
|
||||
here.** Call ``apply_chinese_defaults(doc)`` once — it selects the GB/T 9704
|
||||
公文 profile by default — and every helper below follows it. Pass an explicit
|
||||
``font=`` / ``size_pt=`` only where a single run genuinely differs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
|
||||
# Fonts a Chinese user's Word certainly has. Rendering happens on their machine,
|
||||
# so an exotic face here becomes a substitution there.
|
||||
try:
|
||||
from style_profiles import active_profile, heading_spec, resolve_profile, set_active_profile
|
||||
except ImportError:
|
||||
# Loaded without the bundle's scripts/ dir on sys.path (importlib spec, exec,
|
||||
# a build script that forgot the sys.path.insert). Find our sibling ourselves
|
||||
# rather than failing the whole build over an import line.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from style_profiles import active_profile, heading_spec, resolve_profile, set_active_profile
|
||||
|
||||
# Kept for build scripts that name a face directly. These are the "modern"
|
||||
# profile's values; helpers no longer reference them — they read the active
|
||||
# profile instead, so a 公文 does not silently come out in 微软雅黑.
|
||||
CN_FONT = "微软雅黑"
|
||||
CN_FONT_SERIF = "宋体"
|
||||
CN_FONT_HEADING = "微软雅黑"
|
||||
@@ -75,40 +93,128 @@ def _style_font(style, cn_name: str, size_pt: float | None = None):
|
||||
style.font.size = Pt(size_pt)
|
||||
|
||||
|
||||
def apply_chinese_defaults(doc, body_font: str = CN_FONT, heading_font: str = CN_FONT_HEADING, body_pt: float = 11):
|
||||
"""Make Normal and Heading 1–4 render correctly in Chinese.
|
||||
def _apply_line_spacing(paragraph_format, spec: dict):
|
||||
"""公文 leading is a fixed point value; the corporate profile uses a multiple.
|
||||
|
||||
A multiple cannot hold GB/T 9704's "22 lines per page" once the body size
|
||||
moves, so the two are expressed differently and only one is ever set.
|
||||
"""
|
||||
if spec.get("line_pt"):
|
||||
paragraph_format.line_spacing = Pt(spec["line_pt"])
|
||||
paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
||||
elif spec.get("line_multiple"):
|
||||
paragraph_format.line_spacing = spec["line_multiple"]
|
||||
|
||||
|
||||
def apply_chinese_defaults(
|
||||
doc,
|
||||
profile=None,
|
||||
body_font: str | None = None,
|
||||
heading_font: str | None = None,
|
||||
body_pt: float | None = None,
|
||||
**overrides,
|
||||
):
|
||||
"""Set Normal and Heading 1-4, and make this profile the default for every
|
||||
other helper in the package.
|
||||
|
||||
Defaults to the GB/T 9704 公文 profile. For a CV, a marketing one-pager or an
|
||||
outward-facing proposal — anything explicitly not an official document — pass
|
||||
``profile="modern"``.
|
||||
|
||||
``body_font`` / ``heading_font`` / ``body_pt`` stay supported as shorthand for
|
||||
a one-off tweak on top of the chosen profile.
|
||||
|
||||
Word's stock Heading styles are Calibri Light in a blue nobody asked for;
|
||||
left alone they make every generated document look identically foreign.
|
||||
"""
|
||||
_style_font(doc.styles["Normal"], body_font, body_pt)
|
||||
doc.styles["Normal"].paragraph_format.line_spacing = 1.5
|
||||
doc.styles["Normal"].paragraph_format.space_after = Pt(6)
|
||||
tweaks: dict = {}
|
||||
if body_font:
|
||||
tweaks.setdefault("body", {})["font"] = body_font
|
||||
if body_pt is not None:
|
||||
tweaks.setdefault("body", {})["pt"] = body_pt
|
||||
if heading_font:
|
||||
tweaks["headings"] = {level: {"font": heading_font} for level in (1, 2, 3, 4)}
|
||||
for key, value in overrides.items():
|
||||
tweaks[key] = value
|
||||
|
||||
sizes = {1: 20, 2: 16, 3: 14, 4: 12}
|
||||
for level, size in sizes.items():
|
||||
resolved = set_active_profile(profile, tweaks or None)
|
||||
body = resolved["body"]
|
||||
|
||||
# Margins belong to the profile too, and this function is usually called
|
||||
# *after* setup_page (that is the order SKILL.md's skeleton shows), so a
|
||||
# profile switch has to reach back and correct the page it already laid out
|
||||
# — otherwise a "modern" document keeps 公文's 37/35/28/26mm 版心.
|
||||
# To override margins deliberately, call setup_page(margin_cm=...) *after*
|
||||
# this function.
|
||||
page = resolved["page"]
|
||||
for section in doc.sections:
|
||||
section.top_margin = Cm(page["margin_top_cm"])
|
||||
section.bottom_margin = Cm(page["margin_bottom_cm"])
|
||||
section.left_margin = Cm(page["margin_left_cm"])
|
||||
section.right_margin = Cm(page["margin_right_cm"])
|
||||
|
||||
normal = doc.styles["Normal"]
|
||||
_style_font(normal, body["font"], body["pt"])
|
||||
_apply_line_spacing(normal.paragraph_format, body)
|
||||
normal.paragraph_format.space_after = Pt(body.get("space_after_pt", 0))
|
||||
if body.get("color"):
|
||||
normal.font.color.rgb = RGBColor.from_string(body["color"])
|
||||
|
||||
headings = resolved["headings"]
|
||||
for level in (1, 2, 3, 4):
|
||||
try:
|
||||
style = doc.styles[f"Heading {level}"]
|
||||
except KeyError:
|
||||
continue
|
||||
_style_font(style, heading_font, size)
|
||||
style.font.bold = True
|
||||
spec = heading_spec(level, resolved)
|
||||
_style_font(style, spec["font"], spec["pt"])
|
||||
style.font.bold = spec.get("bold", True)
|
||||
# Word's stock Heading 4 (and several template variants) are italic.
|
||||
# Left alone, a level-4 Chinese heading renders as slanted serif and
|
||||
# looks like a different document.
|
||||
style.font.italic = False
|
||||
style.font.color.rgb = RGBColor.from_string("1F1F1F")
|
||||
style.paragraph_format.space_before = Pt(12 if level > 1 else 18)
|
||||
style.paragraph_format.space_after = Pt(6)
|
||||
if spec.get("color"):
|
||||
style.font.color.rgb = RGBColor.from_string(spec["color"])
|
||||
before = headings.get("space_before_pt", 12)
|
||||
style.paragraph_format.space_before = Pt(before + 6 if level == 1 and before else before)
|
||||
style.paragraph_format.space_after = Pt(headings.get("space_after_pt", 6))
|
||||
return resolved
|
||||
|
||||
|
||||
def setup_page(doc, width_cm: float = 21.0, height_cm: float = 29.7, margin_cm: float = 2.54, landscape: bool = False):
|
||||
"""A4 portrait by default; pass landscape=True to swap the dimensions."""
|
||||
def apply_gongwen_defaults(doc, **overrides):
|
||||
"""GB/T 9704 公文 typography. Same as ``apply_chinese_defaults(doc)`` — named
|
||||
explicitly for build scripts that want the intent on the page."""
|
||||
return apply_chinese_defaults(doc, profile="gongwen", **overrides)
|
||||
|
||||
|
||||
def setup_page(
|
||||
doc,
|
||||
width_cm: float = 21.0,
|
||||
height_cm: float = 29.7,
|
||||
margin_cm: float | None = None,
|
||||
landscape: bool = False,
|
||||
profile=None,
|
||||
):
|
||||
"""A4 portrait by default; pass landscape=True to swap the dimensions.
|
||||
|
||||
Margins come from the active profile (公文 uses the standard's uneven
|
||||
37/35/28/26mm 版心). Pass ``margin_cm`` to force one value on all four sides.
|
||||
"""
|
||||
page = (resolve_profile(profile) if profile is not None else active_profile())["page"]
|
||||
if margin_cm is not None:
|
||||
top = bottom = left = right = margin_cm
|
||||
else:
|
||||
top = page["margin_top_cm"]
|
||||
bottom = page["margin_bottom_cm"]
|
||||
left = page["margin_left_cm"]
|
||||
right = page["margin_right_cm"]
|
||||
for section in doc.sections:
|
||||
section.page_width = Cm(height_cm if landscape else width_cm)
|
||||
section.page_height = Cm(width_cm if landscape else height_cm)
|
||||
section.left_margin = section.right_margin = Cm(margin_cm)
|
||||
section.top_margin = section.bottom_margin = Cm(margin_cm)
|
||||
section.left_margin = Cm(left)
|
||||
section.right_margin = Cm(right)
|
||||
section.top_margin = Cm(top)
|
||||
section.bottom_margin = Cm(bottom)
|
||||
return doc.sections[0]
|
||||
|
||||
|
||||
@@ -159,12 +265,13 @@ def enable_update_fields(doc):
|
||||
existing.set(qn("w:val"), "true")
|
||||
|
||||
|
||||
def add_toc(doc, levels: str = "1-3", title: str = "目录"):
|
||||
def add_toc(doc, levels: str = "1-3", title: str = "目录", font: str | None = None, size_pt: float | None = None):
|
||||
"""Insert a table of contents field. Requires built-in Heading styles."""
|
||||
spec = active_profile()["toc"]
|
||||
if title:
|
||||
heading = doc.add_paragraph()
|
||||
run = heading.add_run(title)
|
||||
set_run_font(run, CN_FONT_HEADING, 18, bold=True)
|
||||
set_run_font(run, font or spec["font"], size_pt or spec["pt"], bold=True, color=spec.get("color"))
|
||||
heading.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
paragraph = doc.add_paragraph()
|
||||
_field(paragraph, f' TOC \\o "{levels}" \\h \\z \\u ', "(打开文档时自动生成;若未显示请按 Ctrl+A 后 F9)")
|
||||
@@ -172,8 +279,14 @@ def add_toc(doc, levels: str = "1-3", title: str = "目录"):
|
||||
return paragraph
|
||||
|
||||
|
||||
def add_page_number_footer(section, template: str = "第 {PAGE} 页 / 共 {NUMPAGES} 页", size_pt: float = 9):
|
||||
def add_page_number_footer(
|
||||
section,
|
||||
template: str = "第 {PAGE} 页 / 共 {NUMPAGES} 页",
|
||||
size_pt: float | None = None,
|
||||
font: str | None = None,
|
||||
):
|
||||
"""Page numbers in the footer, as live fields rather than baked-in text."""
|
||||
spec = active_profile()["footer"]
|
||||
footer = section.footer
|
||||
paragraph = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
@@ -183,7 +296,7 @@ def add_page_number_footer(section, template: str = "第 {PAGE} 页 / 共 {NUMPA
|
||||
else:
|
||||
paragraph.add_run(chunk)
|
||||
for run in paragraph.runs:
|
||||
set_run_font(run, CN_FONT, size_pt, color="808080")
|
||||
set_run_font(run, font or spec["font"], size_pt or spec["pt"], color=spec.get("color"))
|
||||
return paragraph
|
||||
|
||||
|
||||
@@ -215,13 +328,32 @@ def set_cell_shading(cell, hex_fill: str):
|
||||
tc_pr.append(shd)
|
||||
|
||||
|
||||
def add_table(doc, headers, rows, widths_cm=None, style: str = "Table Grid", font_pt: float = 10, banded=True):
|
||||
def add_table(
|
||||
doc,
|
||||
headers,
|
||||
rows,
|
||||
widths_cm=None,
|
||||
style: str = "Table Grid",
|
||||
font_pt: float | None = None,
|
||||
banded: bool | None = None,
|
||||
font: str | None = None,
|
||||
size_pt: float | None = None,
|
||||
):
|
||||
"""Add a table with widths Word will actually honour.
|
||||
|
||||
python-docx exposes ``column.width``, but Word ignores it unless the same
|
||||
width is written to every cell in that column and autofit is off — the
|
||||
single most common "why is my table squashed" bug.
|
||||
|
||||
Face, size and header treatment come from the active profile: the corporate
|
||||
profile gives a dark header band with white text, 公文 gives a plain bold
|
||||
header row (an official document does not carry colour blocks).
|
||||
"""
|
||||
spec = active_profile()["table"]
|
||||
face = font or spec["font"]
|
||||
pt = size_pt if size_pt is not None else (font_pt if font_pt is not None else spec["pt"])
|
||||
striped = spec.get("banded", True) if banded is None else banded
|
||||
|
||||
table = doc.add_table(rows=1, cols=len(headers))
|
||||
if style:
|
||||
try:
|
||||
@@ -236,9 +368,10 @@ def add_table(doc, headers, rows, widths_cm=None, style: str = "Table Grid", fon
|
||||
cell = header_cells[index]
|
||||
cell.text = ""
|
||||
run = cell.paragraphs[0].add_run(str(title))
|
||||
set_run_font(run, CN_FONT, font_pt, bold=True, color="FFFFFF")
|
||||
set_run_font(run, face, pt, bold=spec.get("header_bold", True), color=spec.get("header_color"))
|
||||
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
set_cell_shading(cell, HEADER_FILL)
|
||||
if spec.get("header_fill"):
|
||||
set_cell_shading(cell, spec["header_fill"])
|
||||
|
||||
for row_index, record in enumerate(rows):
|
||||
cells = table.add_row().cells
|
||||
@@ -246,11 +379,11 @@ def add_table(doc, headers, rows, widths_cm=None, style: str = "Table Grid", fon
|
||||
cell = cells[index]
|
||||
cell.text = ""
|
||||
run = cell.paragraphs[0].add_run("" if value is None else str(value))
|
||||
set_run_font(run, CN_FONT, font_pt)
|
||||
set_run_font(run, face, pt)
|
||||
if isinstance(value, (int, float)):
|
||||
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
if banded and row_index % 2 == 1:
|
||||
set_cell_shading(cell, BAND_FILL)
|
||||
if striped and spec.get("band_fill") and row_index % 2 == 1:
|
||||
set_cell_shading(cell, spec["band_fill"])
|
||||
|
||||
if widths_cm:
|
||||
for row in table.rows:
|
||||
@@ -309,33 +442,105 @@ def add_image_fitted(doc, image_path: str, section=None, max_width_cm: float | N
|
||||
picture.height = int(picture.height * limit / picture.width)
|
||||
picture.width = limit
|
||||
if caption:
|
||||
spec = active_profile()["caption"]
|
||||
cap = doc.add_paragraph()
|
||||
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
set_run_font(cap.add_run(caption), CN_FONT, 9, color="808080")
|
||||
set_run_font(cap.add_run(caption), spec["font"], spec["pt"], color=spec.get("color"))
|
||||
return paragraph
|
||||
|
||||
|
||||
def first_line_indent(paragraph, chars: float = 2, font_pt: float = 11):
|
||||
def first_line_indent(paragraph, chars: float | None = None, font_pt: float | None = None):
|
||||
"""Chinese body text is indented by two characters, not by a tab."""
|
||||
body = active_profile()["body"]
|
||||
chars = body.get("indent_chars", 2) if chars is None else chars
|
||||
font_pt = body["pt"] if font_pt is None else font_pt
|
||||
paragraph.paragraph_format.first_line_indent = Pt(chars * font_pt)
|
||||
return paragraph
|
||||
|
||||
|
||||
def add_body(doc, text: str, indent: bool = True, font_pt: float = 11):
|
||||
def add_body(
|
||||
doc,
|
||||
text: str,
|
||||
indent: bool = True,
|
||||
font_pt: float | None = None,
|
||||
font: str | None = None,
|
||||
size_pt: float | None = None,
|
||||
):
|
||||
"""A body paragraph with the Chinese conventions already applied."""
|
||||
body = active_profile()["body"]
|
||||
face = font or body["font"]
|
||||
pt = size_pt if size_pt is not None else (font_pt if font_pt is not None else body["pt"])
|
||||
paragraph = doc.add_paragraph()
|
||||
set_run_font(paragraph.add_run(text), CN_FONT, font_pt)
|
||||
set_run_font(paragraph.add_run(text), face, pt, color=body.get("color"))
|
||||
_apply_line_spacing(paragraph.paragraph_format, body)
|
||||
if indent:
|
||||
first_line_indent(paragraph, 2, font_pt)
|
||||
first_line_indent(paragraph, body.get("indent_chars", 2), pt)
|
||||
return paragraph
|
||||
|
||||
|
||||
def add_heading_cn(doc, text: str, level: int = 1):
|
||||
def add_heading_cn(doc, text: str, level: int = 1, font: str | None = None, size_pt: float | None = None):
|
||||
"""A heading that keeps the built-in style (so the TOC finds it) but renders
|
||||
in a Chinese face."""
|
||||
in the active profile's face for that level.
|
||||
|
||||
Under 公文 all four levels are 三号 and differ by face (黑体 / 楷体 / 仿宋加粗)
|
||||
rather than by size — do not "fix" that into a descending size ladder.
|
||||
"""
|
||||
spec = heading_spec(level)
|
||||
heading = doc.add_heading(level=level)
|
||||
run = set_run_font(
|
||||
heading.add_run(text), CN_FONT_HEADING, {1: 20, 2: 16, 3: 14, 4: 12}.get(level, 12), bold=True, color="1F1F1F"
|
||||
heading.add_run(text),
|
||||
font or spec["font"],
|
||||
size_pt if size_pt is not None else spec["pt"],
|
||||
bold=spec.get("bold", True),
|
||||
color=spec.get("color"),
|
||||
)
|
||||
run.font.italic = False # stock Heading 4 is italic; override at run level too
|
||||
return heading
|
||||
|
||||
|
||||
def add_gongwen_title(doc, text: str, font: str | None = None, size_pt: float | None = None):
|
||||
"""The document title of an official document (标题) — 二号方正小标宋简体, centred.
|
||||
|
||||
Not a Heading style: GB/T 9704's 标题 sits above the 主送机关 and is not part
|
||||
of the heading ladder, so it must not land in the table of contents.
|
||||
Long titles wrap on meaning; do not split a word or a book-title mark.
|
||||
"""
|
||||
spec = active_profile()["title"]
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
paragraph.paragraph_format.first_line_indent = Pt(0)
|
||||
set_run_font(
|
||||
paragraph.add_run(text),
|
||||
font or spec["font"],
|
||||
size_pt if size_pt is not None else spec["pt"],
|
||||
bold=False, # 小标宋 is already heavy; bolding it renders as a smear
|
||||
color=spec.get("color"),
|
||||
)
|
||||
return paragraph
|
||||
|
||||
|
||||
def add_signature_block(doc, org: str | None = None, date: str | None = None, blank_lines: int = 1):
|
||||
"""发文机关署名 + 成文日期, set back from the right margin per GB/T 9704.
|
||||
|
||||
The two indents differ and the standard is explicit about it: on a document
|
||||
that carries no seal, 发文机关署名 is set back **two** characters and 成文日期
|
||||
**four**. Equalising them is the giveaway that a template was eyeballed.
|
||||
|
||||
Leave ``org`` empty when the user has not supplied an authorised issuer —
|
||||
never invent a real 机关名称.
|
||||
"""
|
||||
body = active_profile()["body"]
|
||||
for _ in range(max(0, blank_lines)):
|
||||
doc.add_paragraph()
|
||||
paragraphs = []
|
||||
for text, back_chars in ((org, 2), (date, 4)):
|
||||
if not text:
|
||||
continue
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
paragraph.paragraph_format.first_line_indent = Pt(0)
|
||||
paragraph.paragraph_format.right_indent = Pt(back_chars * body["pt"])
|
||||
_apply_line_spacing(paragraph.paragraph_format, body)
|
||||
set_run_font(paragraph.add_run(text), body["font"], body["pt"], color=body.get("color"))
|
||||
paragraphs.append(paragraph)
|
||||
return paragraphs
|
||||
|
||||
@@ -27,8 +27,10 @@ Always exits 0 — the executor discards stdout on a non-zero exit.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from itertools import pairwise
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
@@ -37,12 +39,29 @@ except ImportError: # pragma: no cover
|
||||
print("[FATAL] python-docx 不可用;无法体检。")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
from style_profiles import GENERIC_CN_FONTS, GONGWEN, MODERN
|
||||
except ImportError:
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
try:
|
||||
from style_profiles import GENERIC_CN_FONTS, GONGWEN, MODERN
|
||||
except ImportError: # pragma: no cover - script copied out of the bundle
|
||||
GENERIC_CN_FONTS = ("微软雅黑", "等线", "宋体", "SimSun", "Microsoft YaHei", "DengXian")
|
||||
GONGWEN = MODERN = None
|
||||
|
||||
PLACEHOLDERS = ["待填", "待补", "TODO", "XXX", "占位", "请填写", "TBD", "lorem ipsum", "示例文字"]
|
||||
CJK_RANGE = ("一", "鿿")
|
||||
MIN_RUN_PT = 9.0 # design-zh: 图注 9pt 是最小合法字号
|
||||
MIN_BODY_PT = 10.5 # design-zh: 正文 11pt
|
||||
MIN_RUN_PT = 9.0 # 图注 9pt 是最小合法字号
|
||||
REPEAT_HEADER_ROWS = 12 # 超过这个行数的表跨页时必须重复表头
|
||||
|
||||
# Faces no corporate document reaches for by accident. Their presence is the
|
||||
# most reliable signal that this document is meant to be an official one.
|
||||
GONGWEN_ONLY_FONTS = ("方正小标宋简体", "小标宋", "仿宋_GB2312", "楷体_GB2312")
|
||||
# 「关于××的通知」and its siblings — a narrow shape, so a CV or a deck title
|
||||
# cannot trip it. Catches the case where every face was already swapped out and
|
||||
# GONGWEN_ONLY_FONTS finds nothing.
|
||||
GONGWEN_TITLE_RE = re.compile(r"^关于.{2,60}的(通知|报告|请示|函|意见|批复|决定|通报|纪要|公告|通告)$")
|
||||
|
||||
findings: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
@@ -185,7 +204,72 @@ def dump_content(path: str, max_paras: int) -> None:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def check_fonts(doc) -> None:
|
||||
def document_faces(doc) -> set[str]:
|
||||
"""Every CJK face the document actually names, styles and runs alike."""
|
||||
faces: set[str] = set()
|
||||
for style_name in ("Normal", "Heading 1", "Heading 2", "Heading 3", "Heading 4"):
|
||||
try:
|
||||
face = style_east_asia(doc.styles[style_name])
|
||||
except KeyError:
|
||||
continue
|
||||
if face:
|
||||
faces.add(face)
|
||||
for paragraph, _ in iter_block_paragraphs(doc):
|
||||
for run in paragraph.runs:
|
||||
face = run_east_asia(run)
|
||||
if face:
|
||||
faces.add(face)
|
||||
return faces
|
||||
|
||||
|
||||
def looks_like_gongwen(doc, faces: set[str]) -> bool:
|
||||
"""Is this document *meant* to be an official document?
|
||||
|
||||
Deliberately not "what does Normal say" — if the body face was swapped for
|
||||
微软雅黑 that is exactly the defect being looked for, and keying off it would
|
||||
make the check disappear precisely when it matters.
|
||||
"""
|
||||
if any(face in GONGWEN_ONLY_FONTS for face in faces):
|
||||
return True
|
||||
|
||||
# A 公文 title wraps on meaning rather than running to the margin, so it is
|
||||
# routinely split across two paragraphs («关于××映射表 V2.0» / «变量个数情况的报告»).
|
||||
# Test the first few leading paragraphs both alone and pairwise joined.
|
||||
leading = []
|
||||
for paragraph in doc.paragraphs:
|
||||
text = paragraph.text.strip().replace(" ", "")
|
||||
if text:
|
||||
leading.append(text)
|
||||
if len(leading) >= 3:
|
||||
break
|
||||
candidates = list(leading)
|
||||
candidates += [a + b for a, b in pairwise(leading)]
|
||||
return any(GONGWEN_TITLE_RE.match(text) for text in candidates)
|
||||
|
||||
|
||||
def check_profile_fonts(doc, faces: set[str], is_gongwen: bool) -> None:
|
||||
"""公文 faces are a national-standard requirement, not a preference.
|
||||
|
||||
Reported as WARN, never ERROR: the user's own unit template legitimately
|
||||
overrides GB/T 9704, and an unsatisfiable checker sends the model into a
|
||||
repair loop that burns the remaining rounds.
|
||||
"""
|
||||
if not is_gongwen or GONGWEN is None:
|
||||
return
|
||||
generic = sorted(face for face in faces if face in GENERIC_CN_FONTS)
|
||||
if not generic:
|
||||
return
|
||||
add(
|
||||
"WARN",
|
||||
f"这份文档看着是公文,却用到了通用字体:{', '.join(generic)}。"
|
||||
"GB/T 9704 规定标题二号方正小标宋简体、正文三号仿宋_GB2312、一级黑体、二级楷体_GB2312。"
|
||||
"调 apply_chinese_defaults(doc)(默认就是公文档)而不是逐处写字体名;"
|
||||
"「用户机器可能没装」不成立 —— 字体名只写进 XML,渲染在用户的 Word 里完成。"
|
||||
"若这是本单位模板要求的字体,忽略本条。",
|
||||
)
|
||||
|
||||
|
||||
def check_fonts(doc, profile=None) -> None:
|
||||
"""The headline check: every Chinese run must end up with a w:eastAsia face."""
|
||||
if not style_east_asia(doc.styles["Normal"]):
|
||||
add(
|
||||
@@ -259,12 +343,13 @@ def check_fonts(doc) -> None:
|
||||
f"{' …' if len(small) > 6 else ''}。打印后基本读不清 —— "
|
||||
f"正文 11pt、表格 10pt、图注 9pt,最小不要低于 {MIN_RUN_PT:g}pt。",
|
||||
)
|
||||
expected_pt = (profile or MODERN or {}).get("body", {}).get("pt", 11)
|
||||
normal_pt = style_size_pt(doc.styles["Normal"])
|
||||
if normal_pt is not None and normal_pt < MIN_BODY_PT:
|
||||
if normal_pt is not None and normal_pt < expected_pt - 1:
|
||||
add(
|
||||
"WARN",
|
||||
f"Normal 样式字号 {normal_pt:g}pt 偏小(正文规范 11pt)。"
|
||||
"在 apply_chinese_defaults(doc, body_pt=11) 里调回来。",
|
||||
f"Normal 样式字号 {normal_pt:g}pt 偏小(当前版式档的正文是 {expected_pt:g}pt)。"
|
||||
f"在 apply_chinese_defaults(doc, body_pt={expected_pt:g}) 里调回来。",
|
||||
)
|
||||
|
||||
|
||||
@@ -368,7 +453,7 @@ def check_images(doc, section) -> None:
|
||||
)
|
||||
|
||||
|
||||
def check_text(doc) -> None:
|
||||
def check_text(doc, is_gongwen: bool = False) -> None:
|
||||
empty_streak = 0
|
||||
for paragraph in doc.paragraphs:
|
||||
text = paragraph.text.strip()
|
||||
@@ -386,7 +471,15 @@ def check_text(doc) -> None:
|
||||
prefix = f"{where} " if where else ""
|
||||
for token in PLACEHOLDERS:
|
||||
if token in text:
|
||||
add("WARN", f"{prefix}残留占位文本「{token}」:{text[:40]} —— 换成真实内容,或整段删掉")
|
||||
# In an official document a bracketed 发文机关 is the *correct*
|
||||
# output when the user gave no authorised issuer — telling the
|
||||
# model to "replace with real content" invites it to invent one.
|
||||
advice = (
|
||||
"确认是否应换成真实机关名;用户没给授权名称时保留占位是对的,不要编造"
|
||||
if is_gongwen
|
||||
else "换成真实内容,或整段删掉"
|
||||
)
|
||||
add("WARN", f"{prefix}残留占位文本「{token}」:{text[:40]} —— {advice}")
|
||||
break
|
||||
if "\n" in paragraph.text:
|
||||
add(
|
||||
@@ -422,15 +515,20 @@ def check_fields(doc) -> None:
|
||||
def run_checks(path: str) -> None:
|
||||
doc = Document(path)
|
||||
section = doc.sections[0]
|
||||
check_fonts(doc)
|
||||
faces = document_faces(doc)
|
||||
is_gongwen = looks_like_gongwen(doc, faces)
|
||||
profile = GONGWEN if is_gongwen else MODERN
|
||||
check_fonts(doc, profile)
|
||||
check_profile_fonts(doc, faces, is_gongwen)
|
||||
check_headings(doc)
|
||||
check_tables(doc, section)
|
||||
check_images(doc, section)
|
||||
check_text(doc)
|
||||
check_text(doc, is_gongwen)
|
||||
check_fields(doc)
|
||||
|
||||
print()
|
||||
print("=== 体检 ===")
|
||||
print(f"(按「{'公文' if is_gongwen else '非公文'}」版式档校验)")
|
||||
|
||||
order = {"ERROR": 0, "WARN": 1, "INFO": 2}
|
||||
counts = {"ERROR": 0, "WARN": 0, "INFO": 0}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Typography profiles — the *values* half of this skill, kept apart from the code.
|
||||
|
||||
``docx_helpers`` owns the mechanics (``w:eastAsia``, field codes, cell widths).
|
||||
This module owns what those mechanics render *as*: which face, which size, which
|
||||
leading. Splitting them is what lets an imported third-party 公文 skill override
|
||||
the look without reimplementing the plumbing — it hands over a dict, not a script.
|
||||
|
||||
Two built-in profiles:
|
||||
|
||||
* ``gongwen`` — GB/T 9704-2012《党政机关公文格式》. **The default.**
|
||||
* ``modern`` — the generic corporate look (微软雅黑 11pt). For CVs, marketing
|
||||
one-pagers, outward-facing proposals — anything explicitly *not* an official
|
||||
document.
|
||||
|
||||
A profile is a plain dict so a caller can pass only the keys that differ::
|
||||
|
||||
resolve_profile("gongwen", {"body": {"font": "华文中宋"}})
|
||||
|
||||
Chinese size names, since every 公文 spec is written in them:
|
||||
初号42 · 小初36 · 一号26 · 小一24 · **二号22** · 小二18 · **三号16** · 小三15
|
||||
· **四号14** · 小四12 · 五号10.5 · 小五9
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
# GB/T 9704-2012. Sizes are the Chinese names resolved to points; the margins are
|
||||
# the standard's 版心 (37/35/28/26 mm). Body leading is fixed 28pt rather than a
|
||||
# multiple — the standard specifies 22 lines per page, which a multiple cannot
|
||||
# hold once the font size moves.
|
||||
GONGWEN = {
|
||||
"name": "gongwen",
|
||||
"page": {
|
||||
"margin_top_cm": 3.7,
|
||||
"margin_bottom_cm": 3.5,
|
||||
"margin_left_cm": 2.8,
|
||||
"margin_right_cm": 2.6,
|
||||
},
|
||||
"body": {
|
||||
"font": "仿宋_GB2312", # 三号仿宋
|
||||
"pt": 16,
|
||||
"line_pt": 28, # fixed leading, not a multiple
|
||||
"space_after_pt": 0, # 公文 runs continuous; spacing comes from leading
|
||||
"indent_chars": 2,
|
||||
"color": "000000",
|
||||
},
|
||||
# The document title (标题), not a Heading style: 二号方正小标宋简体, centred.
|
||||
"title": {"font": "方正小标宋简体", "pt": 22, "color": "000000"},
|
||||
# All four levels are 三号 — the standard separates them by *face*, not size.
|
||||
"headings": {
|
||||
1: {"font": "黑体", "pt": 16, "bold": False, "color": "000000"}, # 一、
|
||||
2: {"font": "楷体_GB2312", "pt": 16, "bold": False, "color": "000000"}, # (一)
|
||||
3: {"font": "仿宋_GB2312", "pt": 16, "bold": True, "color": "000000"}, # 1.
|
||||
4: {"font": "仿宋_GB2312", "pt": 16, "bold": True, "color": "000000"}, # (1)
|
||||
"space_before_pt": 0,
|
||||
"space_after_pt": 0,
|
||||
},
|
||||
"toc": {"font": "黑体", "pt": 16, "color": "000000"},
|
||||
"table": {
|
||||
"font": "仿宋_GB2312",
|
||||
"pt": 14, # 四号
|
||||
"header_fill": None, # 公文 tables carry no dark header band…
|
||||
"header_bold": True, # …so the header row has to be bold, or it reads as data
|
||||
"header_color": "000000",
|
||||
"banded": False,
|
||||
"band_fill": None,
|
||||
},
|
||||
"footer": {"font": "宋体", "pt": 14, "color": "000000"}, # 页码 四号宋体
|
||||
"caption": {"font": "楷体_GB2312", "pt": 14, "color": "000000"},
|
||||
}
|
||||
|
||||
# The look this skill shipped with. Values are carried over unchanged so a
|
||||
# document built before profiles existed renders identically under "modern".
|
||||
MODERN = {
|
||||
"name": "modern",
|
||||
"page": {
|
||||
"margin_top_cm": 2.54,
|
||||
"margin_bottom_cm": 2.54,
|
||||
"margin_left_cm": 2.54,
|
||||
"margin_right_cm": 2.54,
|
||||
},
|
||||
"body": {
|
||||
"font": "微软雅黑",
|
||||
"pt": 11,
|
||||
"line_multiple": 1.5,
|
||||
"space_after_pt": 6,
|
||||
"indent_chars": 2,
|
||||
"color": None,
|
||||
},
|
||||
"title": {"font": "微软雅黑", "pt": 20, "color": "1F1F1F"},
|
||||
"headings": {
|
||||
1: {"font": "微软雅黑", "pt": 20, "bold": True, "color": "1F1F1F"},
|
||||
2: {"font": "微软雅黑", "pt": 16, "bold": True, "color": "1F1F1F"},
|
||||
3: {"font": "微软雅黑", "pt": 14, "bold": True, "color": "1F1F1F"},
|
||||
4: {"font": "微软雅黑", "pt": 12, "bold": True, "color": "1F1F1F"},
|
||||
"space_before_pt": 12,
|
||||
"space_after_pt": 6,
|
||||
},
|
||||
"toc": {"font": "微软雅黑", "pt": 18, "color": "1F1F1F"},
|
||||
"table": {
|
||||
"font": "微软雅黑",
|
||||
"pt": 10,
|
||||
"header_fill": "1F4E79",
|
||||
"header_bold": True,
|
||||
"header_color": "FFFFFF",
|
||||
"banded": True,
|
||||
"band_fill": "F2F6FA",
|
||||
},
|
||||
"footer": {"font": "微软雅黑", "pt": 9, "color": "808080"},
|
||||
"caption": {"font": "微软雅黑", "pt": 9, "color": "808080"},
|
||||
}
|
||||
|
||||
BUILTIN = {"gongwen": GONGWEN, "modern": MODERN}
|
||||
|
||||
# Generic Chinese faces. Perfectly fine in a corporate document; in a 公文 they
|
||||
# mean the mandated face was silently swapped out. ``inspect_docx.py`` reads this.
|
||||
GENERIC_CN_FONTS = ("微软雅黑", "等线", "宋体", "SimSun", "Microsoft YaHei", "DengXian", "Noto Sans CJK SC")
|
||||
|
||||
_ACTIVE = deepcopy(GONGWEN)
|
||||
|
||||
|
||||
def _deep_merge(base: dict, extra: dict) -> dict:
|
||||
"""Merge ``extra`` into a copy of ``base``, recursing into nested dicts.
|
||||
|
||||
Nested rather than flat so a caller can say ``{"body": {"font": "华文中宋"}}``
|
||||
and keep the profile's size, leading and indent.
|
||||
"""
|
||||
merged = deepcopy(base)
|
||||
for key, value in extra.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = _deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = deepcopy(value)
|
||||
return merged
|
||||
|
||||
|
||||
def resolve_profile(profile=None, overrides: dict | None = None) -> dict:
|
||||
"""Resolve a profile name, a dict of overrides, or both, into a full profile.
|
||||
|
||||
resolve_profile() -> gongwen
|
||||
resolve_profile("modern") -> modern
|
||||
resolve_profile({"body": {"font": "华文中宋"}}) -> gongwen + that change
|
||||
resolve_profile("modern", {"body": {"pt": 12}}) -> modern + that change
|
||||
|
||||
An unknown name falls back to gongwen rather than raising: a build script that
|
||||
dies on a typo costs the user a whole round, a slightly wrong face does not.
|
||||
"""
|
||||
if profile is None:
|
||||
resolved = deepcopy(GONGWEN)
|
||||
elif isinstance(profile, str):
|
||||
resolved = deepcopy(BUILTIN.get(profile, GONGWEN))
|
||||
elif isinstance(profile, dict):
|
||||
# A bare dict is treated as overrides on top of the default profile,
|
||||
# unless it names a base itself.
|
||||
base = BUILTIN.get(profile.get("name"), GONGWEN)
|
||||
resolved = _deep_merge(base, profile)
|
||||
else:
|
||||
resolved = deepcopy(GONGWEN)
|
||||
|
||||
if overrides:
|
||||
resolved = _deep_merge(resolved, overrides)
|
||||
return resolved
|
||||
|
||||
|
||||
def set_active_profile(profile=None, overrides: dict | None = None) -> dict:
|
||||
"""Make a profile the default for every helper in this package.
|
||||
|
||||
``apply_chinese_defaults()`` calls this, so a build script picks its profile
|
||||
once and every later ``add_body`` / ``add_heading_cn`` / ``add_table`` follows
|
||||
without being told again. Passing the profile to each call individually is the
|
||||
reliable way to end up with a document that is 90% right.
|
||||
"""
|
||||
global _ACTIVE
|
||||
_ACTIVE = resolve_profile(profile, overrides)
|
||||
return _ACTIVE
|
||||
|
||||
|
||||
def active_profile() -> dict:
|
||||
"""The profile helpers fall back to when no explicit font/size is given."""
|
||||
return _ACTIVE
|
||||
|
||||
|
||||
def heading_spec(level: int, profile: dict | None = None) -> dict:
|
||||
"""Per-level heading values, falling back to level 4 beyond the fourth level."""
|
||||
headings = (profile or _ACTIVE)["headings"]
|
||||
return headings.get(level) or headings[4]
|
||||
@@ -297,6 +297,7 @@ class F048ResourcePermissionApi:
|
||||
"source": {
|
||||
"type": row.source_type,
|
||||
"include_children": row.include_children,
|
||||
"userset_relation": row.userset_relation,
|
||||
},
|
||||
"scope": row.scope,
|
||||
"inherited_from": row.inherited_from,
|
||||
|
||||
@@ -313,6 +313,7 @@ class GrantModelDTO(BaseModel):
|
||||
class GrantSourceDTO(BaseModel):
|
||||
type: str
|
||||
include_children: bool = False
|
||||
userset_relation: str | None = None
|
||||
|
||||
|
||||
class GrantAssigneeDTO(BaseModel):
|
||||
|
||||
@@ -93,22 +93,25 @@ class ToolExecutor(BaseTool):
|
||||
tool_instance: BaseTool = Field(..., description="Langchain Tool Instance")
|
||||
|
||||
@staticmethod
|
||||
def _build_permission_user(
|
||||
async def _build_permission_user(
|
||||
user_id: int,
|
||||
tenant_id: int,
|
||||
) -> UserPayload:
|
||||
from bisheng.utils.http_middleware import _check_is_global_super
|
||||
|
||||
return UserPayload(
|
||||
user_id=user_id,
|
||||
user_name="",
|
||||
user_role=[],
|
||||
tenant_id=tenant_id,
|
||||
is_global_super=await _check_is_global_super(user_id),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _ensure_use_permission_async(cls, tool_type: GptsToolsType, user_id: int) -> None:
|
||||
if not tool_type.id or not tool_type.tenant_id:
|
||||
raise PermissionError("Permission denied: use")
|
||||
login_user = cls._build_permission_user(
|
||||
login_user = await cls._build_permission_user(
|
||||
user_id,
|
||||
int(tool_type.tenant_id),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "backend"
|
||||
version = "2.6.0"
|
||||
version = "3.0.0-beta1"
|
||||
description = "BISHENG backend service"
|
||||
readme = "README.md"
|
||||
license = "Apache 2.0"
|
||||
|
||||
@@ -28,6 +28,7 @@ the contract that matters.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
@@ -232,6 +233,27 @@ def _docx_helpers():
|
||||
return module
|
||||
|
||||
|
||||
def _style_profiles():
|
||||
"""Import the bundle's style_profiles by path, without polluting sys.path."""
|
||||
source = BUILTIN_SKILLS_DIR / "bisheng-docx" / "scripts" / "style_profiles.py"
|
||||
spec = importlib.util.spec_from_file_location("builtin_style_profiles", source)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _style_face(doc, style_name: str) -> str | None:
|
||||
"""The ``w:eastAsia`` face a style actually carries — the only field that
|
||||
decides how a Chinese glyph renders."""
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
rpr = doc.styles[style_name].element.find(qn("w:rPr"))
|
||||
if rpr is None:
|
||||
return None
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
return rfonts.get(qn("w:eastAsia")) if rfonts is not None else None
|
||||
|
||||
|
||||
def build_good_docx(path: Path) -> Path:
|
||||
helpers = _docx_helpers()
|
||||
doc = Document()
|
||||
@@ -471,6 +493,162 @@ def test_docx_content_dump_prints_paragraphs_and_tables(samples):
|
||||
assert_mentions(out, "=== 内容 ===", "电力工程年度经营报告", "## 表 1")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# docx · 版式档 (style profiles)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_docx_defaults_to_the_gongwen_profile(tmp_path):
|
||||
"""Calling ``apply_chinese_defaults(doc)`` with no argument must produce a
|
||||
GB/T 9704 document.
|
||||
|
||||
Two sessions shipped 公文 in 微软雅黑 because the helper hardcoded a corporate
|
||||
face and the model, told by design-zh.md to avoid "exotic" fonts, went along
|
||||
with it. The default is the whole fix — parameterising the helpers while
|
||||
leaving 微软雅黑 as the default would have changed nothing.
|
||||
"""
|
||||
helpers = _docx_helpers()
|
||||
doc = Document()
|
||||
helpers.apply_chinese_defaults(doc)
|
||||
section = helpers.setup_page(doc)
|
||||
path = tmp_path / "gongwen.docx"
|
||||
doc.save(path)
|
||||
|
||||
saved = Document(path)
|
||||
assert _style_face(saved, "Normal") == "仿宋_GB2312"
|
||||
assert _style_face(saved, "Heading 1") == "黑体"
|
||||
assert _style_face(saved, "Heading 2") == "楷体_GB2312"
|
||||
# 三号 throughout; the standard separates levels by face, not by size
|
||||
assert saved.styles["Normal"].font.size.pt == 16
|
||||
assert saved.styles["Heading 1"].font.size.pt == 16
|
||||
assert saved.styles["Heading 2"].font.size.pt == 16
|
||||
# 版心: 37 / 35 / 28 / 26 mm
|
||||
assert round(section.top_margin.cm, 2) == 3.7
|
||||
assert round(section.left_margin.cm, 2) == 2.8
|
||||
|
||||
|
||||
def test_docx_modern_profile_restores_the_corporate_look(tmp_path):
|
||||
"""A CV or a marketing one-pager must still be able to opt out."""
|
||||
helpers = _docx_helpers()
|
||||
doc = Document()
|
||||
helpers.apply_chinese_defaults(doc, profile="modern")
|
||||
section = helpers.setup_page(doc)
|
||||
path = tmp_path / "modern.docx"
|
||||
doc.save(path)
|
||||
|
||||
saved = Document(path)
|
||||
assert _style_face(saved, "Normal") == "微软雅黑"
|
||||
assert saved.styles["Normal"].font.size.pt == 11
|
||||
assert saved.styles["Heading 1"].font.size.pt == 20
|
||||
assert round(section.top_margin.cm, 2) == 2.54
|
||||
|
||||
|
||||
def test_setup_page_before_apply_defaults_still_lands_on_profile_margins(tmp_path):
|
||||
"""SKILL.md's skeleton used to call ``setup_page`` first, and a model that
|
||||
copies the old order must not end up with 公文 margins on a modern document.
|
||||
|
||||
``apply_chinese_defaults`` therefore rewrites the margins of the sections
|
||||
already laid out, rather than trusting the call order.
|
||||
"""
|
||||
helpers = _docx_helpers()
|
||||
doc = Document()
|
||||
section = helpers.setup_page(doc) # laid out under whatever profile was active
|
||||
helpers.apply_chinese_defaults(doc, profile="modern")
|
||||
path = tmp_path / "order.docx"
|
||||
doc.save(path)
|
||||
|
||||
assert round(Document(path).sections[0].top_margin.cm, 2) == 2.54
|
||||
assert round(section.left_margin.cm, 2) == 2.54
|
||||
|
||||
|
||||
def test_profile_overrides_replace_only_the_keys_given():
|
||||
"""The override dict is the seam an imported third-party 公文 skill hands its
|
||||
unit template through, so it has to merge deeply — a caller naming one face
|
||||
must not lose the size, leading and indent that came with the profile."""
|
||||
profiles = _style_profiles()
|
||||
|
||||
merged = profiles.resolve_profile("gongwen", {"body": {"font": "华文中宋"}})
|
||||
assert merged["body"]["font"] == "华文中宋"
|
||||
assert merged["body"]["pt"] == 16 # untouched
|
||||
assert merged["body"]["line_pt"] == 28 # untouched
|
||||
assert merged["headings"][1]["font"] == "黑体" # untouched
|
||||
|
||||
# A bare dict is read as overrides on the default (公文) profile.
|
||||
bare = profiles.resolve_profile({"headings": {1: {"font": "方正小标宋简体"}}})
|
||||
assert bare["headings"][1]["font"] == "方正小标宋简体"
|
||||
assert bare["headings"][2]["font"] == "楷体_GB2312"
|
||||
assert bare["body"]["font"] == "仿宋_GB2312"
|
||||
|
||||
# Resolving must not mutate the module-level profile.
|
||||
assert profiles.GONGWEN["body"]["font"] == "仿宋_GB2312"
|
||||
|
||||
|
||||
def test_helpers_follow_the_active_profile_without_being_told(tmp_path):
|
||||
"""Passing the profile to every call is how a document ends up 90% right:
|
||||
``apply_chinese_defaults`` sets it once and the rest follow."""
|
||||
helpers = _docx_helpers()
|
||||
doc = Document()
|
||||
helpers.apply_chinese_defaults(doc)
|
||||
helpers.add_gongwen_title(doc, "关于××××的通知")
|
||||
helpers.add_body(doc, "正文内容。")
|
||||
helpers.add_heading_cn(doc, "一、总体情况", 1)
|
||||
helpers.add_table(doc, ["列一", "列二"], [["甲", "乙"]])
|
||||
path = tmp_path / "follow.docx"
|
||||
doc.save(path)
|
||||
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
faces = {
|
||||
run._element.find(qn("w:rPr")).find(qn("w:rFonts")).get(qn("w:eastAsia"))
|
||||
for paragraph in Document(path).paragraphs
|
||||
for run in paragraph.runs
|
||||
if run.text.strip()
|
||||
}
|
||||
assert faces == {"方正小标宋简体", "仿宋_GB2312", "黑体"}, faces
|
||||
|
||||
|
||||
def test_gongwen_document_built_with_generic_fonts_is_warned(tmp_path):
|
||||
"""Regression for the two shipped sessions: a document that is plainly an
|
||||
official one, rendered in 微软雅黑, must be called out.
|
||||
|
||||
Detection keys off the 公文 *intent* (a 「关于××的通知」title, or any of the
|
||||
four mandated faces appearing somewhere) rather than off the body face —
|
||||
keying off the body face would make the check vanish exactly when the body
|
||||
face is the thing that was replaced.
|
||||
"""
|
||||
helpers = _docx_helpers()
|
||||
doc = Document()
|
||||
helpers.apply_chinese_defaults(doc, profile="modern")
|
||||
helpers.add_gongwen_title(doc, "关于下发《电力行业映射表 V2.0》的通知")
|
||||
helpers.add_body(doc, "各有关部门:", indent=False)
|
||||
helpers.add_body(doc, "现将映射表予以印发,请遵照执行。")
|
||||
path = tmp_path / "gongwen_in_yahei.docx"
|
||||
doc.save(path)
|
||||
|
||||
out = run_script("bisheng-docx", "inspect_docx.py", path, "--check-only").stdout
|
||||
|
||||
assert_mentions(out, "按「公文」版式档校验", "看着是公文,却用到了通用字体", "微软雅黑")
|
||||
# WARN, never ERROR: a unit template legitimately overrides the standard, and
|
||||
# an unsatisfiable checker turns the model's repair loop into an infinite one.
|
||||
assert PASS in out, out
|
||||
assert "合计: 0 ERROR" in out
|
||||
|
||||
|
||||
def test_a_corporate_document_is_not_judged_as_gongwen(tmp_path):
|
||||
"""The 公文 detector must stay narrow — a weekly report in 微软雅黑 is fine."""
|
||||
helpers = _docx_helpers()
|
||||
doc = Document()
|
||||
helpers.apply_chinese_defaults(doc, profile="modern")
|
||||
helpers.add_heading_cn(doc, "本周工作进展", 1)
|
||||
helpers.add_body(doc, "本周完成三项交付,下周进入联调。")
|
||||
path = tmp_path / "weekly.docx"
|
||||
doc.save(path)
|
||||
|
||||
out = run_script("bisheng-docx", "inspect_docx.py", path, "--check-only").stdout
|
||||
|
||||
assert "按「非公文」版式档校验" in out, out
|
||||
assert "通用字体" not in out, out
|
||||
assert PASS in out, out
|
||||
|
||||
|
||||
def test_docx_render_degrades_without_libreoffice(samples, no_soffice_path):
|
||||
out = run_script(
|
||||
"bisheng-docx",
|
||||
@@ -588,6 +766,38 @@ def test_only_the_inspect_scripts_may_print_the_stop_string(script):
|
||||
assert owner or not printed, f"{script} prints {PASS!r}, which only the inspect_* script may claim: {printed}"
|
||||
|
||||
|
||||
_CN_FACE_LITERAL = re.compile(r"微软雅黑|仿宋|小标宋|楷体|黑体|等线|宋体")
|
||||
_COMPAT_CONSTANTS = {"CN_FONT", "CN_FONT_SERIF", "CN_FONT_HEADING"}
|
||||
|
||||
|
||||
def test_docx_helpers_read_faces_from_the_profile_not_from_literals():
|
||||
"""Static half of the profile fix: no helper may name a Chinese face itself.
|
||||
|
||||
The original defect was exactly this — ``add_heading_cn`` wrote
|
||||
``CN_FONT_HEADING`` into the run, at the highest precedence there is, so a
|
||||
build script could set every style correctly and still get 微软雅黑 headings.
|
||||
The module-level compat constants may stay (build scripts reference them);
|
||||
what may not come back is a *helper* reading one, or inlining a face name.
|
||||
"""
|
||||
source = (BUILTIN_SKILLS_DIR / "bisheng-docx" / "scripts" / "docx_helpers.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
|
||||
offenders: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in _COMPAT_CONSTANTS:
|
||||
offenders.append(f"line {node.lineno}: reads {node.id}")
|
||||
|
||||
for func in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
||||
body = func.body[1:] if ast.get_docstring(func) else func.body
|
||||
for statement in body:
|
||||
for node in ast.walk(statement):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
if _CN_FACE_LITERAL.search(node.value):
|
||||
offenders.append(f"line {node.lineno}: {func.name}() inlines {node.value!r}")
|
||||
|
||||
assert not offenders, "faces must come from style_profiles, not from docx_helpers: " + "; ".join(offenders)
|
||||
|
||||
|
||||
def test_warnings_alone_do_not_block_xlsx_delivery(tmp_path):
|
||||
"""WARN must never withhold the stop string, or the loop cannot terminate.
|
||||
|
||||
|
||||
@@ -124,11 +124,11 @@ class _Runtime:
|
||||
PermissionSourceExplanation(
|
||||
source_id=91,
|
||||
source_version=2,
|
||||
subject_type="user",
|
||||
subject_id="8",
|
||||
userset_relation=None,
|
||||
subject_type="user_group",
|
||||
subject_id="2",
|
||||
userset_relation="admin",
|
||||
include_children=False,
|
||||
source_type="DIRECT",
|
||||
source_type="USER_GROUP",
|
||||
model_key="viewer",
|
||||
model_level=1,
|
||||
scope="LOCAL",
|
||||
@@ -156,7 +156,10 @@ class _Subjects:
|
||||
)
|
||||
|
||||
async def display_names(self, subjects):
|
||||
return {("user", "8"): "Member 8"}
|
||||
return {
|
||||
("user", "8"): "Member 8",
|
||||
("user_group", "2"): "Reviewers",
|
||||
}
|
||||
|
||||
async def actor_projected_subjects(self, actor):
|
||||
return frozenset({f"user:{actor.user_id}"})
|
||||
@@ -518,7 +521,16 @@ async def test_roster_uses_bounded_sql_page_instead_of_full_explanation() -> Non
|
||||
page_size=25,
|
||||
)
|
||||
|
||||
assert result["data"][0]["subject"]["name"] == "Member 8"
|
||||
assert result["data"][0]["subject"] == {
|
||||
"type": "user_group",
|
||||
"id": "2",
|
||||
"name": "Reviewers",
|
||||
}
|
||||
assert result["data"][0]["source"] == {
|
||||
"type": "USER_GROUP",
|
||||
"include_children": False,
|
||||
"userset_relation": "admin",
|
||||
}
|
||||
assert result["has_more"] is True
|
||||
assert result["next_cursor"]
|
||||
assert runtime.page_calls == [
|
||||
|
||||
@@ -12,10 +12,15 @@ from bisheng.tool.domain.services.executor import ToolExecutor, ToolInitializati
|
||||
@pytest.mark.asyncio
|
||||
async def test_preset_tool_execution_still_checks_exact_use_action(monkeypatch):
|
||||
check_action = AsyncMock(return_value=True)
|
||||
check_global_super = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"bisheng.tool.domain.services.executor.check_business_action",
|
||||
check_action,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"bisheng.utils.http_middleware._check_is_global_super",
|
||||
check_global_super,
|
||||
)
|
||||
tool_type = SimpleNamespace(
|
||||
id=10,
|
||||
tenant_id=5,
|
||||
@@ -27,6 +32,8 @@ async def test_preset_tool_execution_still_checks_exact_use_action(monkeypatch):
|
||||
login_user = check_action.await_args.args[0]
|
||||
assert login_user.user_id == 7
|
||||
assert login_user.tenant_id == 5
|
||||
assert login_user.is_global_super is True
|
||||
check_global_super.assert_awaited_once_with(7)
|
||||
assert check_action.await_args.kwargs == {
|
||||
"resource_type": "tool",
|
||||
"resource_id": 10,
|
||||
@@ -39,6 +46,10 @@ async def test_preset_tool_execution_never_falls_back_when_fga_fails(
|
||||
monkeypatch,
|
||||
):
|
||||
check_action = AsyncMock(side_effect=PermissionFGAUnavailableError())
|
||||
monkeypatch.setattr(
|
||||
"bisheng.utils.http_middleware._check_is_global_super",
|
||||
AsyncMock(return_value=False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"bisheng.tool.domain.services.executor.check_business_action",
|
||||
check_action,
|
||||
@@ -52,6 +63,9 @@ async def test_preset_tool_execution_never_falls_back_when_fga_fails(
|
||||
with pytest.raises(PermissionFGAUnavailableError):
|
||||
await ToolExecutor._ensure_use_permission_async(tool_type, user_id=7)
|
||||
|
||||
login_user = check_action.await_args.args[0]
|
||||
assert login_user.is_global_super is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_by_tool_ids_can_skip_unauthorized_tools(monkeypatch):
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
},
|
||||
"src/components/Chat/ChatView.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 28
|
||||
"count": 27
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface PermissionGrantSubject {
|
||||
export interface PermissionGrantSource {
|
||||
type: string;
|
||||
include_children: boolean;
|
||||
userset_relation?: string | null;
|
||||
}
|
||||
|
||||
export interface PermissionGrantAssignee {
|
||||
|
||||
@@ -12,7 +12,7 @@ import AiChatMessages from '~/components/Chat/AiChatMessages';
|
||||
import { PinnedTaskPanel } from '~/components/Linsight/Execution/PinnedTaskPanel';
|
||||
import { WorkspacePanel } from '~/components/Linsight/Artifacts/WorkspacePanel';
|
||||
import { useWorkspacePanel } from '~/components/Linsight/Artifacts/useWorkspacePanel';
|
||||
import { type ArtifactFile, toUploadedArtifacts } from '~/components/Linsight/Artifacts/artifactUtils';
|
||||
import { collectConversationWorkspaceFiles } from '~/components/Linsight/Artifacts/artifactUtils';
|
||||
import { useLinsightManager } from '~/hooks/useLinsightManager';
|
||||
import { userStopLinsightEvent } from '~/api/linsight';
|
||||
import { SopStatus, taskModeState } from '~/store/linsight';
|
||||
@@ -363,15 +363,20 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
|
||||
// 12221-40080) for the conversation's latest task turn — it tracks that turn's
|
||||
// execution detail from the linsight store rather than scrolling away in the
|
||||
// message stream.
|
||||
const latestTaskVersionId = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i] as any;
|
||||
if (m?.category === 'task' && m?.linsightSessionVersionId) {
|
||||
return m.linsightSessionVersionId as string;
|
||||
const taskVersionIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const message of messages) {
|
||||
const m = message as any;
|
||||
const versionId = m?.category === 'task' ? m?.linsightSessionVersionId : '';
|
||||
if (versionId && !seen.has(versionId)) {
|
||||
seen.add(versionId);
|
||||
ids.push(versionId);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
return ids;
|
||||
}, [messages]);
|
||||
const latestTaskVersionId = taskVersionIds[taskVersionIds.length - 1] || '';
|
||||
|
||||
// F035: a conversation that already holds a task turn IS a task-mode
|
||||
// conversation. Guard on the message's own conversationId so the stale
|
||||
@@ -451,10 +456,11 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
|
||||
|
||||
// F035: workspace drawer for the chat-embedded task mode. Lifted to ChatView
|
||||
// (the task turn renders inline per message, but the entry button lives in the
|
||||
// shared header) and bound to the LATEST task turn. Shows uploaded sources +
|
||||
// generated deliverables. The drawer only opens on the header button — no
|
||||
// auto-expand (the entry icon appearing is enough).
|
||||
const { getLinsight, updateLinsight } = useLinsightManager();
|
||||
// shared header). Execution state remains bound to the LATEST task turn, while
|
||||
// the file list accumulates formal artifacts from every task turn because the
|
||||
// workspace itself belongs to the conversation. The drawer only opens on the
|
||||
// header button — no auto-expand (the entry icon appearing is enough).
|
||||
const { getLinsight, updateLinsight, linsightMap } = useLinsightManager();
|
||||
const taskArtifacts = useWorkspacePanel(latestTaskVersionId);
|
||||
|
||||
// F035: enter/exit animation for the fullscreen workspace overlay. The overlay
|
||||
@@ -559,10 +565,8 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
|
||||
|
||||
const taskLinsight = latestTaskVersionId ? getLinsight(latestTaskVersionId) : null;
|
||||
const taskWorkspaceFiles = useMemo(() => {
|
||||
const uploaded = toUploadedArtifacts(taskLinsight?.files as any[]);
|
||||
const generated = (taskLinsight?.file_list as ArtifactFile[]) || [];
|
||||
return [...uploaded, ...generated];
|
||||
}, [taskLinsight?.files, taskLinsight?.file_list]);
|
||||
return collectConversationWorkspaceFiles(taskVersionIds.map((versionId) => linsightMap.get(versionId)));
|
||||
}, [taskVersionIds, linsightMap]);
|
||||
|
||||
// F035: while the latest task round is in a non-terminal state (generating /
|
||||
// running / queued), the input stays editable but the send button is disabled
|
||||
|
||||
@@ -116,6 +116,55 @@ export function toUploadedArtifacts(files: any[] | undefined): ArtifactFile[] {
|
||||
});
|
||||
}
|
||||
|
||||
export interface WorkspaceArtifactSnapshot {
|
||||
files?: Parameters<typeof toUploadedArtifacts>[0];
|
||||
file_list?: ArtifactFile[];
|
||||
history?: Array<{ file_list?: ArtifactFile[] }>;
|
||||
}
|
||||
|
||||
function getWorkspaceArtifactKey(file: ArtifactFile): string {
|
||||
const source = file.source || 'output';
|
||||
const normalizedPath = decodeSafe(file.file_path || '').replace(/\\/g, '/');
|
||||
const workspacePath = normalizedPath.match(/(?:^|\/)(output|uploads)\/(.+)$/i);
|
||||
const identity = workspacePath
|
||||
? `${workspacePath[1].toLowerCase()}/${workspacePath[2]}`
|
||||
: decodeSafe(file.file_name || '').toLowerCase();
|
||||
return `${source}:${identity}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the conversation-scoped workspace list from per-round result snapshots.
|
||||
*
|
||||
* `final_files` describes what one task round delivered; it is not the workspace
|
||||
* itself. A later greeting can legitimately have an empty `final_files` array
|
||||
* while still inheriting every prior deliverable. Accumulate snapshots in
|
||||
* chronological order so the drawer keeps those files, with a newer artifact
|
||||
* replacing an older entry at the same workspace path.
|
||||
*/
|
||||
export function collectConversationWorkspaceFiles(
|
||||
snapshots: Array<WorkspaceArtifactSnapshot | null | undefined>,
|
||||
): ArtifactFile[] {
|
||||
const collected = new Map<string, ArtifactFile>();
|
||||
|
||||
const addFiles = (files: ArtifactFile[] | undefined) => {
|
||||
for (const file of files || []) {
|
||||
if (!file?.file_name || !file?.file_url) continue;
|
||||
collected.set(getWorkspaceArtifactKey(file), file);
|
||||
}
|
||||
};
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
if (!snapshot) continue;
|
||||
addFiles(toUploadedArtifacts(snapshot.files));
|
||||
for (const round of snapshot.history || []) {
|
||||
addFiles(round.file_list);
|
||||
}
|
||||
addFiles(snapshot.file_list);
|
||||
}
|
||||
|
||||
return Array.from(collected.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a MinIO share url into a same-origin fetchable path.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
type ArtifactFile,
|
||||
collectConversationWorkspaceFiles,
|
||||
} from './artifactUtils';
|
||||
|
||||
const makeArtifact = (overrides: Partial<ArtifactFile>): ArtifactFile => ({
|
||||
file_id: overrides.file_id || Math.random().toString(36).slice(2),
|
||||
file_name: overrides.file_name || 'artifact.md',
|
||||
file_url: overrides.file_url || 'linsight/final_result/version/artifact.md',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('collectConversationWorkspaceFiles', () => {
|
||||
it('keeps prior deliverables when the latest round has no final files', () => {
|
||||
const report = makeArtifact({ file_name: 'report.md', file_url: 'final/old/report.md' });
|
||||
|
||||
expect(collectConversationWorkspaceFiles([
|
||||
{ file_list: [report] },
|
||||
{ file_list: [] },
|
||||
])).toEqual([report]);
|
||||
});
|
||||
|
||||
it('lets a newer round replace the same workspace path', () => {
|
||||
const oldReport = makeArtifact({
|
||||
file_name: 'report.md',
|
||||
file_url: 'final/old/report.md',
|
||||
file_path: '/cache/old/output/report.md',
|
||||
});
|
||||
const newReport = makeArtifact({
|
||||
file_name: 'report.md',
|
||||
file_url: 'final/new/report.md',
|
||||
file_path: '/cache/new/output/report.md',
|
||||
});
|
||||
|
||||
expect(collectConversationWorkspaceFiles([
|
||||
{ file_list: [oldReport] },
|
||||
{ file_list: [newReport] },
|
||||
])).toEqual([newReport]);
|
||||
});
|
||||
|
||||
it('includes completed history rounds when multiple rounds share one version', () => {
|
||||
const first = makeArtifact({ file_name: 'round-one.md' });
|
||||
const second = makeArtifact({ file_name: 'round-two.docx' });
|
||||
|
||||
expect(collectConversationWorkspaceFiles([{
|
||||
history: [{ file_list: [first] }],
|
||||
file_list: [second],
|
||||
}])).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('keeps uploaded sources separate from generated files with the same name', () => {
|
||||
const output = makeArtifact({ file_name: 'requirements.docx', source: 'output' });
|
||||
const files = collectConversationWorkspaceFiles([{
|
||||
files: [{
|
||||
file_id: 'upload-1',
|
||||
file_name: 'requirements.docx',
|
||||
markdown_file_path: 'workspace/version/uploads/requirements.md',
|
||||
}],
|
||||
file_list: [output],
|
||||
}]);
|
||||
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files.map((file) => file.source)).toEqual(['upload', 'output']);
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,22 @@ jest.mock("./SubjectSearchDepartment", () => ({
|
||||
}));
|
||||
|
||||
jest.mock("./SubjectSearchUserGroup", () => ({
|
||||
SubjectSearchUserGroup: () => <div>group picker</div>,
|
||||
SubjectSearchUserGroup: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (
|
||||
subjects: Array<{ type: "user_group"; id: number; name: string }>,
|
||||
) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange([{ type: "user_group", id: 77, name: "Reviewers" }])
|
||||
}
|
||||
>
|
||||
select group
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockedGetModels = getGrantablePermissionModels as jest.MockedFunction<
|
||||
@@ -256,6 +271,37 @@ describe("F048 Client PermissionGrantTab", () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows an existing user-group administrator grant", async () => {
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
context={context}
|
||||
assignees={[
|
||||
existing("77", {
|
||||
subject: {
|
||||
type: "user_group",
|
||||
id: "77",
|
||||
name: "Reviewers",
|
||||
},
|
||||
source: {
|
||||
type: "USER_GROUP",
|
||||
include_children: false,
|
||||
userset_relation: "admin",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
onSuccess={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/f048_permission\.source\.user_group_admin/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps an inactive existing row read-only when it is not grantable", async () => {
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
@@ -427,6 +473,47 @@ describe("F048 Client PermissionGrantTab", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose or submit a user-group administrator grant", async () => {
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="channel"
|
||||
resourceId="channel-1"
|
||||
context={context}
|
||||
onSuccess={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByLabelText("f048_permission.grant.add_model");
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "f048_permission.subject.user_group",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "select group" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "f048_permission.grant.add" }),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "f048_permission.grant.submit" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockedMutate).toHaveBeenCalledTimes(1));
|
||||
expect(mockedMutate.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
changes: [
|
||||
{
|
||||
op: "ADD",
|
||||
model_key: "viewer",
|
||||
subject: {
|
||||
type: "user_group",
|
||||
id: "77",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on a version conflict", async () => {
|
||||
mockedMutate.mockRejectedValueOnce(new Error("version conflict"));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import { Button, Checkbox } from "~/components/ui";
|
||||
import { useLocalize } from "~/hooks";
|
||||
import { canMutatePermissionAssignee } from "./assigneePolicy";
|
||||
import { SourceBadge } from "./SourceBadge";
|
||||
import { SubjectSearchDepartment } from "./SubjectSearchDepartment";
|
||||
import { SubjectSearchUser } from "./SubjectSearchUser";
|
||||
import { SubjectSearchUserGroup } from "./SubjectSearchUserGroup";
|
||||
@@ -398,15 +399,15 @@ export function PermissionGrantTab({
|
||||
{assignee.subject.name ||
|
||||
`${assignee.subject.type}:${assignee.subject.id}`}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#818181]">
|
||||
{assignee.source.type}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-[#818181]">
|
||||
<SourceBadge source={assignee.source} />
|
||||
{assignee.protected && (
|
||||
<span className="ml-2 inline-flex items-center gap-1">
|
||||
<LockKeyhole aria-hidden="true" className="size-3" />
|
||||
{localize("f048_permission.roster.protected")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
aria-label={`${localize(
|
||||
|
||||
@@ -31,6 +31,11 @@ export function SourceBadge({ source }: SourceBadgeProps) {
|
||||
· {localize("f048_permission.source.include_children")}
|
||||
</span>
|
||||
)}
|
||||
{source.userset_relation === "admin" && (
|
||||
<span className="font-normal">
|
||||
· {localize("f048_permission.source.user_group_admin")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2170,6 +2170,7 @@
|
||||
"shared": "Shared Source",
|
||||
"system": "System Source",
|
||||
"include_children": "Include sub-departments",
|
||||
"user_group_admin": "User group administrators",
|
||||
"snapshot_from_parent": "Copied on inherit",
|
||||
"space_membership": "Space member",
|
||||
"channel_membership": "Channel member",
|
||||
|
||||
@@ -2093,6 +2093,7 @@
|
||||
"shared": "共有元",
|
||||
"system": "システム元",
|
||||
"include_children": "サブ部門を含む",
|
||||
"user_group_admin": "ユーザーグループ管理者",
|
||||
"snapshot_from_parent": "継承時にコピー",
|
||||
"space_membership": "スペースメンバー",
|
||||
"channel_membership": "チャンネルメンバー",
|
||||
|
||||
@@ -2099,6 +2099,7 @@
|
||||
"shared": "共享来源",
|
||||
"system": "系统来源",
|
||||
"include_children": "包含子部门",
|
||||
"user_group_admin": "用户组管理员",
|
||||
"snapshot_from_parent": "继承时复制",
|
||||
"space_membership": "空间成员",
|
||||
"channel_membership": "频道成员",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"shared": "Shared Source",
|
||||
"system": "System Source",
|
||||
"includeChildren": "Include sub-departments",
|
||||
"userGroupAdmin": "User group administrators",
|
||||
"snapshot_from_parent": "Copied on inherit",
|
||||
"space_membership": "Space member",
|
||||
"channel_membership": "Channel member",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"shared": "共有元",
|
||||
"system": "システム元",
|
||||
"includeChildren": "サブ部門を含む",
|
||||
"userGroupAdmin": "ユーザーグループ管理者",
|
||||
"snapshot_from_parent": "継承時にコピー",
|
||||
"space_membership": "スペースメンバー",
|
||||
"channel_membership": "チャンネルメンバー",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"shared": "共享来源",
|
||||
"system": "系统来源",
|
||||
"includeChildren": "包含子部门",
|
||||
"userGroupAdmin": "用户组管理员",
|
||||
"snapshot_from_parent": "继承时复制",
|
||||
"space_membership": "空间成员",
|
||||
"channel_membership": "频道成员",
|
||||
|
||||
@@ -29,6 +29,9 @@ export function SourceBadge({ source }: SourceBadgeProps) {
|
||||
{source.include_children && (
|
||||
<span className="font-normal">· {t("source.includeChildren")}</span>
|
||||
)}
|
||||
{source.userset_relation === "admin" && (
|
||||
<span className="font-normal">· {t("source.userGroupAdmin")}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ export interface PermissionGrantSubject {
|
||||
export interface PermissionGrantSource {
|
||||
type: string
|
||||
include_children: boolean
|
||||
userset_relation?: string | null
|
||||
}
|
||||
|
||||
export interface PermissionGrantAssignee {
|
||||
|
||||
@@ -36,7 +36,23 @@ vi.mock("@/components/bs-comp/permission/SubjectSearchDepartment", () => ({
|
||||
}))
|
||||
|
||||
vi.mock("@/components/bs-comp/permission/SubjectSearchUserGroup", () => ({
|
||||
SubjectSearchUserGroup: () => <div>group-picker</div>,
|
||||
SubjectSearchUserGroup: (props: {
|
||||
value: Array<{ type: "user_group"; id: number; name: string }>
|
||||
onChange: (
|
||||
value: Array<{ type: "user_group"; id: number; name: string }>,
|
||||
) => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
props.onChange([
|
||||
{ type: "user_group", id: 77, name: "Reviewers" },
|
||||
])
|
||||
}
|
||||
>
|
||||
select-group
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const context: ResourcePermissionContext = {
|
||||
@@ -209,6 +225,71 @@ describe("F048 PermissionGrantTab", () => {
|
||||
expect(screen.getByRole("button", { name: "grant.remove.42" })).toBeDisabled()
|
||||
})
|
||||
|
||||
it("shows an existing user-group administrator grant", async () => {
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="workflow"
|
||||
resourceId="flow-1"
|
||||
context={context}
|
||||
assignees={[
|
||||
{
|
||||
...editableAssignee,
|
||||
subject: {
|
||||
type: "user_group",
|
||||
id: "77",
|
||||
name: "Reviewers",
|
||||
},
|
||||
source: {
|
||||
type: "USER_GROUP",
|
||||
include_children: false,
|
||||
userset_relation: "admin",
|
||||
},
|
||||
},
|
||||
]}
|
||||
onSuccess={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/source\.userGroupAdmin/),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("does not expose or submit a user-group administrator grant", async () => {
|
||||
render(
|
||||
<PermissionGrantTab
|
||||
resourceType="workflow"
|
||||
resourceId="flow-1"
|
||||
context={context}
|
||||
onSuccess={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await screen.findByLabelText("grant.addModel")
|
||||
fireEvent.click(screen.getByRole("button", { name: "subject.userGroup" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "select-group" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "grant.submit" }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mutateResourceGrantsApi).toHaveBeenCalledWith(
|
||||
"workflow",
|
||||
"flow-1",
|
||||
expect.objectContaining({
|
||||
changes: [
|
||||
{
|
||||
op: "ADD",
|
||||
model_key: "viewer",
|
||||
subject: {
|
||||
type: "user_group",
|
||||
id: "77",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("removes exactly one editable assignee and reports stale conflicts", async () => {
|
||||
vi.mocked(mutateResourceGrantsApi).mockRejectedValueOnce(
|
||||
new Error("stale resource version"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#! /bin/bash
|
||||
|
||||
old_version="2.6.0-fix"
|
||||
new_version="2.6.0-fix2"
|
||||
old_version="2.6.0-fix2"
|
||||
new_version="3.0.0-beta1"
|
||||
sed -i.bak "s/$old_version/$new_version/g" ./docker/docker-compose.yml
|
||||
sed -i.bak "s/$old_version/$new_version/g" ./src/backend/pyproject.toml
|
||||
sed -i.bak "s/$old_version/$new_version/g" ./src/backend/bisheng/__init__.py
|
||||
|
||||
Reference in New Issue
Block a user