dsh-plugin-deck-studio 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -2
  3. package/client.js +500 -0
  4. package/cordis.example.yml +163 -0
  5. package/cordis.patch.yml +147 -0
  6. package/index.js +2340 -0
  7. package/lib/config.js +204 -0
  8. package/lib/content-density.js +98 -0
  9. package/lib/deck-brief.js +87 -0
  10. package/lib/deck-docs.js +1095 -0
  11. package/lib/deck-rules.js +124 -0
  12. package/lib/deck-templates.js +149 -0
  13. package/lib/deck-workspace.js +480 -0
  14. package/lib/design-style.js +157 -0
  15. package/lib/designs.js +175 -0
  16. package/lib/diff.js +88 -0
  17. package/lib/exporter.js +336 -0
  18. package/lib/faq.js +300 -0
  19. package/lib/git-commit.js +87 -0
  20. package/lib/home-catalog.js +249 -0
  21. package/lib/png.js +275 -0
  22. package/lib/proposal-decision.js +204 -0
  23. package/lib/render-nodes.js +171 -0
  24. package/lib/render-settings.js +213 -0
  25. package/lib/render.js +287 -0
  26. package/lib/review-personas.js +258 -0
  27. package/lib/script-style.js +55 -0
  28. package/lib/script-writer.js +165 -0
  29. package/lib/source-adapter.js +327 -0
  30. package/lib/store.js +212 -0
  31. package/lib/tools.js +2594 -0
  32. package/lib/watermark.js +137 -0
  33. package/package.json +66 -4
  34. package/seeds/resources/deckline-logo.png +0 -0
  35. package/seeds/resources/deckline-logo.svg +12 -0
  36. package/seeds/resources/deckline-mark.png +0 -0
  37. package/seeds/resources/index.json +19 -0
  38. package/seeds/samples/BIZ-01.jpg +0 -0
  39. package/seeds/samples/BIZ-02.jpg +0 -0
  40. package/seeds/samples/BIZ-03.jpg +0 -0
  41. package/seeds/samples/EDT-01.jpg +0 -0
  42. package/seeds/samples/EDT-02.jpg +0 -0
  43. package/seeds/samples/EDT-03.jpg +0 -0
  44. package/seeds/samples/FYI-01.jpg +0 -0
  45. package/seeds/samples/FYI-02.jpg +0 -0
  46. package/seeds/samples/FYI-03.jpg +0 -0
  47. package/seeds/samples/GRD-01.jpg +0 -0
  48. package/seeds/samples/GRD-02.jpg +0 -0
  49. package/seeds/samples/GRD-03.jpg +0 -0
  50. package/seeds/samples/JPN-01.jpg +0 -0
  51. package/seeds/samples/JPN-02.jpg +0 -0
  52. package/seeds/samples/JPN-03.jpg +0 -0
  53. package/seeds/samples/SKT-01.jpg +0 -0
  54. package/seeds/samples/SKT-02.jpg +0 -0
  55. package/seeds/samples/SKT-03.jpg +0 -0
  56. package/seeds/samples/SWS-01.jpg +0 -0
  57. package/seeds/samples/SWS-02.jpg +0 -0
  58. package/seeds/samples/SWS-03.jpg +0 -0
  59. package/seeds/samples/TEC-01.jpg +0 -0
  60. package/seeds/samples/TEC-02.jpg +0 -0
  61. package/seeds/samples/TEC-03.jpg +0 -0
  62. package/seeds/styles/BIZ.json +22 -0
  63. package/seeds/styles/EDT.json +22 -0
  64. package/seeds/styles/FYI.json +22 -0
  65. package/seeds/styles/GRD.json +22 -0
  66. package/seeds/styles/JPN.json +22 -0
  67. package/seeds/styles/SKT.json +22 -0
  68. package/seeds/styles/SWS.json +22 -0
  69. package/seeds/styles/TEC.json +22 -0
  70. package/ui/logo.svg +20 -0
  71. package/ui/workbench.css +687 -0
  72. package/ui/workbench.html +4554 -0
