Add ABB120 demo suite and docs

This commit is contained in:
wangdequan
2026-06-28 20:58:18 +08:00
parent 84a5b54195
commit 65187499d6
820 changed files with 215410 additions and 211 deletions

View File

@@ -0,0 +1,598 @@
import { execFileSync } from "node:child_process";
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync
} from "node:fs";
import { join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const webRoot = join(root, "kdl-wasm", "web");
const docRoot = join(root, "work", "doc");
const stagingRoot = join(webRoot, "test-results", "grl-manual-docx-build");
const outputPath = join(docRoot, "GRL功能语法逻辑与程序创建使用手册.docx");
const flowDir = join(docRoot, "通用机器人项目功能与数据流程图-png");
const flowImages = [
{ source: join(flowDir, "flow-01.png"), title: "系统功能总览图", cx: 5943600, cy: 3343275 },
{ source: join(flowDir, "flow-02.png"), title: "GRL 到 IR 编译流程图", cx: 5943600, cy: 3343275 },
{ source: join(flowDir, "flow-03.png"), title: "运动规划与 KDL 数据流图", cx: 5943600, cy: 3343275 },
{ source: join(flowDir, "flow-04.png"), title: "虚拟控制器执行流程图", cx: 5943600, cy: 3343275 },
{ source: join(flowDir, "flow-05.png"), title: "后处理与导入回读流程图", cx: 5943600, cy: 3343275 }
];
const uiImages = [
{
source: join(webRoot, "test-results", "virtual-controller", "virtual-controller-desktop.png"),
title: "虚拟控制器桌面端界面",
cx: 5943600,
cy: 3962400
},
{
source: join(webRoot, "test-results", "virtual-controller", "virtual-controller-mobile.png"),
title: "虚拟控制器移动端界面",
cx: 2743200,
cy: 5943600
}
];
const smokeSourcePath = join(webRoot, "tests", "fixtures", "abb120", "programs", "A120_00_Smoke.grl");
const blendSourcePath = join(webRoot, "tests", "fixtures", "abb120", "programs", "A120_20_CartesianBlend.grl");
const ioSourcePath = join(webRoot, "tests", "fixtures", "abb120", "spec-programs", "runtime", "W2_60_IOWaitPulse.grl");
const fullExamplePath = join(webRoot, "tests", "fixtures", "abb120", "spec-programs", "runtime", "W2_99_FullSpecExample.grl");
const generatedAt = formatShanghaiTime(new Date());
buildDocx();
console.log(JSON.stringify({ outputPath }, null, 2));
function buildDocx() {
assertSafeStaging(stagingRoot);
rmSync(stagingRoot, { recursive: true, force: true });
mkdirSync(join(stagingRoot, "_rels"), { recursive: true });
mkdirSync(join(stagingRoot, "docProps"), { recursive: true });
mkdirSync(join(stagingRoot, "word", "_rels"), { recursive: true });
mkdirSync(join(stagingRoot, "word", "media"), { recursive: true });
const images = copyImages([...flowImages, ...uiImages]);
writeFileSync(join(stagingRoot, "[Content_Types].xml"), contentTypesXml(), "utf8");
writeFileSync(join(stagingRoot, "_rels", ".rels"), packageRelsXml(), "utf8");
writeFileSync(join(stagingRoot, "docProps", "core.xml"), corePropsXml(), "utf8");
writeFileSync(join(stagingRoot, "docProps", "app.xml"), appPropsXml(), "utf8");
writeFileSync(join(stagingRoot, "word", "styles.xml"), stylesXml(), "utf8");
writeFileSync(join(stagingRoot, "word", "_rels", "document.xml.rels"), documentRelsXml(images), "utf8");
writeFileSync(join(stagingRoot, "word", "document.xml"), documentXml(images), "utf8");
if (existsSync(outputPath)) {
rmSync(outputPath, { force: true });
}
zipDirectory(stagingRoot, outputPath);
}
function documentXml(images) {
const body = [];
body.push(heading("GRL 功能、语法逻辑与程序创建使用手册", 1));
body.push(p(`生成时间:${generatedAt}`));
body.push(p("适用对象:通用机器人离线编程系统用户、工艺工程师、自动编程开发人员、虚拟调试人员。"));
body.push(p("手册目标:用中文说明 GRL 的功能边界、语法逻辑、程序对象关系、创建程序步骤、仿真检查和后处理输出方式,并通过图示和源码示例帮助用户快速编写可运行程序。"));
body.push(heading("一、GRL 是什么", 1));
body.push(p("GRL全称 Generic Robot Language是本离线编程系统中的通用机器人程序语言。它不是 ABB、FANUC 或 KUKA 任一品牌语言的拷贝而是把多品牌机器人共同具备的目标点、运动、路径、工艺、IO、流程控制、异常和后处理语义抽象为统一文本。"));
body.push(table([
["目标", "说明"],
["统一描述", "用同一套 GRL 描述机器人程序、路径、工艺动作和 IO 逻辑。"],
["可编译", "GRL 经 lexer/parser/semantic analyzer 编译为统一 Executable IR。"],
["可仿真", "IR 可进入虚拟控制器运行,产生 trace、motion queue、IO image 和诊断。"],
["可后处理", "同一 IR 可输出 ABB RAPID、FANUC LS/TP 风格文本、KUKA KRL 原型程序。"],
["可维护", "文本稳定、可读、可 diff适合版本管理和自动生成。"]
]));
body.push(heading("二、系统功能总览", 1));
body.push(p("离线编程系统从项目数据、机器人模型和 GRL 程序出发经过语义编译、KDL 运动规划、虚拟控制器执行、诊断报告和多品牌后处理,形成完整离线开发闭环。"));
body.push(imageWithCaption(images[0], 1, "图 1 系统功能总览图"));
body.push(table([
["模块", "主要职责"],
["GRL 源码", "声明工具、坐标系、目标点、速度、zone、path、operation 和 proc。"],
["编译器", "把源码解析为 AST做符号解析、单位归一化、语义检查生成 IR 和 KDL request。"],
["KDL 运动规划", "处理 FK、IK、MoveJ、MoveL、MoveC、Path planning、可达性和轨迹采样。"],
["虚拟控制器", "执行 IR维护程序计数器、调用栈、报警队列、IO 镜像、运动队列和 trace。"],
["后处理器", "按 ABB/FANUC/KUKA 规则输出品牌程序,并记录无法保真映射的转换报告。"]
]));
body.push(heading("三、GRL 程序基本结构", 1));
body.push(p("一个 GRL 文件通常由 language 声明、module 声明、数据声明、目标点、路径、工艺操作和 main 子程序组成。最小程序只需要定义模块入口和一条运动。"));
body.push(codeBlock(readFileSync(smokeSourcePath, "utf8")));
body.push(table([
["结构", "作用"],
["language grl 0.1", "声明语言版本,便于后续兼容升级。"],
["module", "程序模块,类似 ABB MODULE、KUKA DEF 文件或 FANUC 程序容器。"],
["const/persistent/var", "声明常量、持久数据或局部变量。"],
["target", "声明 joint_target 或 pose_target。"],
["proc main()", "默认入口程序。虚拟控制器从 main 开始执行。"],
["movej/movel/movec", "基础运动指令,最终进入 KDL 规划或品牌后处理。"]
]));
body.push(heading("四、对象模型和语法逻辑", 1));
body.push(p("GRL 的语法逻辑建议按“数据先声明、路径再组织、工艺再封装、入口只调用”的方式理解。手写小程序可以直接在 main 中写运动,自动编程和复杂工艺应优先生成 path 和 operation。"));
body.push(imageWithCaption(images[1], 2, "图 2 GRL 到 IR 编译流程图"));
body.push(table([
["对象", "语法", "使用建议"],
["工具", "persistent tool gripper = tool { ... }", "描述 TCP、质量、重心。运动时通过 tool 参数或 path defaults 引用。"],
["坐标系", "persistent frame fixture = frame { ... }", "描述工件坐标或基坐标。目标点和路径应显式绑定 frame。"],
["速度", "const speed v = linear(300 mm/s) / joint(50 %)", "按运动类型选择 joint、linear、angular。"],
["过渡", "const zone z10 = z(10 mm) / fine / cnt(30)", "fine 精确到点z/cnt/continuous 用于连续过渡。"],
["目标点", "target pick = pose_target { pose: pose(...) }", "关节目标用 joint_target笛卡尔点用 pose_target。"],
["路径", "path pick_path { defaults ... point ... event ... }", "自动编程优先生成 path点名保持稳定。"],
["工艺", "operation pick_op { kind: handling path: pick_path ... }", "封装路径、工艺参数、start_action/end_action。"],
["入口程序", "proc main() run_operation pick_op end", "入口程序保持短,便于调试和后处理。"]
]));
body.push(heading("五、表达式、单位和常量求值", 1));
body.push(p("GRL 支持带单位表达式。编译器会在需要常量的位置做单位归一化,例如 mm 转 m、deg 转 rad、ms 转 s、% 转比例。这样同一程序在仿真和后处理中使用统一数值。"));
body.push(table([
["类别", "示例", "说明"],
["长度", "100 mm、0.25 m", "内部统一为 meter。"],
["角度", "90 deg、1.57 rad", "内部统一为 radian。"],
["时间", "200 ms、2 s", "内部统一为 second。"],
["速度", "linear(300 mm/s)、joint(50 %)", "分别表示 TCP 线速度和关节速度比例。"],
["数学函数", "atan2(0, 1)、max(50 mm/s, 150 mm/s)", "支持常用三角函数和数值函数。"],
["单位检查", "10 mm + 2 s", "不同维度相加会产生诊断。"]
]));
body.push(heading("六、运动语法", 1));
body.push(p("运动指令分为 MoveJ、MoveL、MoveC 三类。MoveJ 走关节空间MoveL 走 TCP 直线MoveC 走 TCP 圆弧。每条运动最终都需要确定 target、speed、zone、tool 和 frame。"));
body.push(imageWithCaption(images[2], 3, "图 3 运动规划与 KDL 数据流图"));
body.push(table([
["指令", "语法", "语义", "品牌映射"],
["movej", "movej home speed v_joint zone fine", "关节空间运动,适合回零和快速接近。", "ABB MoveJ / FANUC J / KUKA PTP"],
["movel", "movel pick speed v_linear zone z10", "TCP 直线运动,适合接近、加工、搬运直线段。", "ABB MoveL / FANUC L / KUKA LIN"],
["movec", "movec via mid target arc_end speed v zone z10", "TCP 圆弧运动,通过 via 点到终点。", "ABB MoveC / FANUC C / KUKA CIRC"],
["run_path", "run_path cart_path", "执行已声明路径。", "可展开为品牌运动语句"],
["run_operation", "run_operation pick_op", "执行工艺操作,包含 action 和 path。", "按品牌能力展开或报告"]
]));
body.push(p("下面是当前工作区中的笛卡尔混合路径示例,包含 tool/frame、表达式速度、zone clamp、MoveL、MoveC 和 run_path。"));
body.push(codeBlock(readFileSync(blendSourcePath, "utf8")));
body.push(heading("七、Path 和 Operation", 1));
body.push(p("Path 是离线编程的核心对象,适合承载 CAD 曲线、示教点、工艺采样点和路径事件。Operation 是对 Path 的工艺封装,适合表达搬运、焊接、喷涂、打磨、上下料等任务。"));
body.push(table([
["写法", "说明"],
["defaults", "为路径点设置默认 speed、zone、tool、frame减少重复。"],
["source", "记录 CAD 曲线、采样间距、规划来源等元数据。"],
["point", "定义路径点和该点运动方式。"],
["event before/after/at", "定义路径事件,例如到点后开夹爪、距离某点前开焊接。"],
["operation process", "记录工艺参数,例如 gripper、weld_mode、paint_recipe。"],
["start_action/end_action", "操作开始或结束时执行 IO、wait、pulse 等动作。"]
]));
body.push(heading("八、IO、wait 和 pulse", 1));
body.push(p("GRL 中的 IO 使用统一引用格式io.di、io.do、io.ai、io.ao、io.gi、io.go、io.ri、io.ro 和 io.alias。离线系统运行时维护虚拟 IO 镜像,程序、用户和脚本都可以写入这份镜像。"));
body.push(table([
["语法", "含义"],
["io.do[1] = true", "写数字输出。"],
["io.ao[1] = 0.75", "写模拟输出。"],
["wait io.di[1] == true timeout 2 s", "等待输入满足条件,超时产生诊断或报警。"],
["wait all(...)", "所有条件满足才继续。"],
["wait any(...)", "任一条件满足即继续。"],
["rising/falling/changed", "边沿或变化检测。"],
["pulse io.do[2] duration 100 ms", "输出置 true持续指定时间后自动复位。"]
]));
body.push(codeBlock(readFileSync(ioSourcePath, "utf8")));
body.push(heading("九、流程控制、子程序和异常", 1));
body.push(p("GRL 支持结构化控制和子程序。简单运行逻辑写在 proc 中,纯计算逻辑写在 func 中。func 默认不应产生运动、IO、wait、pulse 等副作用。"));
body.push(table([
["能力", "语法", "说明"],
["条件", "if / elseif / else / end", "按布尔条件选择执行分支。"],
["循环", "while、for to step", "执行重复逻辑break/continue 必须处于合法块内。"],
["多分支", "switch / case / default", "case 必须是常量表达式,不能重复。"],
["子程序", "proc name(in int slot) ... end", "可定义 in/out/inout 参数。"],
["函数", "func bool ok() return ... end", "有返回值,建议无副作用。"],
["报警", "alarm ID \"message\" severity warning", "进入虚拟控制器报警队列。"],
["异常", "raise、try/catch/finally", "P0 支持基础语义,复杂品牌恢复分阶段扩展。"]
]));
body.push(heading("十、从零创建一个 GRL 程序", 1));
body.push(p("建议按以下顺序创建程序。先保证数据和坐标清楚,再组织路径和工艺,最后在 main 中调用。"));
body.push(table([
["步骤", "操作", "检查点"],
["1", "创建 .grl 文件,写 language 和 module。", "模块名使用 ASCII 标识符,文件 UTF-8。"],
["2", "声明 tool 和 frame。", "TCP、质量、工件坐标单位明确。"],
["3", "声明 speed 和 zone。", "速度和过渡统一命名,例如 v_fast、v_pick、z10。"],
["4", "声明 target。", "home 用 joint_target工艺点用 pose_target。"],
["5", "创建 path。", "设置 defaults点名按 p000/p001 或语义名称稳定输出。"],
["6", "添加 path event 或 operation action。", "夹具、等待、脉冲、工艺开关不要散落在 main 中。"],
["7", "创建 operation。", "把 path 和工艺参数封装成可复用操作。"],
["8", "编写 proc main。", "只做 set_tool/set_frame、run_operation、回 home 等主流程。"],
["9", "运行编译和测试。", "执行 npm test 或 suite检查 diagnostics、trace、motion queue。"],
["10", "后处理输出。", "检查 ABB/FANUC/KUKA 转换报告,不支持项不能忽略。"]
]));
body.push(heading("十一、完整程序模板", 1));
body.push(p("下面给出一个可直接改写的模板,适合搬运、上下料、夹具动作和基础路径。"));
body.push(codeBlock(`language grl 0.1
module DemoPickPlace
persistent tool gripper = tool {
tcp: pose(0 mm, 0 mm, 120 mm, 0 deg, 0 deg, 0 deg),
mass: 1.5 kg
}
persistent frame fixture = frame {
origin: pose(800 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg)
}
const speed v_joint = joint(50 %)
const speed v_pick = linear(250 mm/s)
const zone z10 = z(10 mm)
const zone zf = fine
target home = joint_target { joints: [0 deg, 0 deg, 0 deg, 0 deg, 0 deg, 0 deg] }
target pick = pose_target { pose: pose(500 mm, 100 mm, 200 mm, 180 deg, 0 deg, 90 deg), tool: gripper, frame: fixture }
target place = pose_target { pose: pose(650 mm, -100 mm, 200 mm, 180 deg, 0 deg, 90 deg), tool: gripper, frame: fixture }
path pick_path {
defaults { speed: v_pick, zone: z10, tool: gripper, frame: fixture }
point approach movel pick offset z 100 mm
point at_pick movel pick zone zf
event after at_pick io.do[1] = true
event after at_pick wait io.di[1] == true timeout 2 s on_timeout alarm "Clamp close timeout"
point leave movel pick offset z 100 mm
}
operation pick_op {
kind: handling
path: pick_path
process { gripper: "main_clamp" }
}
proc main()
set_tool gripper
set_frame fixture
movej home speed v_joint zone zf
run_operation pick_op
movej home speed v_joint zone zf
end
end`));
body.push(heading("十二、编译、仿真和截图验证", 1));
body.push(imageWithCaption(images[3], 4, "图 4 虚拟控制器执行流程图"));
body.push(p("编译后,虚拟控制器会加载 IR从 main 开始执行,采集运行 trace、motion queue、IO image、报警队列和诊断。桌面端和移动端界面可用于检查程序结构、状态、编辑器、面板和运行结果。"));
body.push(imageWithCaption(images[5], 6, "图 5 虚拟控制器桌面端界面"));
body.push(imageWithCaption(images[6], 7, "图 6 虚拟控制器移动端界面"));
body.push(table([
["命令", "用途"],
["npm run typecheck", "检查 TypeScript 类型。"],
["npm test", "运行 KDL、GRL、runtime、post/import、integration 全量测试。"],
["npm run suite:abb120-spec", "运行 19 个 ABB120 GRL 规范程序,生成 job/report/program 证据。"],
["npm run verify:virtual-controller", "打开虚拟控制器 HTML执行交互并生成桌面/移动截图。"]
]));
body.push(heading("十三、后处理和跨品牌输出", 1));
body.push(imageWithCaption(images[4], 5, "图 7 后处理与导入回读流程图"));
body.push(table([
["GRL", "ABB RAPID", "FANUC LS/TP", "KUKA KRL"],
["module", "MODULE", "/PROG 或模块", "DEF/.src"],
["proc", "PROC", "CALL 子程序", "DEF 子程序"],
["movej", "MoveJ", "J P[...] ", "PTP"],
["movel", "MoveL", "L P[...] ", "LIN"],
["movec", "MoveC", "C P[...] ", "CIRC"],
["io.do[1] = true", "SetDO do1, 1", "DO[1]=ON", "$OUT[1]=TRUE"],
["wait io.di[1] == true", "WaitDI di1, 1", "WAIT DI[1]=ON", "WAIT FOR $IN[1]==TRUE"]
]));
body.push(p("后处理器遇到品牌不支持或只能近似表达的语义时,应写入转换报告。用户必须检查报告,再到真实控制器中做品牌校验和现场低速试运行。"));
body.push(heading("十四、常见诊断和处理建议", 1));
body.push(table([
["诊断", "常见原因", "处理方式"],
["GRL_EXPR_UNKNOWN_SYMBOL", "表达式引用未声明常量或变量。", "检查拼写和声明顺序。"],
["GRL_EXPR_UNIT_MISMATCH", "不同物理单位混合计算。", "统一长度、时间、角度或速度维度。"],
["GRL_TARGET_NOT_FOUND", "运动或路径点引用不存在目标点。", "补充 target 或修正名称。"],
["KDL_TARGET_UNREACHABLE", "笛卡尔点超出机器人工作空间。", "调整目标点、工具、坐标系或机器人配置。"],
["KDL_JOINT_LIMIT", "目标关节超过限位。", "修改 joint_target 或使用 IK 可达姿态。"],
["GRL_PATH_EMPTY", "path 没有 point。", "至少添加一个 point。"],
["VC_WAIT_TIMEOUT", "wait 条件在 timeout 内未满足。", "检查 IO 输入、脚本、超时时间和 on_timeout 动作。"],
["GRL_POST_UNSUPPORTED", "目标品牌后处理暂不支持该语义。", "查看转换报告,必要时改写为品牌可支持结构。"]
]));
body.push(heading("十五、编写规范建议", 1));
body.push(table([
["建议", "说明"],
["名称稳定", "target、path point、operation 命名稳定,便于 diff 和回读。"],
["显式单位", "自动生成程序尽量输出 mm、deg、mm/s、ms 等单位。"],
["入口简短", "main 中只保留主流程调用,复杂路径放入 path/operation。"],
["默认集中", "path defaults 中集中设置 speed、zone、tool、frame。"],
["IO 事件贴近路径", "与路径点相关的夹具和工艺开关写成 event。"],
["诊断必看", "error 必须修复warning 必须人工确认info 可作为转换记录。"],
["先仿真后后处理", "先通过 GRL 编译和虚拟控制器,再输出品牌程序。"],
["真实运行前校验", "后处理程序必须经过品牌控制器验证和现场低速试运行。"]
]));
body.push(heading("十六、参考完整示例", 1));
body.push(p("以下是规范套件中的完整示例程序,覆盖 tool/frame、path、operation、IO event、run_operation 和回 home可作为复杂程序的参考。"));
body.push(codeBlock(readFileSync(fullExamplePath, "utf8")));
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:w10="urn:schemas-microsoft-com:office:word"
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"
xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"
xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex"
xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"
xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"
xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"
xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
mc:Ignorable="w14 w15 w16se wp14">
<w:body>
${body.join("\n")}
<w:sectPr>
<w:pgSz w:w="11906" w:h="16838"/>
<w:pgMar w:top="1134" w:right="850" w:bottom="1134" w:left="850" w:header="708" w:footer="708" w:gutter="0"/>
<w:cols w:space="708"/>
<w:docGrid w:linePitch="312"/>
</w:sectPr>
</w:body>
</w:document>`;
}
function copyImages(imageInputs) {
return imageInputs.map((image, index) => {
const fileName = `image-${String(index + 1).padStart(2, "0")}.png`;
copyFileSync(image.source, join(stagingRoot, "word", "media", fileName));
return {
...image,
id: `rIdImage${index + 1}`,
fileName
};
});
}
function heading(text, level) {
return paragraph(text, `Heading${Math.min(level, 3)}`);
}
function p(text) {
return paragraph(text, "BodyText");
}
function paragraph(text, style) {
return `<w:p><w:pPr><w:pStyle w:val="${style}"/></w:pPr>${run(text)}</w:p>`;
}
function run(text) {
return `<w:r><w:t xml:space="preserve">${escapeXml(String(text))}</w:t></w:r>`;
}
function table(rows) {
const width = Math.floor(9200 / Math.max(1, rows[0]?.length ?? 1));
return `<w:tbl>
<w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/><w:tblLook w:val="04A0"/></w:tblPr>
${rows.map((row, rowIndex) => `<w:tr>${row.map((cell) => tableCell(cell, width, rowIndex === 0)).join("")}</w:tr>`).join("\n")}
</w:tbl>`;
}
function tableCell(text, width, header) {
return `<w:tc>
<w:tcPr><w:tcW w:w="${width}" w:type="dxa"/></w:tcPr>
<w:p><w:pPr>${header ? "<w:pStyle w:val=\"TableHeader\"/>" : ""}</w:pPr>${run(text)}</w:p>
</w:tc>`;
}
function codeBlock(text) {
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
return lines.map((line) =>
`<w:p><w:pPr><w:pStyle w:val="Code"/></w:pPr><w:r><w:t xml:space="preserve">${escapeXml(line === "" ? " " : line)}</w:t></w:r></w:p>`
).join("\n");
}
function imageWithCaption(image, docPrId, caption) {
return `${imageParagraph(image, docPrId)}${paragraph(caption, "Caption")}`;
}
function imageParagraph(image, docPrId) {
return `<w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:drawing>
<wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="${image.cx}" cy="${image.cy}"/>
<wp:effectExtent l="0" t="0" r="0" b="0"/>
<wp:docPr id="${docPrId}" name="${escapeXml(image.title)}"/>
<wp:cNvGraphicFramePr><a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/></wp:cNvGraphicFramePr>
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:nvPicPr><pic:cNvPr id="${docPrId}" name="${escapeXml(image.fileName)}"/><pic:cNvPicPr/></pic:nvPicPr>
<pic:blipFill><a:blip r:embed="${image.id}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>
<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${image.cx}" cy="${image.cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing></w:r></w:p>`;
}
function contentTypesXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>`;
}
function packageRelsXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>`;
}
function documentRelsXml(images) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
${images.map((image) => `<Relationship Id="${image.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${image.fileName}"/>`).join("\n")}
</Relationships>`;
}
function corePropsXml() {
const iso = new Date().toISOString();
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dcterms="http://purl.org/dc/terms/"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>GRL 功能语法逻辑与程序创建使用手册</dc:title>
<dc:creator>Codex</dc:creator>
<cp:lastModifiedBy>Codex</cp:lastModifiedBy>
<dcterms:created xsi:type="dcterms:W3CDTF">${iso}</dcterms:created>
<dcterms:modified xsi:type="dcterms:W3CDTF">${iso}</dcterms:modified>
</cp:coreProperties>`;
}
function appPropsXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<Application>Codex</Application>
<DocSecurity>0</DocSecurity>
<ScaleCrop>false</ScaleCrop>
<Company>KDL Work</Company>
<LinksUpToDate>false</LinksUpToDate>
<SharedDoc>false</SharedDoc>
<HyperlinksChanged>false</HyperlinksChanged>
<AppVersion>16.0000</AppVersion>
</Properties>`;
}
function stylesXml() {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:qFormat/>
<w:rPr><w:rFonts w:ascii="Microsoft YaHei" w:hAnsi="Microsoft YaHei" w:eastAsia="Microsoft YaHei"/><w:sz w:val="21"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="BodyText">
<w:name w:val="Body Text"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:after="120" w:line="276" w:lineRule="auto"/></w:pPr>
<w:rPr><w:rFonts w:ascii="Microsoft YaHei" w:hAnsi="Microsoft YaHei" w:eastAsia="Microsoft YaHei"/><w:sz w:val="21"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Caption">
<w:name w:val="Caption"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:jc w:val="center"/><w:spacing w:after="160"/></w:pPr>
<w:rPr><w:i/><w:rFonts w:ascii="Microsoft YaHei" w:hAnsi="Microsoft YaHei" w:eastAsia="Microsoft YaHei"/><w:sz w:val="18"/><w:color w:val="475569"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="BodyText"/>
<w:qFormat/>
<w:pPr><w:keepNext/><w:spacing w:before="360" w:after="180"/></w:pPr>
<w:rPr><w:b/><w:rFonts w:ascii="Microsoft YaHei" w:hAnsi="Microsoft YaHei" w:eastAsia="Microsoft YaHei"/><w:sz w:val="32"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="BodyText"/>
<w:qFormat/>
<w:pPr><w:keepNext/><w:spacing w:before="280" w:after="140"/></w:pPr>
<w:rPr><w:b/><w:rFonts w:ascii="Microsoft YaHei" w:hAnsi="Microsoft YaHei" w:eastAsia="Microsoft YaHei"/><w:sz w:val="27"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading3">
<w:name w:val="heading 3"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="BodyText"/>
<w:qFormat/>
<w:pPr><w:keepNext/><w:spacing w:before="180" w:after="100"/></w:pPr>
<w:rPr><w:b/><w:rFonts w:ascii="Microsoft YaHei" w:hAnsi="Microsoft YaHei" w:eastAsia="Microsoft YaHei"/><w:sz w:val="23"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Code">
<w:name w:val="Code"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/></w:pPr>
<w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:eastAsia="Consolas"/><w:sz w:val="16"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="TableHeader">
<w:name w:val="Table Header"/>
<w:basedOn w:val="Normal"/>
<w:rPr><w:b/><w:rFonts w:ascii="Microsoft YaHei" w:hAnsi="Microsoft YaHei" w:eastAsia="Microsoft YaHei"/><w:sz w:val="20"/></w:rPr>
</w:style>
<w:style w:type="table" w:default="1" w:styleId="TableNormal">
<w:name w:val="Normal Table"/>
<w:tblPr><w:tblInd w:w="0" w:type="dxa"/><w:tblCellMar><w:top w:w="0" w:type="dxa"/><w:left w:w="108" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="108" w:type="dxa"/></w:tblCellMar></w:tblPr>
</w:style>
<w:style w:type="table" w:styleId="TableGrid">
<w:name w:val="Table Grid"/>
<w:basedOn w:val="TableNormal"/>
<w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="B7C1CC"/><w:left w:val="single" w:sz="4" w:space="0" w:color="B7C1CC"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="B7C1CC"/><w:right w:val="single" w:sz="4" w:space="0" w:color="B7C1CC"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="B7C1CC"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="B7C1CC"/></w:tblBorders></w:tblPr>
</w:style>
</w:styles>`;
}
function escapeXml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&apos;");
}
function psSingleQuoted(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function zipDirectory(sourceDir, destinationFile) {
const script = [
"Add-Type -AssemblyName System.IO.Compression.FileSystem",
`$src = ${psSingleQuoted(sourceDir)}`,
`$dst = ${psSingleQuoted(destinationFile)}`,
"if (Test-Path -LiteralPath $dst) { Remove-Item -LiteralPath $dst -Force }",
"[System.IO.Compression.ZipFile]::CreateFromDirectory($src, $dst)"
].join("\n");
execFileSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
stdio: "inherit"
});
}
function assertSafeStaging(path) {
const allowedRoot = join(webRoot, "test-results") + sep;
const resolved = resolve(path);
if (!resolved.startsWith(allowedRoot)) {
throw new Error(`Refusing to clear staging path outside test-results: ${resolved}`);
}
}
function formatShanghaiTime(date) {
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).formatToParts(date);
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${value.year}-${value.month}-${value.day} ${value.hour}:${value.minute}:${value.second} Asia/Shanghai`;
}