package/lib/designs.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * design(风格)注册表 + 会话绑定表。
3
+ *
4
+ * 背景(F-43):Designs 页可以「添加风格」——弹窗描述画面 → 建一个 0 页空白 deck
5
+ * 当画布 → 左侧 chat 新建会话开始工作。这个会话绑定的是 design,不是那个空白 deck。
6
+ * 规则:每个会话必须绑定一个 deck 或一个 design。
7
+ *
8
+ * 存储(都在 .deck/ 下,不进 git):
9
+ * - `designs-registry.json`:`{ designs: [{ designId, name, abbr, description, constraints,
10
+ * reference, canvasDeckId, createdAt }] }`
11
+ * - `session-bindings.json`:`{ bindings: { <sessionId>: { kind, id, title, createdAt, updatedAt } } }`
12
+ *
13
+ * DSH 会话本身没有「绑定」字段,所以绑定表在插件侧维护;客户端建/切会话后调
14
+ * `/api/deck-studio.session-bind` 落表。
15
+ * @module dsh-plugin-deck-studio/designs
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ const KIND_LABEL = { deck: "Deck", design: "Design" };
22
+
23
+ function readJson(file, fallback) {
24
+ try {
25
+ if (!existsSync(file)) return fallback;
26
+ return JSON.parse(readFileSync(file, "utf8"));
27
+ } catch {
28
+ return fallback;
29
+ }
30
+ }
31
+
32
+ function writeJson(file, value) {
33
+ mkdirSync(dirname(file), { recursive: true });
34
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
35
+ }
36
+
37
+ /** 风格缩写:2–6 位字母/数字,目录名直接用(`designs/<ABBR>/`)。 */
38
+ export function normalizeAbbr(value) {
39
+ const abbr = String(value ?? "").trim();
40
+ if (!/^[A-Za-z][A-Za-z0-9]{1,5}$/.test(abbr)) throw new Error("风格缩写要 2–6 位字母/数字,且以字母开头(如 PC)");
41
+ return abbr.toUpperCase();
42
+ }
43
+
44
+ /**
45
+ * @param {{dataRoot: string}} input
46
+ */
47
+ export function createDesignRegistry({ dataRoot }) {
48
+ const designFile = join(dataRoot, "designs-registry.json");
49
+ const bindingFile = join(dataRoot, "session-bindings.json");
50
+ // deck → design 关联(F-51):正式 deck 登记它用哪个风格,源文件不动。
51
+ const deckDesignFile = join(dataRoot, "deck-designs.json");
52
+
53
+ function listDesigns() {
54
+ const raw = readJson(designFile, { designs: [] });
55
+ return Array.isArray(raw?.designs) ? raw.designs.filter((item) => item?.designId && item?.canvasDeckId) : [];
56
+ }
57
+
58
+ function upsertDesign(entry) {
59
+ const designs = listDesigns().filter((item) => item.designId !== entry.designId);
60
+ designs.push(entry);
61
+ writeJson(designFile, { designs });
62
+ return entry;
63
+ }
64
+
65
+ function findDesignByAbbr(abbr) {
66
+ const key = String(abbr ?? "").toUpperCase();
67
+ return listDesigns().find((item) => String(item.abbr).toUpperCase() === key) ?? null;
68
+ }
69
+
70
+ function findDesign(designId) {
71
+ return listDesigns().find((item) => item.designId === designId) ?? null;
72
+ }
73
+
74
+ function removeDesign(designId) {
75
+ const designs = listDesigns();
76
+ const target = designs.find((item) => item.designId === designId) ?? null;
77
+ if (!target) return null;
78
+ writeJson(designFile, { designs: designs.filter((item) => item.designId !== designId) });
79
+ return target;
80
+ }
81
+
82
+ /** deck → design 关联表:`{ associations: { <deckId>: { designId, updatedAt } } }`(F-51)。 */
83
+ function listDeckDesigns() {
84
+ const raw = readJson(deckDesignFile, { associations: {} });
85
+ return raw?.associations && typeof raw.associations === "object" ? raw.associations : {};
86
+ }
87
+
88
+ function deckDesignFor(deckId) {
89
+ const association = listDeckDesigns()[deckId];
90
+ return association?.designId ? association : null;
91
+ }
92
+
93
+ function setDeckDesign(deckId, designId) {
94
+ const associations = listDeckDesigns();
95
+ if (designId) associations[deckId] = { designId, updatedAt: new Date().toISOString() };
96
+ else delete associations[deckId];
97
+ writeJson(deckDesignFile, { associations });
98
+ return designId ? associations[deckId] : null;
99
+ }
100
+
101
+ function listBindings() {
102
+ const raw = readJson(bindingFile, { bindings: {} });
103
+ const bindings = raw?.bindings && typeof raw.bindings === "object" ? raw.bindings : {};
104
+ return Object.entries(bindings)
105
+ .filter(([sessionId, value]) => sessionId && value?.kind && value?.id)
106
+ .map(([sessionId, value]) => ({ sessionId, ...value }));
107
+ }
108
+
109
+ /** 会话绑定;同会话重复绑定直接覆盖(用户改主意时以最新为准)。 */
110
+ function upsertBinding({ sessionId, kind, id, title = "" }) {
111
+ if (!sessionId) throw new Error("sessionId 不能为空");
112
+ if (kind !== "deck" && kind !== "design") throw new Error(`未知绑定类型:${kind}`);
113
+ const raw = readJson(bindingFile, { bindings: {} });
114
+ const bindings = raw?.bindings && typeof raw.bindings === "object" ? raw.bindings : {};
115
+ const existing = bindings[sessionId];
116
+ const now = new Date().toISOString();
117
+ bindings[sessionId] = {
118
+ kind,
119
+ id,
120
+ title: String(title || "").slice(0, 80),
121
+ createdAt: existing?.createdAt ?? now,
122
+ updatedAt: now,
123
+ };
124
+ writeJson(bindingFile, { bindings });
125
+ return { sessionId, ...bindings[sessionId], kindLabel: KIND_LABEL[kind] };
126
+ }
127
+
128
+ function removeBinding(sessionId) {
129
+ const raw = readJson(bindingFile, { bindings: {} });
130
+ const bindings = raw?.bindings && typeof raw.bindings === "object" ? raw.bindings : {};
131
+ if (!bindings[sessionId]) return false;
132
+ delete bindings[sessionId];
133
+ writeJson(bindingFile, { bindings });
134
+ return true;
135
+ }
136
+
137
+ /** 找某个 deck / design 已绑定的会话(用于「点 deck 复用上次会话」)。 */
138
+ function bindingFor(kind, id) {
139
+ return listBindings().find((binding) => binding.kind === kind && binding.id === id) ?? null;
140
+ }
141
+
142
+ /** 删掉某个 deck / design 的全部会话绑定(删除实体时用)。 */
143
+ function removeBindingsFor(kind, id) {
144
+ const raw = readJson(bindingFile, { bindings: {} });
145
+ const bindings = raw?.bindings && typeof raw.bindings === "object" ? raw.bindings : {};
146
+ let removed = 0;
147
+ for (const [sessionId, value] of Object.entries(bindings)) {
148
+ if (value?.kind === kind && value?.id === id) {
149
+ delete bindings[sessionId];
150
+ removed += 1;
151
+ }
152
+ }
153
+ if (removed) writeJson(bindingFile, { bindings });
154
+ return removed;
155
+ }
156
+
157
+ return {
158
+ designFile,
159
+ bindingFile,
160
+ deckDesignFile,
161
+ listDesigns,
162
+ upsertDesign,
163
+ findDesign,
164
+ findDesignByAbbr,
165
+ removeDesign,
166
+ listDeckDesigns,
167
+ deckDesignFor,
168
+ setDeckDesign,
169
+ listBindings,
170
+ upsertBinding,
171
+ removeBinding,
172
+ removeBindingsFor,
173
+ bindingFor,
174
+ };
175
+ }
package/lib/diff.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 行级 unified diff(LCS)。只服务 script.md / deck.json 的提案展示,不追求 GNU diff 逐字节一致。
3
+ * @module dsh-plugin-deck-studio/diff
4
+ */
5
+
6
+ /** LCS-backtrack 行差异:返回 [{type:"same"|"del"|"add", line}]。 */
7
+ function rowDiff(oldLines, newLines) {
8
+ const n = oldLines.length;
9
+ const m = newLines.length;
10
+ // 防御超长输入:脚本文件 ~200 行,deck.json ~60 行,直接 DP 足够。
11
+ const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
12
+ for (let i = n - 1; i >= 0; i -= 1) {
13
+ for (let j = m - 1; j >= 0; j -= 1) {
14
+ dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
15
+ }
16
+ }
17
+ const rows = [];
18
+ let i = 0;
19
+ let j = 0;
20
+ while (i < n && j < m) {
21
+ if (oldLines[i] === newLines[j]) {
22
+ rows.push({ type: "same", line: oldLines[i] });
23
+ i += 1;
24
+ j += 1;
25
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
26
+ rows.push({ type: "del", line: oldLines[i] });
27
+ i += 1;
28
+ } else {
29
+ rows.push({ type: "add", line: newLines[j] });
30
+ j += 1;
31
+ }
32
+ }
33
+ while (i < n) {
34
+ rows.push({ type: "del", line: oldLines[i] });
35
+ i += 1;
36
+ }
37
+ while (j < m) {
38
+ rows.push({ type: "add", line: newLines[j] });
39
+ j += 1;
40
+ }
41
+ return rows;
42
+ }
43
+
44
+ /**
45
+ * unified diff 文本(带 `@@` 块头与上下文行)。
46
+ * @param {string} oldText
47
+ * @param {string} newText
48
+ * @param {{context?: number, from?: string, to?: string}} [options]
49
+ * @returns {string} 无差异时返回空串。
50
+ */
51
+ export function lineDiff(oldText, newText, options = {}) {
52
+ const context = Number.isFinite(options.context) && options.context >= 0 ? Math.floor(options.context) : 2;
53
+ const from = options.from ?? "a/script.md";
54
+ const to = options.to ?? "b/script.md";
55
+ const rows = rowDiff(oldText.split("\n"), newText.split("\n"));
56
+ if (!rows.some((row) => row.type !== "same")) return "";
57
+
58
+ // 聚成 hunk:相邻变更 + context 行。
59
+ const hunks = [];
60
+ let current = null;
61
+ rows.forEach((row, index) => {
62
+ const interesting = row.type !== "same";
63
+ if (interesting) {
64
+ if (!current) current = { start: Math.max(0, index - context), rows: [] };
65
+ current.rows.push(row);
66
+ current.end = index;
67
+ } else if (current && index - current.end > context) {
68
+ hunks.push(current);
69
+ current = null;
70
+ } else if (current) {
71
+ current.rows.push(row);
72
+ }
73
+ });
74
+ if (current) hunks.push(current);
75
+
76
+ const lines = [`--- ${from}`, `+++ ${to}`];
77
+ for (const hunk of hunks) {
78
+ const oldCount = hunk.rows.filter((row) => row.type !== "add").length;
79
+ const newCount = hunk.rows.filter((row) => row.type !== "del").length;
80
+ const oldStart = rows.slice(0, hunk.start).filter((row) => row.type !== "add").length + 1;
81
+ const newStart = rows.slice(0, hunk.start).filter((row) => row.type !== "del").length + 1;
82
+ lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
83
+ for (const row of hunk.rows) {
84
+ lines.push(`${row.type === "same" ? " " : row.type === "del" ? "-" : "+"}${row.line}`);
85
+ }
86
+ }
87
+ return `${lines.join("\n")}\n`;
88
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Deck 导出:PPTX(每页一张整页图)与 PDF(每页一张整页图)。
3
+ *
4
+ * 零第三方依赖:
5
+ * - PPTX = OOXML zip(deflate + PNG/JPEG 直接嵌入),16:9 13.333×7.5in;
6
+ * - PDF = 每页一张 JPEG(DCTDecode),页面尺寸按图片比例;
7
+ * - PNG → JPEG 用 macOS `sips`(本地工具;找不到时 PDF 导出会报清晰错误)。
8
+ *
9
+ * 导出是「整页图」的拼装,不做文字重排——与 image deck 的 renderer 契约一致。
10
+ * @module dsh-plugin-deck-studio/exporter
11
+ */
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { extname, join } from "node:path";
17
+ import { deflateRawSync, deflateSync } from "node:zlib";
18
+
19
+ /* ------------------------------------------------------------------ *
20
+ * zip(PPTX 容器)
21
+ * ------------------------------------------------------------------ */
22
+
23
+ const CRC_TABLE = (() => {
24
+ const table = new Uint32Array(256);
25
+ for (let i = 0; i < 256; i += 1) {
26
+ let value = i;
27
+ for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
28
+ table[i] = value >>> 0;
29
+ }
30
+ return table;
31
+ })();
32
+
33
+ function crc32(buffer) {
34
+ let crc = 0xffffffff;
35
+ for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
36
+ return (crc ^ 0xffffffff) >>> 0;
37
+ }
38
+
39
+ /** 内存 zip:entries [{name, data:Buffer|string, store?:boolean}] */
40
+ export function zip(entries) {
41
+ const locals = [];
42
+ const centrals = [];
43
+ let offset = 0;
44
+ for (const entry of entries) {
45
+ const name = Buffer.from(entry.name, "utf8");
46
+ const raw = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data), "utf8");
47
+ const deflated = entry.store ? null : deflateRawSync(raw);
48
+ const useDeflate = deflated && deflated.length < raw.length;
49
+ const data = useDeflate ? deflated : raw;
50
+ const method = useDeflate ? 8 : 0;
51
+ const crc = crc32(raw);
52
+ const local = Buffer.alloc(30);
53
+ local.writeUInt32LE(0x04034b50, 0);
54
+ local.writeUInt16LE(20, 4);
55
+ local.writeUInt16LE(0, 6);
56
+ local.writeUInt16LE(method, 8);
57
+ local.writeUInt16LE(0, 10);
58
+ local.writeUInt16LE(0, 12);
59
+ local.writeUInt32LE(crc, 14);
60
+ local.writeUInt32LE(data.length, 18);
61
+ local.writeUInt32LE(raw.length, 22);
62
+ local.writeUInt16LE(name.length, 26);
63
+ local.writeUInt16LE(0, 28);
64
+ locals.push(local, name, data);
65
+ const central = Buffer.alloc(46);
66
+ central.writeUInt32LE(0x02014b50, 0);
67
+ central.writeUInt16LE(20, 4);
68
+ central.writeUInt16LE(20, 6);
69
+ central.writeUInt16LE(0, 8);
70
+ central.writeUInt16LE(method, 10);
71
+ central.writeUInt16LE(0, 12);
72
+ central.writeUInt16LE(0, 14);
73
+ central.writeUInt32LE(crc, 16);
74
+ central.writeUInt32LE(data.length, 20);
75
+ central.writeUInt32LE(raw.length, 24);
76
+ central.writeUInt16LE(name.length, 28);
77
+ central.writeUInt16LE(0, 30);
78
+ central.writeUInt16LE(0, 32);
79
+ central.writeUInt16LE(0, 34);
80
+ central.writeUInt16LE(0, 36);
81
+ central.writeUInt32LE(0, 38);
82
+ central.writeUInt32LE(offset, 42);
83
+ centrals.push(central, name);
84
+ offset += local.length + name.length + data.length;
85
+ }
86
+ const centralSize = centrals.reduce((sum, buffer) => sum + buffer.length, 0);
87
+ const end = Buffer.alloc(22);
88
+ end.writeUInt32LE(0x06054b50, 0);
89
+ end.writeUInt16LE(0, 4);
90
+ end.writeUInt16LE(0, 6);
91
+ end.writeUInt16LE(entries.length, 8);
92
+ end.writeUInt16LE(entries.length, 10);
93
+ end.writeUInt32LE(centralSize, 12);
94
+ end.writeUInt32LE(offset, 16);
95
+ end.writeUInt16LE(0, 20);
96
+ return Buffer.concat([...locals, ...centrals, end]);
97
+ }
98
+
99
+ /* ------------------------------------------------------------------ *
100
+ * PPTX
101
+ * ------------------------------------------------------------------ */
102
+
103
+ const EMU_W = 12192000;
104
+ const EMU_H = 6858000;
105
+
106
+ const XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
107
+ const NS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"';
108
+
109
+ const REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships";
110
+ const REL_TYPE = {
111
+ officeDocument: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
112
+ slide: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
113
+ slideLayout: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
114
+ slideMaster: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
115
+ theme: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
116
+ image: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
117
+ };
118
+
119
+ const relsXml = (rels) =>
120
+ `${XML_HEAD}<Relationships xmlns="${REL_NS}">${rels
121
+ .map((rel) => `<Relationship Id="${rel.id}" Type="${rel.type}" Target="${rel.target}"/>`)
122
+ .join("")}</Relationships>`;
123
+
124
+ const emptySpTree = `<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree>`;
125
+
126
+ const themeXml = `${XML_HEAD}<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Deck Studio"><a:themeElements>
127
+ <a:clrScheme name="Deck Studio"><a:dk1><a:srgbClr val="0C4651"/></a:dk1><a:lt1><a:srgbClr val="FAF8F2"/></a:lt1><a:dk2><a:srgbClr val="0F6975"/></a:dk2><a:lt2><a:srgbClr val="F1EDE2"/></a:lt2><a:accent1><a:srgbClr val="1496A8"/></a:accent1><a:accent2><a:srgbClr val="C5882F"/></a:accent2><a:accent3><a:srgbClr val="7FA3A8"/></a:accent3><a:accent4><a:srgbClr val="B4462F"/></a:accent4><a:accent5><a:srgbClr val="5E7F86"/></a:accent5><a:accent6><a:srgbClr val="D9D2C2"/></a:accent6><a:hlink><a:srgbClr val="1496A8"/></a:hlink><a:folHlink><a:srgbClr val="C5882F"/></a:folHlink></a:clrScheme>
128
+ <a:fontScheme name="Deck Studio"><a:majorFont><a:latin typeface="Helvetica"/><a:ea typeface="PingFang SC"/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Helvetica"/><a:ea typeface="PingFang SC"/><a:cs typeface=""/></a:minorFont></a:fontScheme>
129
+ <a:fmtScheme name="Deck Studio"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="6350"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="12700"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme>
130
+ </a:themeElements></a:theme>`;
131
+
132
+ /**
133
+ * 生成 PPTX。
134
+ * @param {{file: string}[]} slides 按页序
135
+ * @returns {Buffer}
136
+ */
137
+ /**
138
+ * 单页 PPTX slide XML:整页图以一个铺满画布的 <p:pic> 放进 spTree。
139
+ * 导出 blank 回归根因(F-98):旧实现用 emptySpTree.replace("</p:grpSpPr>", …),
140
+ * 而 emptySpTree 里是自闭合的 <p:grpSpPr/>,替换永远不命中 → 每页 spTree 为空,
141
+ * PPT 打开全是空白(图片却在包里)。现在直接拼完整 spTree,并有 smoke 断言。
142
+ */
143
+ export function pptxSlideXml(number) {
144
+ return `${XML_HEAD}<p:sld ${NS}><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/><p:pic><p:nvPicPr><p:cNvPr id="2" name="page ${number}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${EMU_W}" cy="${EMU_H}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>`;
145
+ }
146
+
147
+ export function buildPptx(slides) {
148
+ if (!slides?.length) throw new Error("没有可导出的页(至少需要 1 页图片)");
149
+ const entries = [];
150
+ const slideCount = slides.length;
151
+ const contentTypes = [
152
+ `${XML_HEAD}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`,
153
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
154
+ '<Default Extension="xml" ContentType="application/xml"/>',
155
+ '<Default Extension="png" ContentType="image/png"/>',
156
+ '<Default Extension="jpg" ContentType="image/jpeg"/>',
157
+ '<Default Extension="jpeg" ContentType="image/jpeg"/>',
158
+ '<Default Extension="webp" ContentType="image/png"/>',
159
+ '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>',
160
+ '<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>',
161
+ '<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>',
162
+ '<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>',
163
+ ...slides.map((_, index) => `<Override PartName="/ppt/slides/slide${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`),
164
+ "</Types>",
165
+ ].join("");
166
+
167
+ entries.push({ name: "[Content_Types].xml", data: contentTypes });
168
+ entries.push({
169
+ name: "_rels/.rels",
170
+ data: relsXml([{ id: "rId1", type: REL_TYPE.officeDocument, target: "ppt/presentation.xml" }]),
171
+ });
172
+
173
+ const presentationRels = [
174
+ { id: "rId1", type: REL_TYPE.slideMaster, target: "slideMasters/slideMaster1.xml" },
175
+ ...slides.map((_, index) => ({ id: `rId${index + 2}`, type: REL_TYPE.slide, target: `slides/slide${index + 1}.xml` })),
176
+ ];
177
+ const presentation = `${XML_HEAD}<p:presentation ${NS}><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst>${slides
178
+ .map((_, index) => `<p:sldId id="${256 + index}" r:id="rId${index + 2}"/>`)
179
+ .join("")}</p:sldIdLst><p:sldSz cx="${EMU_W}" cy="${EMU_H}" type="screen16x9"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`;
180
+ entries.push({ name: "ppt/presentation.xml", data: presentation });
181
+ entries.push({ name: "ppt/_rels/presentation.xml.rels", data: relsXml(presentationRels) });
182
+
183
+ entries.push({
184
+ name: "ppt/slideMasters/slideMaster1.xml",
185
+ data: `${XML_HEAD}<p:sldMaster ${NS}><p:cSld>${emptySpTree}</p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>`,
186
+ });
187
+ entries.push({
188
+ name: "ppt/slideMasters/_rels/slideMaster1.xml.rels",
189
+ data: relsXml([
190
+ { id: "rId1", type: REL_TYPE.slideLayout, target: "../slideLayouts/slideLayout1.xml" },
191
+ { id: "rId2", type: REL_TYPE.theme, target: "../theme/theme1.xml" },
192
+ ]),
193
+ });
194
+ entries.push({
195
+ name: "ppt/slideLayouts/slideLayout1.xml",
196
+ data: `${XML_HEAD}<p:sldLayout ${NS} type="blank" preserve="1"><p:cSld name="Blank">${emptySpTree}</p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>`,
197
+ });
198
+ entries.push({
199
+ name: "ppt/slideLayouts/_rels/slideLayout1.xml.rels",
200
+ data: relsXml([{ id: "rId1", type: REL_TYPE.slideMaster, target: "../slideMasters/slideMaster1.xml" }]),
201
+ });
202
+ entries.push({ name: "ppt/theme/theme1.xml", data: themeXml });
203
+
204
+ slides.forEach((slide, index) => {
205
+ const number = index + 1;
206
+ const ext = extname(slide.file).toLowerCase().replace(".", "") || "png";
207
+ const mediaName = `image${number}.${ext === "jpg" ? "jpeg" : ext}`;
208
+ const rels = [
209
+ { id: "rId1", type: REL_TYPE.slideLayout, target: "../slideLayouts/slideLayout1.xml" },
210
+ { id: "rId2", type: REL_TYPE.image, target: `../media/${mediaName}` },
211
+ ];
212
+ const slideXml = pptxSlideXml(number);
213
+ entries.push({ name: `ppt/slides/slide${number}.xml`, data: slideXml });
214
+ entries.push({ name: `ppt/slides/_rels/slide${number}.xml.rels`, data: relsXml(rels) });
215
+ entries.push({ name: `ppt/media/${mediaName}`, data: readFileSync(slide.file), store: true });
216
+ });
217
+ return zip(entries);
218
+ }
219
+
220
+ /* ------------------------------------------------------------------ *
221
+ * PDF
222
+ * ------------------------------------------------------------------ */
223
+
224
+ /** JPEG 尺寸(SOF0/SOF2)。 */
225
+ function jpegSize(buffer) {
226
+ let offset = 2;
227
+ while (offset + 9 < buffer.length) {
228
+ if (buffer[offset] !== 0xff) {
229
+ offset += 1;
230
+ continue;
231
+ }
232
+ const marker = buffer[offset + 1];
233
+ if (marker >= 0xc0 && marker <= 0xc3) {
234
+ return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
235
+ }
236
+ offset += 2 + buffer.readUInt16BE(offset + 2);
237
+ }
238
+ return null;
239
+ }
240
+
241
+ /** PNG/JPEG/WEBP → JPEG(macOS sips)。 */
242
+ export function toJpeg(file, workDir) {
243
+ const out = join(workDir, `${file.split("/").pop().replace(/[^\w.-]/g, "_")}.jpg`);
244
+ try {
245
+ execFileSync("sips", ["-s", "format", "jpeg", "-s", "formatOptions", "88", file, "--out", out], { stdio: ["ignore", "ignore", "ignore"] });
246
+ } catch {
247
+ throw new Error("PDF 导出需要 macOS `sips` 把整页图转成 JPEG;当前环境不可用。可改导 PPTX。");
248
+ }
249
+ return readFileSync(out);
250
+ }
251
+
252
+ /**
253
+ * 生成 PDF(每页一张整页图,按图片比例铺满)。
254
+ * @param {{file: string}[]} slides
255
+ * @returns {Buffer}
256
+ */
257
+ export function buildPdf(slides) {
258
+ if (!slides?.length) throw new Error("没有可导出的页(至少需要 1 页图片)");
259
+ const workDir = mkdtempSync(join(tmpdir(), "deck-pdf-"));
260
+ try {
261
+ const images = slides.map((slide) => {
262
+ const jpeg = toJpeg(slide.file, workDir);
263
+ const size = jpegSize(jpeg) ?? { width: 2560, height: 1440 };
264
+ const pageHeight = 540;
265
+ const pageWidth = Math.round((pageHeight * size.width) / size.height);
266
+ return { jpeg, width: size.width, height: size.height, pageWidth, pageHeight };
267
+ });
268
+
269
+ const objects = [];
270
+ const add = (data) => {
271
+ objects.push(Buffer.isBuffer(data) ? data : Buffer.from(String(data), "utf8"));
272
+ return objects.length;
273
+ };
274
+ const catalogId = add("<< /Type /Catalog /Pages 2 0 R >>");
275
+ const pagesId = add(""); // 占位,稍后回填
276
+ const pageIds = [];
277
+ const contentIds = [];
278
+ const imageIds = [];
279
+ for (const image of images) {
280
+ const stream = `q\n${image.pageWidth} 0 0 ${image.pageHeight} 0 0 cm\n/Im0 Do\nQ\n`;
281
+ contentIds.push(add(Buffer.concat([Buffer.from(`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n`), Buffer.from(stream), Buffer.from("endstream")])));
282
+ imageIds.push(
283
+ add(
284
+ Buffer.concat([
285
+ Buffer.from(
286
+ `<< /Type /XObject /Subtype /Image /Width ${image.width} /Height ${image.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.jpeg.length} >>\nstream\n`,
287
+ ),
288
+ image.jpeg,
289
+ Buffer.from("\nendstream"),
290
+ ]),
291
+ ),
292
+ );
293
+ }
294
+ for (let index = 0; index < images.length; index += 1) {
295
+ const image = images[index];
296
+ pageIds.push(
297
+ add(
298
+ `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${image.pageWidth} ${image.pageHeight}] /Resources << /XObject << /Im0 ${imageIds[index]} 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
299
+ ),
300
+ );
301
+ }
302
+ objects[pagesId - 1] = Buffer.from(`<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pageIds.length} >>`);
303
+
304
+ const chunks = [Buffer.from("%PDF-1.5\n%\u00e2\u00e3\u00cf\u00d3\n", "latin1")];
305
+ const offsets = [];
306
+ let position = chunks[0].length;
307
+ objects.forEach((body, index) => {
308
+ offsets.push(position);
309
+ const head = Buffer.from(`${index + 1} 0 obj\n`, "utf8");
310
+ const tail = Buffer.from("\nendobj\n", "utf8");
311
+ chunks.push(head, body, tail);
312
+ position += head.length + body.length + tail.length;
313
+ });
314
+ const xrefStart = position;
315
+ const xref = [`xref\n0 ${objects.length + 1}\n`, "0000000000 65535 f \n"];
316
+ for (const offset of offsets) xref.push(`${String(offset).padStart(10, "0")} 00000 n \n`);
317
+ chunks.push(Buffer.from(xref.join(""), "utf8"));
318
+ chunks.push(Buffer.from(`trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`, "utf8"));
319
+ return Buffer.concat(chunks);
320
+ } finally {
321
+ try {
322
+ rmSync(workDir, { recursive: true, force: true });
323
+ } catch {
324
+ /* 临时目录清理失败不影响导出 */
325
+ }
326
+ }
327
+ }
328
+
329
+ /** 导出入口。 */
330
+ export function exportDeck({ format, slides }) {
331
+ if (format === "pptx") return { buffer: buildPptx(slides), ext: "pptx", mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation" };
332
+ if (format === "pdf") return { buffer: buildPdf(slides), ext: "pdf", mime: "application/pdf" };
333
+ throw new Error(`不支持的导出格式:${format}(只支持 pdf / pptx)`);
334
+ }
335
+
336
+ export { jpegSize, deflateSync, existsSync };