@nhtio/adk 1.20260609.0 → 1.20260610.0
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.
- package/CHANGELOG.md +132 -9
- package/batteries/llm/ollama/helpers.cjs +9 -0
- package/batteries/llm/ollama/helpers.cjs.map +1 -1
- package/batteries/llm/ollama/helpers.mjs +9 -0
- package/batteries/llm/ollama/helpers.mjs.map +1 -1
- package/batteries/llm/openai_chat_completions/helpers.cjs +19 -0
- package/batteries/llm/openai_chat_completions/helpers.cjs.map +1 -1
- package/batteries/llm/openai_chat_completions/helpers.mjs +19 -0
- package/batteries/llm/openai_chat_completions/helpers.mjs.map +1 -1
- package/batteries/media/builder.d.ts +245 -0
- package/batteries/media/contracts.cjs +119 -0
- package/batteries/media/contracts.cjs.map +1 -0
- package/batteries/media/contracts.d.ts +321 -0
- package/batteries/media/contracts.mjs +110 -0
- package/batteries/media/contracts.mjs.map +1 -0
- package/batteries/media/engines/audio_decode.cjs +92 -0
- package/batteries/media/engines/audio_decode.cjs.map +1 -0
- package/batteries/media/engines/audio_decode.d.ts +46 -0
- package/batteries/media/engines/audio_decode.mjs +90 -0
- package/batteries/media/engines/audio_decode.mjs.map +1 -0
- package/batteries/media/engines/execa_executor.cjs +64 -0
- package/batteries/media/engines/execa_executor.cjs.map +1 -0
- package/batteries/media/engines/execa_executor.d.ts +54 -0
- package/batteries/media/engines/execa_executor.mjs +62 -0
- package/batteries/media/engines/execa_executor.mjs.map +1 -0
- package/batteries/media/engines/fs_workspace.cjs +84 -0
- package/batteries/media/engines/fs_workspace.cjs.map +1 -0
- package/batteries/media/engines/fs_workspace.d.ts +51 -0
- package/batteries/media/engines/fs_workspace.mjs +82 -0
- package/batteries/media/engines/fs_workspace.mjs.map +1 -0
- package/batteries/media/engines/jimp.cjs +116 -0
- package/batteries/media/engines/jimp.cjs.map +1 -0
- package/batteries/media/engines/jimp.d.ts +32 -0
- package/batteries/media/engines/jimp.mjs +114 -0
- package/batteries/media/engines/jimp.mjs.map +1 -0
- package/batteries/media/engines/sharp.cjs +120 -0
- package/batteries/media/engines/sharp.cjs.map +1 -0
- package/batteries/media/engines/sharp.d.ts +42 -0
- package/batteries/media/engines/sharp.mjs +117 -0
- package/batteries/media/engines/sharp.mjs.map +1 -0
- package/batteries/media/engines/soffice.cjs +246 -0
- package/batteries/media/engines/soffice.cjs.map +1 -0
- package/batteries/media/engines/soffice.d.ts +39 -0
- package/batteries/media/engines/soffice.mjs +244 -0
- package/batteries/media/engines/soffice.mjs.map +1 -0
- package/batteries/media/engines/tesseract_js.cjs +87 -0
- package/batteries/media/engines/tesseract_js.cjs.map +1 -0
- package/batteries/media/engines/tesseract_js.d.ts +41 -0
- package/batteries/media/engines/tesseract_js.mjs +85 -0
- package/batteries/media/engines/tesseract_js.mjs.map +1 -0
- package/batteries/media/engines/transformers_asr.cjs +111 -0
- package/batteries/media/engines/transformers_asr.cjs.map +1 -0
- package/batteries/media/engines/transformers_asr.d.ts +41 -0
- package/batteries/media/engines/transformers_asr.mjs +109 -0
- package/batteries/media/engines/transformers_asr.mjs.map +1 -0
- package/batteries/media/exceptions.d.ts +103 -0
- package/batteries/media/forge.cjs +403 -0
- package/batteries/media/forge.cjs.map +1 -0
- package/batteries/media/forge.d.ts +90 -0
- package/batteries/media/forge.mjs +399 -0
- package/batteries/media/forge.mjs.map +1 -0
- package/batteries/media/formats.d.ts +72 -0
- package/batteries/media/index.d.ts +136 -0
- package/batteries/media/lint.cjs +339 -0
- package/batteries/media/lint.cjs.map +1 -0
- package/batteries/media/lint.d.ts +117 -0
- package/batteries/media/lint.mjs +331 -0
- package/batteries/media/lint.mjs.map +1 -0
- package/batteries/media/pipe.d.ts +66 -0
- package/batteries/media/plan.d.ts +133 -0
- package/batteries/media/registry.d.ts +92 -0
- package/batteries/media/runtime.d.ts +105 -0
- package/batteries/media/steps/doc.d.ts +33 -0
- package/batteries/media/steps/image_audio.d.ts +24 -0
- package/batteries/media/steps/ingest.d.ts +25 -0
- package/batteries/media/steps/pages.d.ts +18 -0
- package/batteries/media/steps/sheet.d.ts +36 -0
- package/batteries/media/steps/slides.d.ts +35 -0
- package/batteries/media/steps/text.d.ts +43 -0
- package/batteries/media/validate.d.ts +49 -0
- package/batteries/media/verbs.d.ts +126 -0
- package/batteries/media.cjs +3049 -0
- package/batteries/media.cjs.map +1 -0
- package/batteries/media.mjs +3009 -0
- package/batteries/media.mjs.map +1 -0
- package/batteries/tools/_shared/index.d.ts +142 -0
- package/batteries/tools/_shared.cjs +173 -0
- package/batteries/tools/_shared.cjs.map +1 -0
- package/batteries/tools/_shared.mjs +164 -0
- package/batteries/tools/_shared.mjs.map +1 -0
- package/batteries/tools/index.d.ts +2 -0
- package/batteries/tools/scrapper/exceptions.d.ts +21 -0
- package/batteries/tools/scrapper/index.d.ts +172 -0
- package/batteries/tools/scrapper/shared.d.ts +146 -0
- package/batteries/tools/scrapper.cjs +8 -0
- package/batteries/tools/scrapper.mjs +2 -0
- package/batteries/tools/searxng/index.d.ts +54 -20
- package/batteries/tools/searxng.cjs +2 -1
- package/batteries/tools/searxng.mjs +2 -2
- package/batteries/tools/web_retrieval/index.d.ts +186 -0
- package/batteries/tools/web_retrieval.cjs +206 -0
- package/batteries/tools/web_retrieval.cjs.map +1 -0
- package/batteries/tools/web_retrieval.mjs +201 -0
- package/batteries/tools/web_retrieval.mjs.map +1 -0
- package/batteries/tools.cjs +13 -1
- package/batteries/tools.mjs +4 -2
- package/batteries.cjs +13 -1
- package/batteries.mjs +4 -2
- package/common.d.ts +1 -1
- package/eslint.cjs +1 -1
- package/eslint.mjs +1 -1
- package/exceptions-C7FSHEnV.mjs +87 -0
- package/exceptions-C7FSHEnV.mjs.map +1 -0
- package/exceptions-CQi_lNs1.js +152 -0
- package/exceptions-CQi_lNs1.js.map +1 -0
- package/index.cjs +2 -2
- package/index.mjs +2 -2
- package/mcp/adk-docs-corpus.json +1 -1
- package/package.json +301 -178
- package/scrapper-BOLWYGbD.js +463 -0
- package/scrapper-BOLWYGbD.js.map +1 -0
- package/scrapper-hDKlNuCT.mjs +433 -0
- package/scrapper-hDKlNuCT.mjs.map +1 -0
- package/{searxng-Bkrwhwhw.js → searxng-CJtEpa8p.js} +82 -85
- package/searxng-CJtEpa8p.js.map +1 -0
- package/{searxng-CyA-nEu5.mjs → searxng-riarj_0u.mjs} +76 -85
- package/searxng-riarj_0u.mjs.map +1 -0
- package/skills/adk-assembly/SKILL.md +2 -2
- package/validate-BFaUYHDN.js +1298 -0
- package/validate-BFaUYHDN.js.map +1 -0
- package/validate-DSZ3wicB.mjs +1215 -0
- package/validate-DSZ3wicB.mjs.map +1 -0
- package/searxng-Bkrwhwhw.js.map +0 -1
- package/searxng-CyA-nEu5.mjs.map +0 -1
|
@@ -0,0 +1,3009 @@
|
|
|
1
|
+
import { c as isObject, o as isError, s as isInstanceOf } from "../tool_registry-791Vrjtf.mjs";
|
|
2
|
+
import "../guards.mjs";
|
|
3
|
+
import { PCM_MIME, bytesToPcm, implementsMediaEngine, pcmToBytes } from "./media/contracts.mjs";
|
|
4
|
+
import { a as E_MEDIA_NOT_PIPE_EXPRESSIBLE, c as E_MEDIA_STEP_UNAVAILABLE, d as E_MEDIA_UNSUPPORTED_OP, i as E_MEDIA_MISSING_ARG, l as E_MEDIA_UNKNOWN_ARG, n as E_MEDIA_BAD_ARG, o as E_MEDIA_PIPE_SYNTAX, r as E_MEDIA_ENGINE_REQUIRED, s as E_MEDIA_STEP_FAILED, t as E_INVALID_MEDIA_PIPELINE_CONFIG, u as E_MEDIA_UNKNOWN_VERB } from "../exceptions-C7FSHEnV.mjs";
|
|
5
|
+
import { a as VERBS, c as suggestVerbs, d as isMediaRef, f as isRegExpRef, i as FOLDED_VERBS, l as canonicalFlags, m as toPipe, n as validateOps, o as VERB_INDEX, p as toOps, r as validateSegments, s as foldVerb, t as availableVerbs, u as fromOps } from "../validate-DSZ3wicB.mjs";
|
|
6
|
+
import { validator } from "@nhtio/validation";
|
|
7
|
+
import { Middleware } from "@nhtio/middleware";
|
|
8
|
+
import moo from "moo";
|
|
9
|
+
//#region src/batteries/media/pipe.ts
|
|
10
|
+
/**
|
|
11
|
+
* The pipe-expression front-end: a moo lexer plus a hand-rolled recursive-descent parser that
|
|
12
|
+
* compiles `select pages=2-5 | redact match=/…/ | convert to=pdf` into a {@link MediaPlan}.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. The grammar is frozen in the
|
|
16
|
+
* design doc (section 0):
|
|
17
|
+
*
|
|
18
|
+
* - `pipeline := segment ('|' segment)*` ; `segment := verb arg*` ; `arg := IDENT '=' value` —
|
|
19
|
+
* named args only, no positionals (an IDENT followed by `=` is an arg; otherwise it is the
|
|
20
|
+
* verb's second word — 2-token lookahead, no verb table needed at parse time).
|
|
21
|
+
* - Verb matching is separator-insensitive (`extract_text` ≡ `extract text` ≡ `extract.text`).
|
|
22
|
+
* - Values: bareword idents, ints/floats, `a-b` ranges, comma lists, `true`/`false`, quoted
|
|
23
|
+
* strings (single or double), class-aware `/regex/flags` literals, `@id` media refs, and
|
|
24
|
+
* quoted-JSON structured payloads (a quoted string that the verb's arg schema declares as
|
|
25
|
+
* `json` is JSON-parsed).
|
|
26
|
+
* - `#` line comments are tolerated (models add them; ignoring them is free robustness).
|
|
27
|
+
* - Two error layers: syntactic ({@link E_MEDIA_PIPE_SYNTAX}, with line/col and a corrective
|
|
28
|
+
* exemplar) and semantic ({@link E_MEDIA_UNKNOWN_VERB} etc., produced by the validator in
|
|
29
|
+
* `validate.ts` — this module only parses to a raw AST and lowers to the plan).
|
|
30
|
+
*
|
|
31
|
+
* Parsing is deployment-independent: the full verb table drives folding, and engine narrowing
|
|
32
|
+
* happens later in validation (frozen 0.3).
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* The moo ruleset. Order matters: first match wins, so `range` must precede `int`, `float`
|
|
36
|
+
* precedes `int`, and `comment` precedes nothing it could shadow. String/regex bodies exclude
|
|
37
|
+
* raw newlines (escape them); `ws` carries `lineBreaks` so spans stay accurate across lines.
|
|
38
|
+
*/
|
|
39
|
+
var lexer = moo.compile({
|
|
40
|
+
ws: {
|
|
41
|
+
match: /[ \t\r\n]+/,
|
|
42
|
+
lineBreaks: true
|
|
43
|
+
},
|
|
44
|
+
comment: /#[^\n]*/,
|
|
45
|
+
pipe: "|",
|
|
46
|
+
eq: "=",
|
|
47
|
+
comma: ",",
|
|
48
|
+
ref: /@[A-Za-z0-9_-]+/,
|
|
49
|
+
regex: /\/(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^/\\[\n])+\/[a-z]*/,
|
|
50
|
+
dstring: /"(?:\\.|[^"\\\n])*"/,
|
|
51
|
+
sstring: /'(?:\\.|[^'\\\n])*'/,
|
|
52
|
+
range: /\d+-\d+/,
|
|
53
|
+
float: /\d+\.\d+/,
|
|
54
|
+
int: /\d+/,
|
|
55
|
+
ident: /[A-Za-z_][A-Za-z0-9_.]*/,
|
|
56
|
+
dash: "-"
|
|
57
|
+
});
|
|
58
|
+
/** Tokenize, skipping ws/comments, surfacing lexer errors as syntax exceptions. */
|
|
59
|
+
var tokenize = (input) => {
|
|
60
|
+
lexer.reset(input);
|
|
61
|
+
const out = [];
|
|
62
|
+
let tok;
|
|
63
|
+
try {
|
|
64
|
+
while (tok = lexer.next()) {
|
|
65
|
+
if (tok.type === "ws" || tok.type === "comment") continue;
|
|
66
|
+
out.push({
|
|
67
|
+
type: tok.type,
|
|
68
|
+
value: tok.value,
|
|
69
|
+
text: tok.text,
|
|
70
|
+
offset: tok.offset,
|
|
71
|
+
line: tok.line,
|
|
72
|
+
col: tok.col
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new E_MEDIA_PIPE_SYNTAX([`${isError(err) ? err.message.split("\n")[0] : String(err)}. Values containing special characters must be quoted — write it like: name="my-value"`]);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
80
|
+
var spanOf = (tok, length) => ({
|
|
81
|
+
offset: tok.offset,
|
|
82
|
+
line: tok.line,
|
|
83
|
+
col: tok.col,
|
|
84
|
+
length: length ?? tok.text.length
|
|
85
|
+
});
|
|
86
|
+
var unquote = (raw) => raw.slice(1, -1).replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\(["'\\/])/g, "$1");
|
|
87
|
+
var fail$5 = (tok, message, exemplar) => {
|
|
88
|
+
throw new E_MEDIA_PIPE_SYNTAX([`${message}${tok ? ` at line ${tok.line}, col ${tok.col}` : " at end of input"}. Write it like: ${exemplar}`]);
|
|
89
|
+
};
|
|
90
|
+
var Parser = class {
|
|
91
|
+
#toks;
|
|
92
|
+
#pos = 0;
|
|
93
|
+
constructor(toks) {
|
|
94
|
+
this.#toks = toks;
|
|
95
|
+
}
|
|
96
|
+
#peek(ahead = 0) {
|
|
97
|
+
return this.#toks[this.#pos + ahead];
|
|
98
|
+
}
|
|
99
|
+
#next() {
|
|
100
|
+
return this.#toks[this.#pos++];
|
|
101
|
+
}
|
|
102
|
+
parsePipeline() {
|
|
103
|
+
const segments = [];
|
|
104
|
+
if (this.#toks.length === 0) fail$5(void 0, "empty pipe expression", "extract text | chunk by=sentence");
|
|
105
|
+
segments.push(this.#parseSegment());
|
|
106
|
+
while (this.#peek()?.type === "pipe") {
|
|
107
|
+
this.#next();
|
|
108
|
+
segments.push(this.#parseSegment());
|
|
109
|
+
}
|
|
110
|
+
const trailing = this.#peek();
|
|
111
|
+
if (trailing) fail$5(trailing, `unexpected "${trailing.text}" after a complete segment`, "segment | segment (segments are separated by |)");
|
|
112
|
+
return segments;
|
|
113
|
+
}
|
|
114
|
+
#parseSegment() {
|
|
115
|
+
const first = this.#peek();
|
|
116
|
+
if (!first || first.type !== "ident") fail$5(first, "expected a verb to start the segment", "convert to=pdf");
|
|
117
|
+
const head = this.#next();
|
|
118
|
+
const collected = [head.value];
|
|
119
|
+
const posAfterWord = [this.#pos];
|
|
120
|
+
while (collected.length < 3) {
|
|
121
|
+
if (this.#peek()?.type !== "ident" || this.#peek(1)?.type === "eq") break;
|
|
122
|
+
collected.push(this.#next().value);
|
|
123
|
+
posAfterWord.push(this.#pos);
|
|
124
|
+
}
|
|
125
|
+
let take = collected.length;
|
|
126
|
+
if (foldVerb(collected) === void 0) {
|
|
127
|
+
let prefix = collected.length - 1;
|
|
128
|
+
while (prefix >= 1 && foldVerb(collected.slice(0, prefix)) === void 0) prefix -= 1;
|
|
129
|
+
if (prefix >= 1) take = prefix;
|
|
130
|
+
}
|
|
131
|
+
const words = collected.slice(0, take);
|
|
132
|
+
this.#pos = posAfterWord[take - 1];
|
|
133
|
+
const folded = foldVerb(words);
|
|
134
|
+
const verbText = words.join(" ");
|
|
135
|
+
const args = /* @__PURE__ */ new Map();
|
|
136
|
+
for (;;) {
|
|
137
|
+
const tok = this.#peek();
|
|
138
|
+
if (!tok || tok.type === "pipe") break;
|
|
139
|
+
if (tok.type === "dash") fail$5(tok, "unexpected \"-\" — values containing dashes must be quoted", `${verbText} name="value-with-dashes"`);
|
|
140
|
+
if (tok.type !== "ident" || this.#peek(1)?.type !== "eq") fail$5(tok, `unexpected "${tok.text}" — args must be name=value`, `${verbText} name=value`);
|
|
141
|
+
const name = this.#next().value;
|
|
142
|
+
this.#next();
|
|
143
|
+
const value = this.#parseValue(verbText, name);
|
|
144
|
+
if (args.has(name)) fail$5(tok, `duplicate arg "${name}"`, `${verbText} ${name}=value (give each arg once)`);
|
|
145
|
+
args.set(name, value);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
verb: folded ?? verbText,
|
|
149
|
+
known: folded !== void 0,
|
|
150
|
+
args,
|
|
151
|
+
span: spanOf(head, verbText.length)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
#parseValue(verbText, argName) {
|
|
155
|
+
const first = this.#parseScalar(verbText, argName);
|
|
156
|
+
if (this.#peek()?.type !== "comma") return first;
|
|
157
|
+
const items = [...asScalarList(first, verbText, argName)];
|
|
158
|
+
let quoted = first.quoted;
|
|
159
|
+
while (this.#peek()?.type === "comma") {
|
|
160
|
+
this.#next();
|
|
161
|
+
const next = this.#parseScalar(verbText, argName);
|
|
162
|
+
quoted = quoted || next.quoted;
|
|
163
|
+
items.push(...asScalarList(next, verbText, argName));
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
value: items,
|
|
167
|
+
quoted,
|
|
168
|
+
span: first.span
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
#parseScalar(verbText, argName) {
|
|
172
|
+
const tok = this.#peek();
|
|
173
|
+
if (!tok) fail$5(tok, `missing value for "${argName}"`, `${verbText} ${argName}=value`);
|
|
174
|
+
const t = tok;
|
|
175
|
+
switch (t.type) {
|
|
176
|
+
case "int":
|
|
177
|
+
this.#next();
|
|
178
|
+
return {
|
|
179
|
+
value: Number(t.value),
|
|
180
|
+
quoted: false,
|
|
181
|
+
span: spanOf(t)
|
|
182
|
+
};
|
|
183
|
+
case "float":
|
|
184
|
+
this.#next();
|
|
185
|
+
return {
|
|
186
|
+
value: Number(t.value),
|
|
187
|
+
quoted: false,
|
|
188
|
+
span: spanOf(t)
|
|
189
|
+
};
|
|
190
|
+
case "range": {
|
|
191
|
+
this.#next();
|
|
192
|
+
const [lo, hi] = t.value.split("-").map(Number);
|
|
193
|
+
if (lo > hi) fail$5(t, `range ${t.value} is descending (start must be ≤ end)`, `${argName}=${hi}-${lo}`);
|
|
194
|
+
const nums = [];
|
|
195
|
+
for (let n = lo; n <= hi; n++) nums.push(n);
|
|
196
|
+
return {
|
|
197
|
+
value: nums,
|
|
198
|
+
quoted: false,
|
|
199
|
+
span: spanOf(t)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
case "ident":
|
|
203
|
+
this.#next();
|
|
204
|
+
if (t.value === "true") return {
|
|
205
|
+
value: true,
|
|
206
|
+
quoted: false,
|
|
207
|
+
span: spanOf(t)
|
|
208
|
+
};
|
|
209
|
+
if (t.value === "false") return {
|
|
210
|
+
value: false,
|
|
211
|
+
quoted: false,
|
|
212
|
+
span: spanOf(t)
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
value: t.value,
|
|
216
|
+
quoted: false,
|
|
217
|
+
span: spanOf(t)
|
|
218
|
+
};
|
|
219
|
+
case "dstring":
|
|
220
|
+
case "sstring":
|
|
221
|
+
this.#next();
|
|
222
|
+
return {
|
|
223
|
+
value: unquote(t.text),
|
|
224
|
+
quoted: true,
|
|
225
|
+
span: spanOf(t)
|
|
226
|
+
};
|
|
227
|
+
case "regex": {
|
|
228
|
+
this.#next();
|
|
229
|
+
const lastSlash = t.text.lastIndexOf("/");
|
|
230
|
+
const source = t.text.slice(1, lastSlash);
|
|
231
|
+
const flags = canonicalFlags(t.text.slice(lastSlash + 1));
|
|
232
|
+
try {
|
|
233
|
+
new RegExp(source, flags);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
const detail = isError(err) ? err.message : String(err);
|
|
236
|
+
fail$5(t, `invalid regex ${t.text}: ${detail}`, `${argName}=/pattern/flags`);
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
value: {
|
|
240
|
+
source,
|
|
241
|
+
flags
|
|
242
|
+
},
|
|
243
|
+
quoted: false,
|
|
244
|
+
span: spanOf(t)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
case "ref":
|
|
248
|
+
this.#next();
|
|
249
|
+
return {
|
|
250
|
+
value: {
|
|
251
|
+
kind: "id",
|
|
252
|
+
id: t.value.slice(1)
|
|
253
|
+
},
|
|
254
|
+
quoted: false,
|
|
255
|
+
span: spanOf(t)
|
|
256
|
+
};
|
|
257
|
+
case "dash":
|
|
258
|
+
fail$5(t, "unexpected \"-\" in a bare value", `${argName}="value-with-dashes" (quote values containing dashes)`);
|
|
259
|
+
break;
|
|
260
|
+
default: fail$5(t, `unexpected "${t.text}"`, `${verbText} ${argName}=value`);
|
|
261
|
+
}
|
|
262
|
+
throw new E_MEDIA_PIPE_SYNTAX(["internal parser error"]);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
var asScalarList = (raw, verbText, argName) => {
|
|
266
|
+
if (Array.isArray(raw.value)) return raw.value;
|
|
267
|
+
if (raw.value === null || typeof raw.value === "object") {
|
|
268
|
+
const v = raw.value;
|
|
269
|
+
if (isObject(v) && !Array.isArray(v) && !("source" in v) && !("kind" in v)) throw new E_MEDIA_PIPE_SYNTAX([`structured values cannot appear in a comma list for "${argName}" on "${verbText}"`]);
|
|
270
|
+
}
|
|
271
|
+
return [raw.value];
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Parse a pipe expression into raw, position-bearing segments.
|
|
275
|
+
*
|
|
276
|
+
* @remarks
|
|
277
|
+
* Purely syntactic — verbs are fold-matched against the full verb table for canonicalization
|
|
278
|
+
* but unknown verbs are NOT an error here (the validator reports them with did-you-mean and the
|
|
279
|
+
* deployment's narrowed verb list). Use {@link parsePipe} for the validated path.
|
|
280
|
+
*
|
|
281
|
+
* @param input - The pipe expression.
|
|
282
|
+
* @returns The raw segments.
|
|
283
|
+
*/
|
|
284
|
+
var parsePipeRaw = (input) => {
|
|
285
|
+
return new Parser(tokenize(input)).parsePipeline();
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Lower raw segments to an (unvalidated) {@link MediaPlan}. Quoted-JSON arg parsing and
|
|
289
|
+
* type/enum checks happen in the validator, which consumes the raw segments — this lowering
|
|
290
|
+
* exists for tooling that wants the structural plan without validation.
|
|
291
|
+
*
|
|
292
|
+
* @param segments - Output of {@link parsePipeRaw}.
|
|
293
|
+
* @returns The structural plan (args carried as parsed, json args still strings).
|
|
294
|
+
*/
|
|
295
|
+
var lowerSegments = (segments) => ({ steps: segments.map((seg) => ({
|
|
296
|
+
verb: seg.verb,
|
|
297
|
+
args: Object.fromEntries(Array.from(seg.args.entries(), ([k, v]) => [k, v.value])),
|
|
298
|
+
span: seg.span
|
|
299
|
+
})) });
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/batteries/media/builder.ts
|
|
302
|
+
/**
|
|
303
|
+
* The chainable, thenable media builder — the implementor-facing front-end that compiles to
|
|
304
|
+
* the same {@link MediaPlan} as the pipe string and JSON ops.
|
|
305
|
+
*
|
|
306
|
+
* @remarks
|
|
307
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. The knex lessons applied:
|
|
308
|
+
* `mp(input)` opens a fresh immutable builder; every verb returns a new builder; awaiting the
|
|
309
|
+
* builder executes the chain (no `.execute()`). Domain verbs hang off typed namespaces
|
|
310
|
+
* (`.sheet`, `.slides`, `.image`, `.audio`); shared transforms live on the root.
|
|
311
|
+
*
|
|
312
|
+
* Each verb method appends an op and revalidates lazily — the full plan validates (verb table,
|
|
313
|
+
* arg schemas, engine narrowing) when the chain executes, so building is cheap and the same
|
|
314
|
+
* validator serves all three front-ends.
|
|
315
|
+
*/
|
|
316
|
+
var refOf = (other) => typeof other === "string" ? {
|
|
317
|
+
kind: "id",
|
|
318
|
+
id: other
|
|
319
|
+
} : {
|
|
320
|
+
kind: "id",
|
|
321
|
+
id: other.mediaId
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* The chainable builder. Immutable — every verb returns a new instance sharing the executor.
|
|
325
|
+
* Thenable — `await` runs the chain and resolves to the terminal step's natural type.
|
|
326
|
+
*/
|
|
327
|
+
var MediaChain = class MediaChain {
|
|
328
|
+
#ops;
|
|
329
|
+
#exec;
|
|
330
|
+
constructor(exec, ops = []) {
|
|
331
|
+
this.#exec = exec;
|
|
332
|
+
this.#ops = ops;
|
|
333
|
+
}
|
|
334
|
+
#with(verb, args) {
|
|
335
|
+
return new MediaChain(this.#exec, [...this.#ops, {
|
|
336
|
+
verb,
|
|
337
|
+
args
|
|
338
|
+
}]);
|
|
339
|
+
}
|
|
340
|
+
/** Convert to another format (requires a convert engine). */
|
|
341
|
+
convert(to) {
|
|
342
|
+
return this.#with("convert", { to });
|
|
343
|
+
}
|
|
344
|
+
/** Keep only the listed 1-based pages. */
|
|
345
|
+
select(options) {
|
|
346
|
+
return this.#with("select", { pages: options.pages });
|
|
347
|
+
}
|
|
348
|
+
/** Split by page (optionally grouped `[[start,end], …]`). Terminal: resolves to media list. */
|
|
349
|
+
split(options = {}) {
|
|
350
|
+
const args = {};
|
|
351
|
+
if (options.by) args.by = options.by;
|
|
352
|
+
if (options.ranges) args.ranges = options.ranges;
|
|
353
|
+
return this.#with("split", args);
|
|
354
|
+
}
|
|
355
|
+
/** Merge other media into this one, in order. */
|
|
356
|
+
merge(...others) {
|
|
357
|
+
return this.#with("merge", { with: others.map(refOf) });
|
|
358
|
+
}
|
|
359
|
+
/** Reorder pages by 1-based index. */
|
|
360
|
+
reorder(order) {
|
|
361
|
+
return this.#with("reorder", { order });
|
|
362
|
+
}
|
|
363
|
+
/** Redact matching text (literals or RegExp). */
|
|
364
|
+
redact(options) {
|
|
365
|
+
const args = { match: (Array.isArray(options.match) ? options.match : [options.match]).map((m) => typeof m === "string" ? m : {
|
|
366
|
+
source: m.source,
|
|
367
|
+
flags: Array.from(m.flags).sort().join("")
|
|
368
|
+
}) };
|
|
369
|
+
if (options.replace !== void 0) args.replace = options.replace;
|
|
370
|
+
return this.#with("redact", args);
|
|
371
|
+
}
|
|
372
|
+
/** Remove potentially unsafe embedded content. */
|
|
373
|
+
sanitize() {
|
|
374
|
+
return this.#with("sanitize", {});
|
|
375
|
+
}
|
|
376
|
+
/** Normalize structure/encoding. */
|
|
377
|
+
normalize() {
|
|
378
|
+
return this.#with("normalize", {});
|
|
379
|
+
}
|
|
380
|
+
/** Replace the first occurrence of anchor text. */
|
|
381
|
+
updateText(anchor, replace) {
|
|
382
|
+
return this.#with("update_text", {
|
|
383
|
+
anchor,
|
|
384
|
+
replace
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/** Compare against another media. Terminal: resolves to a structured diff. */
|
|
388
|
+
diff(other) {
|
|
389
|
+
return this.#with("diff", { with: refOf(other) });
|
|
390
|
+
}
|
|
391
|
+
/** Apply a unified-diff patch. */
|
|
392
|
+
applyPatch(patch, options = {}) {
|
|
393
|
+
const args = { patch };
|
|
394
|
+
if (options.with) args.with = options.with.map(refOf);
|
|
395
|
+
return this.#with("apply_patch", args);
|
|
396
|
+
}
|
|
397
|
+
/** Extract text (routes by format; OCR engine used when needed). Terminal: resolves to text. */
|
|
398
|
+
extractText(options = {}) {
|
|
399
|
+
const args = {};
|
|
400
|
+
if (options.ocr) args.ocr = options.ocr;
|
|
401
|
+
if (options.ocrOut) args.ocr_out = options.ocrOut;
|
|
402
|
+
if (options.lang) args.lang = options.lang;
|
|
403
|
+
return this.#with("extract.text", args);
|
|
404
|
+
}
|
|
405
|
+
/** Extract metadata. Terminal: resolves to a metadata object. */
|
|
406
|
+
extractMetadata() {
|
|
407
|
+
return this.#with("extract.metadata", {});
|
|
408
|
+
}
|
|
409
|
+
/** Extract embedded assets. Terminal: resolves to a media list. */
|
|
410
|
+
extractAssets(options = {}) {
|
|
411
|
+
const args = {};
|
|
412
|
+
if (options.types) args.types = options.types;
|
|
413
|
+
return this.#with("extract.assets", args);
|
|
414
|
+
}
|
|
415
|
+
/** Chunk extracted text. Terminal: resolves to a chunk array. */
|
|
416
|
+
chunk(options = {}) {
|
|
417
|
+
const args = {};
|
|
418
|
+
if (options.strategy) args.by = options.strategy;
|
|
419
|
+
if (options.size !== void 0) args.size = options.size;
|
|
420
|
+
if (options.overlap !== void 0) args.overlap = options.overlap;
|
|
421
|
+
return this.#with("chunk", args);
|
|
422
|
+
}
|
|
423
|
+
/** Spreadsheet mutations. */
|
|
424
|
+
get sheet() {
|
|
425
|
+
return new SheetNamespace(this);
|
|
426
|
+
}
|
|
427
|
+
/** Presentation mutations. */
|
|
428
|
+
get slides() {
|
|
429
|
+
return new SlidesNamespace(this);
|
|
430
|
+
}
|
|
431
|
+
/** Image transforms. */
|
|
432
|
+
get image() {
|
|
433
|
+
return new ImageNamespace(this);
|
|
434
|
+
}
|
|
435
|
+
/** Audio operations. */
|
|
436
|
+
get audio() {
|
|
437
|
+
return new AudioNamespace(this);
|
|
438
|
+
}
|
|
439
|
+
/** The accumulated ops (a copy). */
|
|
440
|
+
toOps() {
|
|
441
|
+
return this.#ops.map((op) => ({
|
|
442
|
+
verb: op.verb,
|
|
443
|
+
args: { ...op.args }
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
/** The canonical pipe form of the accumulated chain. */
|
|
447
|
+
toPipe() {
|
|
448
|
+
return toPipe({ steps: this.#ops.map((op) => ({
|
|
449
|
+
verb: op.verb,
|
|
450
|
+
args: op.args
|
|
451
|
+
})) });
|
|
452
|
+
}
|
|
453
|
+
/** Internal — used by namespaces to append. */
|
|
454
|
+
withOp(verb, args) {
|
|
455
|
+
return this.#with(verb, args);
|
|
456
|
+
}
|
|
457
|
+
/** Thenable: awaiting the chain executes it. */
|
|
458
|
+
then(onfulfilled, onrejected) {
|
|
459
|
+
return this.#exec(this.toOps()).then(unwrapResult).then(onfulfilled, onrejected);
|
|
460
|
+
}
|
|
461
|
+
/** Execute and return the raw {@link PlanResult} (no unwrapping). */
|
|
462
|
+
async run() {
|
|
463
|
+
return this.#exec(this.toOps());
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
/** Resolve a plan result to its natural awaited type. */
|
|
467
|
+
var unwrapResult = (result) => {
|
|
468
|
+
if (result.kind === "media") return result.payload;
|
|
469
|
+
if (result.kind === "media-list") return result.payloads;
|
|
470
|
+
return result.data;
|
|
471
|
+
};
|
|
472
|
+
/** `sheet.*` verbs, mirrored from the verb table. */
|
|
473
|
+
var SheetNamespace = class {
|
|
474
|
+
#chain;
|
|
475
|
+
constructor(chain) {
|
|
476
|
+
this.#chain = chain;
|
|
477
|
+
}
|
|
478
|
+
/** Insert rows. */
|
|
479
|
+
addRows(rows, options = {}) {
|
|
480
|
+
return this.#chain.withOp("sheet.add_rows", compact({
|
|
481
|
+
rows,
|
|
482
|
+
...targetArgs(options),
|
|
483
|
+
before: options.before,
|
|
484
|
+
after: options.after
|
|
485
|
+
}));
|
|
486
|
+
}
|
|
487
|
+
/** Insert columns. */
|
|
488
|
+
addColumns(options) {
|
|
489
|
+
return this.#chain.withOp("sheet.add_columns", compact({
|
|
490
|
+
...targetArgs(options),
|
|
491
|
+
headers: options.headers,
|
|
492
|
+
columns: options.columns,
|
|
493
|
+
before: options.before,
|
|
494
|
+
after: options.after
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
/** Update cells. */
|
|
498
|
+
updateCells(updates, options = {}) {
|
|
499
|
+
return this.#chain.withOp("sheet.update_cells", compact({
|
|
500
|
+
updates,
|
|
501
|
+
...targetArgs(options)
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
/** Delete rows by 1-based index. */
|
|
505
|
+
deleteRows(rows, options = {}) {
|
|
506
|
+
return this.#chain.withOp("sheet.delete_rows", compact({
|
|
507
|
+
rows,
|
|
508
|
+
...targetArgs(options)
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
/** Delete columns by 1-based index. */
|
|
512
|
+
deleteColumns(columns, options = {}) {
|
|
513
|
+
return this.#chain.withOp("sheet.delete_columns", compact({
|
|
514
|
+
columns,
|
|
515
|
+
...targetArgs(options)
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
/** Rename a worksheet (name-targeted). */
|
|
519
|
+
renameSheet(sheet, to) {
|
|
520
|
+
return this.#chain.withOp("sheet.rename_sheet", {
|
|
521
|
+
sheet,
|
|
522
|
+
to
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/** Add a worksheet. */
|
|
526
|
+
addSheet(name, options = {}) {
|
|
527
|
+
return this.#chain.withOp("sheet.add_sheet", compact({
|
|
528
|
+
name,
|
|
529
|
+
at: options.at
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
/** Remove a worksheet (name-targeted). */
|
|
533
|
+
removeSheet(sheet) {
|
|
534
|
+
return this.#chain.withOp("sheet.remove_sheet", { sheet });
|
|
535
|
+
}
|
|
536
|
+
/** Reorder worksheets by names and/or 1-based indices. */
|
|
537
|
+
reorderSheets(order) {
|
|
538
|
+
return this.#chain.withOp("sheet.reorder_sheets", { order });
|
|
539
|
+
}
|
|
540
|
+
/** Table transforms by header name. */
|
|
541
|
+
transformTable(options) {
|
|
542
|
+
return this.#chain.withOp("sheet.transform_table", compact({
|
|
543
|
+
...targetArgs(options),
|
|
544
|
+
header_row: options.headerRow,
|
|
545
|
+
select: options.select,
|
|
546
|
+
drop: options.drop,
|
|
547
|
+
rename: options.rename
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
/** `slides.*` verbs. */
|
|
552
|
+
var SlidesNamespace = class {
|
|
553
|
+
#chain;
|
|
554
|
+
constructor(chain) {
|
|
555
|
+
this.#chain = chain;
|
|
556
|
+
}
|
|
557
|
+
/** Add a slide. */
|
|
558
|
+
add(options = {}) {
|
|
559
|
+
return this.#chain.withOp("slides.add", compact({
|
|
560
|
+
at: options.at,
|
|
561
|
+
title: options.title,
|
|
562
|
+
layout: options.layout
|
|
563
|
+
}));
|
|
564
|
+
}
|
|
565
|
+
/** Update text on a slide. */
|
|
566
|
+
updateText(text, options = {}) {
|
|
567
|
+
return this.#chain.withOp("slides.update_text", compact({
|
|
568
|
+
text,
|
|
569
|
+
slide: slideArg(options.slide),
|
|
570
|
+
placeholder: options.placeholder
|
|
571
|
+
}));
|
|
572
|
+
}
|
|
573
|
+
/** Update table cells on a slide. */
|
|
574
|
+
updateTable(updates, options = {}) {
|
|
575
|
+
return this.#chain.withOp("slides.update_table", compact({
|
|
576
|
+
updates,
|
|
577
|
+
slide: slideArg(options.slide)
|
|
578
|
+
}));
|
|
579
|
+
}
|
|
580
|
+
/** Replace an image on a slide with another media. */
|
|
581
|
+
updateImage(withMedia, options = {}) {
|
|
582
|
+
return this.#chain.withOp("slides.update_image", compact({
|
|
583
|
+
with: refOf(withMedia),
|
|
584
|
+
slide: slideArg(options.slide),
|
|
585
|
+
placeholder: options.placeholder
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
588
|
+
/** Update chart data on a slide. */
|
|
589
|
+
updateChart(data, options = {}) {
|
|
590
|
+
return this.#chain.withOp("slides.update_chart", compact({
|
|
591
|
+
data,
|
|
592
|
+
slide: slideArg(options.slide)
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
595
|
+
/** Delete slides by 1-based index. */
|
|
596
|
+
delete(slides) {
|
|
597
|
+
return this.#chain.withOp("slides.delete", { slides });
|
|
598
|
+
}
|
|
599
|
+
/** Reorder slides. */
|
|
600
|
+
reorder(order) {
|
|
601
|
+
return this.#chain.withOp("slides.reorder", { order });
|
|
602
|
+
}
|
|
603
|
+
/** Duplicate a slide. */
|
|
604
|
+
duplicate(slide, options = {}) {
|
|
605
|
+
return this.#chain.withOp("slides.duplicate", compact({
|
|
606
|
+
slide,
|
|
607
|
+
at: options.at
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
/** `image.*` verbs (the runtime fuses adjacent image steps into one engine pass). */
|
|
612
|
+
var ImageNamespace = class {
|
|
613
|
+
#chain;
|
|
614
|
+
constructor(chain) {
|
|
615
|
+
this.#chain = chain;
|
|
616
|
+
}
|
|
617
|
+
/** Resize. */
|
|
618
|
+
resize(options) {
|
|
619
|
+
return this.#chain.withOp("image.resize", compact({
|
|
620
|
+
width: options.width,
|
|
621
|
+
height: options.height,
|
|
622
|
+
fit: options.fit
|
|
623
|
+
}));
|
|
624
|
+
}
|
|
625
|
+
/** Re-encode to another format. */
|
|
626
|
+
format(to, options = {}) {
|
|
627
|
+
return this.#chain.withOp("image.format", compact({
|
|
628
|
+
to,
|
|
629
|
+
quality: options.quality,
|
|
630
|
+
strip_metadata: options.stripMetadata
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
/** Rotate clockwise. */
|
|
634
|
+
rotate(deg) {
|
|
635
|
+
return this.#chain.withOp("image.rotate", { deg: String(deg) });
|
|
636
|
+
}
|
|
637
|
+
/** Flip. */
|
|
638
|
+
flip(axis) {
|
|
639
|
+
return this.#chain.withOp("image.flip", { axis });
|
|
640
|
+
}
|
|
641
|
+
/** Strip EXIF/ICC metadata. */
|
|
642
|
+
stripMetadata() {
|
|
643
|
+
return this.#chain.withOp("image.strip_metadata", {});
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
/** `audio.*` verbs. */
|
|
647
|
+
var AudioNamespace = class {
|
|
648
|
+
#chain;
|
|
649
|
+
constructor(chain) {
|
|
650
|
+
this.#chain = chain;
|
|
651
|
+
}
|
|
652
|
+
/** Transcribe speech. Terminal: resolves to text (or srt/vtt/json per `out`). */
|
|
653
|
+
transcribe(options = {}) {
|
|
654
|
+
return this.#chain.withOp("audio.transcribe", compact({
|
|
655
|
+
lang: options.language,
|
|
656
|
+
out: options.out,
|
|
657
|
+
translate: options.translate
|
|
658
|
+
}));
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
var targetArgs = (options) => options.sheet === void 0 ? {} : { sheet: options.sheet };
|
|
662
|
+
var slideArg = (slide) => slide;
|
|
663
|
+
var compact = (args) => {
|
|
664
|
+
const out = {};
|
|
665
|
+
for (const [k, v] of Object.entries(args)) if (v !== void 0) out[k] = v;
|
|
666
|
+
return out;
|
|
667
|
+
};
|
|
668
|
+
//#endregion
|
|
669
|
+
//#region src/batteries/media/runtime.ts
|
|
670
|
+
/**
|
|
671
|
+
* The step runtime: executes a validated {@link MediaPlan} as an `@nhtio/middleware` onion,
|
|
672
|
+
* one stage per step, with consumer interceptors wrapping every step.
|
|
673
|
+
*
|
|
674
|
+
* @remarks
|
|
675
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. A fresh `Runner` is minted per
|
|
676
|
+
* execution (middleware runners are single-use), so the same compiled chain can be awaited
|
|
677
|
+
* repeatedly. Each step stage:
|
|
678
|
+
*
|
|
679
|
+
* 1. runs the consumer's `use` interceptors (the documented seam for DLP/AV scanning, caching
|
|
680
|
+
* via `shortCircuit(bytes)`, byte limits, and telemetry — the battery ships no built-in
|
|
681
|
+
* scanner/cache/limiter: those are policies, this is the seam), then
|
|
682
|
+
* 2. dispatches to the verb's registered {@link StepImpl}, validating the output at the step
|
|
683
|
+
* boundary so a misbehaving engine produces a clear error instead of corrupt bytes.
|
|
684
|
+
*
|
|
685
|
+
* Steps stream bytes in memory; nothing touches disk except inside a binary engine's
|
|
686
|
+
* `ScratchWorkspace`. Adjacent `image.*` steps are fused by the compiler before execution so
|
|
687
|
+
* a resize→format→rotate chain costs a single decode/encode.
|
|
688
|
+
*/
|
|
689
|
+
/** Internal sentinel for {@link StepContext.shortCircuit}. */
|
|
690
|
+
var SHORT_CIRCUIT = Symbol("adk.media.shortCircuit");
|
|
691
|
+
var isShortCircuit = (value) => isObject(value) && value[SHORT_CIRCUIT] === true;
|
|
692
|
+
/**
|
|
693
|
+
* Execute a validated plan. Returns the final step's result; intermediate steps must produce
|
|
694
|
+
* `media` results (the compiler guarantees non-terminal steps are media-shaped).
|
|
695
|
+
*
|
|
696
|
+
* @param plan - The validated plan.
|
|
697
|
+
* @param options - Input payload, step registry, interceptors, engine access.
|
|
698
|
+
* @returns The terminal step's result.
|
|
699
|
+
*/
|
|
700
|
+
var executePlan = async (plan, options) => {
|
|
701
|
+
const stash = /* @__PURE__ */ new Map();
|
|
702
|
+
let payload = options.input;
|
|
703
|
+
let result = {
|
|
704
|
+
kind: "media",
|
|
705
|
+
payload
|
|
706
|
+
};
|
|
707
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
708
|
+
const step = plan.steps[i];
|
|
709
|
+
options.signal?.throwIfAborted();
|
|
710
|
+
const impl = options.steps.get(step.verb);
|
|
711
|
+
if (!impl) throw new E_MEDIA_STEP_UNAVAILABLE([step.verb]);
|
|
712
|
+
const ctx = {
|
|
713
|
+
plan,
|
|
714
|
+
stepIndex: i,
|
|
715
|
+
step,
|
|
716
|
+
payload,
|
|
717
|
+
signal: options.signal,
|
|
718
|
+
stash,
|
|
719
|
+
shortCircuit: (sc) => {
|
|
720
|
+
throw {
|
|
721
|
+
[SHORT_CIRCUIT]: true,
|
|
722
|
+
payload: sc
|
|
723
|
+
};
|
|
724
|
+
},
|
|
725
|
+
engines: options.engines,
|
|
726
|
+
resolveRef: options.resolveRef
|
|
727
|
+
};
|
|
728
|
+
try {
|
|
729
|
+
result = await runStep(ctx, impl, options.use);
|
|
730
|
+
} catch (err) {
|
|
731
|
+
if (isShortCircuit(err)) result = {
|
|
732
|
+
kind: "media",
|
|
733
|
+
payload: err.payload
|
|
734
|
+
};
|
|
735
|
+
else if (isError(err) && err.name.startsWith("E_MEDIA_")) throw err;
|
|
736
|
+
else {
|
|
737
|
+
const detail = isError(err) ? err.message : String(err);
|
|
738
|
+
throw new E_MEDIA_STEP_FAILED([step.verb, detail], { cause: err });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (result.kind === "media") payload = result.payload;
|
|
742
|
+
else if (i < plan.steps.length - 1) if (result.kind === "data" && typeof result.asText === "string" && VERB_INDEX.get(step.verb)?.output === "text") {
|
|
743
|
+
payload = {
|
|
744
|
+
bytes: new TextEncoder().encode(result.asText),
|
|
745
|
+
mimeType: "text/plain",
|
|
746
|
+
filename: materializedName(payload.filename)
|
|
747
|
+
};
|
|
748
|
+
result = {
|
|
749
|
+
kind: "media",
|
|
750
|
+
payload
|
|
751
|
+
};
|
|
752
|
+
} else throw new E_MEDIA_STEP_FAILED([step.verb, "a non-terminal step produced a terminal result; only the last step may yield data or multiple media"]);
|
|
753
|
+
}
|
|
754
|
+
return result;
|
|
755
|
+
};
|
|
756
|
+
/** Run one step: interceptor onion (fresh runner) terminating in the implementation. */
|
|
757
|
+
var runStep = async (ctx, impl, use) => {
|
|
758
|
+
if (use.length === 0) return validated(ctx, await impl(ctx));
|
|
759
|
+
const mw = new Middleware();
|
|
760
|
+
for (const fn of use) mw.add(fn);
|
|
761
|
+
let result;
|
|
762
|
+
let caught;
|
|
763
|
+
await mw.runner().errorHandler(async (error) => {
|
|
764
|
+
caught = error;
|
|
765
|
+
}).finalHandler(async () => {
|
|
766
|
+
result = validated(ctx, await impl(ctx));
|
|
767
|
+
}).run((fn, next) => Promise.resolve(fn(ctx, next)));
|
|
768
|
+
if (caught !== void 0) throw caught;
|
|
769
|
+
if (result === void 0) throw new E_MEDIA_STEP_FAILED([ctx.step.verb, "a step interceptor did not call next() and did not short-circuit"]);
|
|
770
|
+
return result;
|
|
771
|
+
};
|
|
772
|
+
/** Validate a step implementation's output shape at the boundary. */
|
|
773
|
+
var validated = (ctx, result) => {
|
|
774
|
+
const bad = (msg) => {
|
|
775
|
+
throw new E_MEDIA_STEP_FAILED([ctx.step.verb, `implementation returned ${msg}`]);
|
|
776
|
+
};
|
|
777
|
+
const isBytes = (value) => isInstanceOf(value, "Uint8Array", Uint8Array);
|
|
778
|
+
if (result.kind === "media") {
|
|
779
|
+
if (!isBytes(result.payload?.bytes)) bad("a media result without bytes");
|
|
780
|
+
if (typeof result.payload.mimeType !== "string") bad("a media result without a mimeType");
|
|
781
|
+
return result;
|
|
782
|
+
}
|
|
783
|
+
if (result.kind === "media-list") {
|
|
784
|
+
if (!Array.isArray(result.payloads) || result.payloads.some((p) => !isBytes(p.bytes))) bad("a media-list result with invalid payloads");
|
|
785
|
+
return result;
|
|
786
|
+
}
|
|
787
|
+
if (result.kind === "data") return result;
|
|
788
|
+
return bad("an unknown result kind");
|
|
789
|
+
};
|
|
790
|
+
/** Filename for an R-step's materialized text payload. */
|
|
791
|
+
var materializedName = (filename) => {
|
|
792
|
+
const dot = filename.lastIndexOf(".");
|
|
793
|
+
return `${dot > 0 ? filename.slice(0, dot) : filename}.txt`;
|
|
794
|
+
};
|
|
795
|
+
/** Convenience: read a step arg with a typed cast (validation already ran). */
|
|
796
|
+
var argOf = (step, name) => step.args[name];
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region src/batteries/media/formats.ts
|
|
799
|
+
/** Well-known MIME constants used across the battery. */
|
|
800
|
+
var MIME = {
|
|
801
|
+
DOCX: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
802
|
+
XLSX: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
803
|
+
PPTX: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
804
|
+
ODT: "application/vnd.oasis.opendocument.text",
|
|
805
|
+
ODS: "application/vnd.oasis.opendocument.spreadsheet",
|
|
806
|
+
ODP: "application/vnd.oasis.opendocument.presentation",
|
|
807
|
+
PDF: "application/pdf",
|
|
808
|
+
DOC: "application/msword",
|
|
809
|
+
XLS: "application/vnd.ms-excel",
|
|
810
|
+
PPT: "application/vnd.ms-powerpoint",
|
|
811
|
+
PAGES: "application/x-iwork-pages-sffpages",
|
|
812
|
+
NUMBERS: "application/x-iwork-numbers-sffnumbers",
|
|
813
|
+
KEYNOTE: "application/x-iwork-keynote-sffkey",
|
|
814
|
+
TXT: "text/plain",
|
|
815
|
+
MD: "text/markdown",
|
|
816
|
+
CSV: "text/csv",
|
|
817
|
+
HTML: "text/html",
|
|
818
|
+
JSON: "application/json",
|
|
819
|
+
PNG: "image/png",
|
|
820
|
+
JPEG: "image/jpeg",
|
|
821
|
+
WEBP: "image/webp",
|
|
822
|
+
TIFF: "image/tiff",
|
|
823
|
+
AVIF: "image/avif",
|
|
824
|
+
WAV: "audio/wav",
|
|
825
|
+
MP3: "audio/mpeg"
|
|
826
|
+
};
|
|
827
|
+
/** Legacy Office MIME types (extraction only — mutation is rejected). */
|
|
828
|
+
var LEGACY_OFFICE_MIMES = new Set([
|
|
829
|
+
MIME.DOC,
|
|
830
|
+
MIME.XLS,
|
|
831
|
+
MIME.PPT
|
|
832
|
+
]);
|
|
833
|
+
/** Apple iWork MIME types (extraction only — mutation is rejected). */
|
|
834
|
+
var IWORK_MIMES = new Set([
|
|
835
|
+
MIME.PAGES,
|
|
836
|
+
MIME.NUMBERS,
|
|
837
|
+
MIME.KEYNOTE
|
|
838
|
+
]);
|
|
839
|
+
var SPREADSHEET_MIMES = new Set([
|
|
840
|
+
MIME.XLSX,
|
|
841
|
+
MIME.ODS,
|
|
842
|
+
MIME.XLS,
|
|
843
|
+
MIME.CSV
|
|
844
|
+
]);
|
|
845
|
+
var PRESENTATION_MIMES = new Set([
|
|
846
|
+
MIME.PPTX,
|
|
847
|
+
MIME.ODP,
|
|
848
|
+
MIME.PPT,
|
|
849
|
+
MIME.KEYNOTE
|
|
850
|
+
]);
|
|
851
|
+
var DOCUMENT_MIMES = new Set([
|
|
852
|
+
MIME.PDF,
|
|
853
|
+
MIME.DOCX,
|
|
854
|
+
MIME.ODT,
|
|
855
|
+
MIME.DOC,
|
|
856
|
+
MIME.PAGES,
|
|
857
|
+
MIME.TXT,
|
|
858
|
+
MIME.MD,
|
|
859
|
+
MIME.HTML,
|
|
860
|
+
MIME.JSON,
|
|
861
|
+
"application/rtf",
|
|
862
|
+
"text/rtf"
|
|
863
|
+
]);
|
|
864
|
+
/**
|
|
865
|
+
* Classify a MIME type into the verb table's broad format family.
|
|
866
|
+
*
|
|
867
|
+
* @param mimeType - The media MIME type.
|
|
868
|
+
* @returns The family used by verb-applicability checks.
|
|
869
|
+
*/
|
|
870
|
+
var familyOf = (mimeType) => {
|
|
871
|
+
const mime = mimeType.toLowerCase().split(";")[0].trim();
|
|
872
|
+
if (SPREADSHEET_MIMES.has(mime)) return "spreadsheet";
|
|
873
|
+
if (PRESENTATION_MIMES.has(mime)) return "presentation";
|
|
874
|
+
if (DOCUMENT_MIMES.has(mime)) return "document";
|
|
875
|
+
if (mime.startsWith("image/")) return "image";
|
|
876
|
+
if (mime.startsWith("audio/")) return "audio";
|
|
877
|
+
if (mime.startsWith("text/")) return "document";
|
|
878
|
+
return "document";
|
|
879
|
+
};
|
|
880
|
+
/**
|
|
881
|
+
* Reason a mutation verb cannot run against this MIME type, or `undefined` when mutation is
|
|
882
|
+
* allowed. Returns a reason string (not a throw) so callers compose model-actionable failures.
|
|
883
|
+
*
|
|
884
|
+
* @param mimeType - The media MIME type.
|
|
885
|
+
* @returns A human-readable reason, or `undefined` when mutation is supported.
|
|
886
|
+
*/
|
|
887
|
+
var unsupportedForMutationReason = (mimeType) => {
|
|
888
|
+
const mime = mimeType.toLowerCase().split(";")[0].trim();
|
|
889
|
+
if (LEGACY_OFFICE_MIMES.has(mime)) return `legacy Office format (${mime}) supports extraction only — convert it first (e.g. convert to=docx)`;
|
|
890
|
+
if (IWORK_MIMES.has(mime)) return `Apple iWork format (${mime}) supports extraction only`;
|
|
891
|
+
};
|
|
892
|
+
/** A small extension → MIME lookup for filenames produced by transforms. */
|
|
893
|
+
var EXT_TO_MIME = {
|
|
894
|
+
pdf: MIME.PDF,
|
|
895
|
+
docx: MIME.DOCX,
|
|
896
|
+
xlsx: MIME.XLSX,
|
|
897
|
+
pptx: MIME.PPTX,
|
|
898
|
+
odt: MIME.ODT,
|
|
899
|
+
ods: MIME.ODS,
|
|
900
|
+
odp: MIME.ODP,
|
|
901
|
+
doc: MIME.DOC,
|
|
902
|
+
xls: MIME.XLS,
|
|
903
|
+
ppt: MIME.PPT,
|
|
904
|
+
txt: MIME.TXT,
|
|
905
|
+
md: MIME.MD,
|
|
906
|
+
csv: MIME.CSV,
|
|
907
|
+
html: MIME.HTML,
|
|
908
|
+
json: MIME.JSON,
|
|
909
|
+
rtf: "application/rtf",
|
|
910
|
+
png: MIME.PNG,
|
|
911
|
+
jpg: MIME.JPEG,
|
|
912
|
+
jpeg: MIME.JPEG,
|
|
913
|
+
webp: MIME.WEBP,
|
|
914
|
+
tiff: MIME.TIFF,
|
|
915
|
+
avif: MIME.AVIF,
|
|
916
|
+
wav: MIME.WAV,
|
|
917
|
+
mp3: MIME.MP3
|
|
918
|
+
};
|
|
919
|
+
/**
|
|
920
|
+
* Replace (or add) a filename's extension.
|
|
921
|
+
*
|
|
922
|
+
* @param filename - The original filename.
|
|
923
|
+
* @param ext - The new extension, without the dot.
|
|
924
|
+
* @returns The filename with the new extension.
|
|
925
|
+
*/
|
|
926
|
+
var replaceExtension = (filename, ext) => {
|
|
927
|
+
const dot = filename.lastIndexOf(".");
|
|
928
|
+
return `${dot > 0 ? filename.slice(0, dot) : filename}.${ext}`;
|
|
929
|
+
};
|
|
930
|
+
//#endregion
|
|
931
|
+
//#region src/batteries/media/steps/text.ts
|
|
932
|
+
/**
|
|
933
|
+
* Pure text-shaped step implementations: chunking, plain-text extraction, text diff/patch,
|
|
934
|
+
* and the text-mode redact/update_text paths.
|
|
935
|
+
*
|
|
936
|
+
* @remarks
|
|
937
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. The split functions are ported
|
|
938
|
+
* verbatim from the source server's `chunk_text` adapter (its green unit suite ports with
|
|
939
|
+
* them). All implementations here operate on in-memory bytes and run in any environment.
|
|
940
|
+
*/
|
|
941
|
+
/** Split text on double-newline paragraph boundaries, with optional character overlap. */
|
|
942
|
+
var splitParagraph = (text, overlap) => {
|
|
943
|
+
const blocks = text.split(/\n{2,}/).map((b) => b.trim()).filter(Boolean);
|
|
944
|
+
const chunks = [];
|
|
945
|
+
let pos = 0;
|
|
946
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
947
|
+
const block = blocks[i];
|
|
948
|
+
const overlapText = i > 0 && overlap > 0 ? blocks.slice(Math.max(0, i - 1), i).join("\n\n").slice(-overlap) : "";
|
|
949
|
+
const chunkText = overlapText ? `${overlapText}\n\n${block}` : block;
|
|
950
|
+
chunks.push({
|
|
951
|
+
index: i,
|
|
952
|
+
text: chunkText,
|
|
953
|
+
char_start: pos,
|
|
954
|
+
char_end: pos + block.length
|
|
955
|
+
});
|
|
956
|
+
pos += block.length + 2;
|
|
957
|
+
}
|
|
958
|
+
return chunks;
|
|
959
|
+
};
|
|
960
|
+
/** Split text on sentence-ending punctuation, with optional character overlap. */
|
|
961
|
+
var splitSentence = (text, overlap) => {
|
|
962
|
+
const sentences = text.match(/[^.!?]+[.!?](?:\s|$)|[^.!?]+$/g) ?? [text];
|
|
963
|
+
const chunks = [];
|
|
964
|
+
let pos = 0;
|
|
965
|
+
for (let i = 0; i < sentences.length; i++) {
|
|
966
|
+
const sentence = sentences[i].trim();
|
|
967
|
+
if (!sentence) continue;
|
|
968
|
+
const overlapText = i > 0 && overlap > 0 ? sentences.slice(Math.max(0, i - 1), i).join(" ").slice(-overlap) : "";
|
|
969
|
+
const chunkText = overlapText ? `${overlapText} ${sentence}` : sentence;
|
|
970
|
+
chunks.push({
|
|
971
|
+
index: chunks.length,
|
|
972
|
+
text: chunkText,
|
|
973
|
+
char_start: pos,
|
|
974
|
+
char_end: pos + sentence.length
|
|
975
|
+
});
|
|
976
|
+
pos += sentence.length + 1;
|
|
977
|
+
}
|
|
978
|
+
return chunks;
|
|
979
|
+
};
|
|
980
|
+
/** Split text into fixed-size character windows with optional overlap. */
|
|
981
|
+
var splitFixed = (text, chunkSize, overlap) => {
|
|
982
|
+
const chunks = [];
|
|
983
|
+
const step = Math.max(1, chunkSize - overlap);
|
|
984
|
+
let i = 0;
|
|
985
|
+
let idx = 0;
|
|
986
|
+
while (i < text.length) {
|
|
987
|
+
const start = Math.max(0, i - overlap);
|
|
988
|
+
const end = i + chunkSize;
|
|
989
|
+
chunks.push({
|
|
990
|
+
index: idx++,
|
|
991
|
+
text: text.slice(start, end),
|
|
992
|
+
char_start: start,
|
|
993
|
+
char_end: Math.min(end, text.length)
|
|
994
|
+
});
|
|
995
|
+
i += step;
|
|
996
|
+
}
|
|
997
|
+
return chunks;
|
|
998
|
+
};
|
|
999
|
+
var decoder = new TextDecoder("utf-8", { fatal: false });
|
|
1000
|
+
var encoder = new TextEncoder();
|
|
1001
|
+
/** Decode payload bytes as UTF-8 text (UTF-16 BOM aware). */
|
|
1002
|
+
var decodeText = (bytes) => {
|
|
1003
|
+
if (bytes.length >= 2) {
|
|
1004
|
+
if (bytes[0] === 255 && bytes[1] === 254) return new TextDecoder("utf-16le").decode(bytes.subarray(2));
|
|
1005
|
+
if (bytes[0] === 254 && bytes[1] === 255) return new TextDecoder("utf-16be").decode(bytes.subarray(2));
|
|
1006
|
+
}
|
|
1007
|
+
return decoder.decode(bytes).replace(/\r\n?/g, "\n");
|
|
1008
|
+
};
|
|
1009
|
+
var textPayload = (source, text, suffix) => ({
|
|
1010
|
+
bytes: encoder.encode(text),
|
|
1011
|
+
mimeType: MIME.TXT,
|
|
1012
|
+
filename: replaceExtension(source.filename, suffix)
|
|
1013
|
+
});
|
|
1014
|
+
/** `chunk` — split text content into retrieval chunks (data result). */
|
|
1015
|
+
var chunkStep = async (ctx) => {
|
|
1016
|
+
const by = argOf(ctx.step, "by") ?? "paragraph";
|
|
1017
|
+
const size = argOf(ctx.step, "size") ?? 1e3;
|
|
1018
|
+
const overlap = argOf(ctx.step, "overlap") ?? 0;
|
|
1019
|
+
const text = decodeText(ctx.payload.bytes);
|
|
1020
|
+
const chunks = by === "sentence" ? splitSentence(text, overlap) : by === "fixed" ? splitFixed(text, size, overlap) : splitParagraph(text, overlap);
|
|
1021
|
+
return {
|
|
1022
|
+
kind: "data",
|
|
1023
|
+
data: chunks,
|
|
1024
|
+
asText: JSON.stringify(chunks)
|
|
1025
|
+
};
|
|
1026
|
+
};
|
|
1027
|
+
/**
|
|
1028
|
+
* `extract.text` — Phase 0 covers the native-text path (txt/md/csv/json/html). Document
|
|
1029
|
+
* formats (pdf/docx/…) and OCR routes are added by later phases; until then they fail with a
|
|
1030
|
+
* clear reason.
|
|
1031
|
+
*/
|
|
1032
|
+
var extractTextStep = async (ctx) => {
|
|
1033
|
+
const family = familyOf(ctx.payload.mimeType);
|
|
1034
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
1035
|
+
if (!(mime.startsWith("text/") || mime === MIME.JSON || mime === MIME.MD || mime === MIME.CSV)) throw new E_MEDIA_STEP_FAILED(["extract.text", `extraction for ${family} media (${mime}) is not yet implemented in this build`]);
|
|
1036
|
+
const text = decodeText(ctx.payload.bytes);
|
|
1037
|
+
return {
|
|
1038
|
+
kind: "data",
|
|
1039
|
+
data: text,
|
|
1040
|
+
asText: text
|
|
1041
|
+
};
|
|
1042
|
+
};
|
|
1043
|
+
/** `extract.metadata` — Phase 0: size/MIME/filename basics (format-aware fields come later). */
|
|
1044
|
+
var extractMetadataStep = async (ctx) => {
|
|
1045
|
+
const meta = {
|
|
1046
|
+
filename: ctx.payload.filename,
|
|
1047
|
+
mime_type: ctx.payload.mimeType,
|
|
1048
|
+
size_bytes: ctx.payload.bytes.byteLength,
|
|
1049
|
+
family: familyOf(ctx.payload.mimeType)
|
|
1050
|
+
};
|
|
1051
|
+
return {
|
|
1052
|
+
kind: "data",
|
|
1053
|
+
data: meta,
|
|
1054
|
+
asText: JSON.stringify(meta)
|
|
1055
|
+
};
|
|
1056
|
+
};
|
|
1057
|
+
var toPattern = (value) => {
|
|
1058
|
+
if (typeof value === "string") return value;
|
|
1059
|
+
const ref = value;
|
|
1060
|
+
return new RegExp(ref.source, ref.flags.includes("g") ? ref.flags : `${ref.flags}g`);
|
|
1061
|
+
};
|
|
1062
|
+
var applyRedaction = (text, patterns, replacement) => {
|
|
1063
|
+
let out = text;
|
|
1064
|
+
for (const p of patterns) {
|
|
1065
|
+
const pattern = toPattern(p);
|
|
1066
|
+
out = typeof pattern === "string" ? out.split(pattern).join(replacement) : out.replace(pattern, replacement);
|
|
1067
|
+
}
|
|
1068
|
+
return out;
|
|
1069
|
+
};
|
|
1070
|
+
/** `redact` — Phase 0: the text-media path. Document-format in-place redaction lands later. */
|
|
1071
|
+
var redactStep = async (ctx) => {
|
|
1072
|
+
if (!ctx.payload.mimeType.toLowerCase().startsWith("text/")) throw new E_MEDIA_STEP_FAILED(["redact", `redaction for ${ctx.payload.mimeType} is not yet implemented in this build`]);
|
|
1073
|
+
const raw = ctx.step.args.match;
|
|
1074
|
+
const patterns = Array.isArray(raw) ? raw : [raw];
|
|
1075
|
+
const replacement = argOf(ctx.step, "replace") ?? "█";
|
|
1076
|
+
const text = applyRedaction(decodeText(ctx.payload.bytes), patterns, replacement);
|
|
1077
|
+
return {
|
|
1078
|
+
kind: "media",
|
|
1079
|
+
payload: textPayload(ctx.payload, text, "txt")
|
|
1080
|
+
};
|
|
1081
|
+
};
|
|
1082
|
+
/** `update_text` — Phase 0: the text-media path. */
|
|
1083
|
+
var updateTextStep = async (ctx) => {
|
|
1084
|
+
if (!ctx.payload.mimeType.toLowerCase().startsWith("text/")) throw new E_MEDIA_STEP_FAILED(["update_text", `text update for ${ctx.payload.mimeType} is not yet implemented in this build`]);
|
|
1085
|
+
const anchor = argOf(ctx.step, "anchor");
|
|
1086
|
+
const replace = argOf(ctx.step, "replace") ?? "";
|
|
1087
|
+
const text = decodeText(ctx.payload.bytes);
|
|
1088
|
+
if (!text.includes(anchor)) throw new E_MEDIA_STEP_FAILED(["update_text", `anchor text not found: "${anchor.slice(0, 80)}"`]);
|
|
1089
|
+
const updated = text.replace(anchor, replace);
|
|
1090
|
+
return {
|
|
1091
|
+
kind: "media",
|
|
1092
|
+
payload: {
|
|
1093
|
+
...ctx.payload,
|
|
1094
|
+
bytes: encoder.encode(updated)
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
};
|
|
1098
|
+
/** Build a unified diff between two texts using the `diff` peer (lazy import). */
|
|
1099
|
+
var unifiedDiff = async (aName, bName, a, b) => {
|
|
1100
|
+
return (await import("diff")).createTwoFilesPatch(aName, bName, a, b);
|
|
1101
|
+
};
|
|
1102
|
+
/** `diff` — compare against another media (text-comparable formats in Phase 0). */
|
|
1103
|
+
var diffStep = async (ctx) => {
|
|
1104
|
+
const ref = ctx.step.args.with;
|
|
1105
|
+
if (ref.kind !== "id") throw new E_MEDIA_STEP_FAILED(["diff", "builder refs are not yet supported here"]);
|
|
1106
|
+
const other = await ctx.resolveRef(ref.id);
|
|
1107
|
+
const a = decodeText(ctx.payload.bytes);
|
|
1108
|
+
const b = decodeText(other.bytes);
|
|
1109
|
+
const patch = await unifiedDiff(ctx.payload.filename, other.filename, a, b);
|
|
1110
|
+
return {
|
|
1111
|
+
kind: "data",
|
|
1112
|
+
data: {
|
|
1113
|
+
patch,
|
|
1114
|
+
changes: (await import("diff")).diffLines(a, b).map((part) => ({
|
|
1115
|
+
added: part.added === true,
|
|
1116
|
+
removed: part.removed === true,
|
|
1117
|
+
count: part.count ?? 0,
|
|
1118
|
+
value: part.value
|
|
1119
|
+
}))
|
|
1120
|
+
},
|
|
1121
|
+
asText: patch
|
|
1122
|
+
};
|
|
1123
|
+
};
|
|
1124
|
+
/** `apply_patch` — apply a unified diff to the media text. */
|
|
1125
|
+
var applyPatchStep = async (ctx) => {
|
|
1126
|
+
const patch = argOf(ctx.step, "patch");
|
|
1127
|
+
const text = decodeText(ctx.payload.bytes);
|
|
1128
|
+
const result = (await import("diff")).applyPatch(text, patch);
|
|
1129
|
+
if (result === false) throw new E_MEDIA_STEP_FAILED(["apply_patch", "the patch does not apply to this media content (context mismatch)"]);
|
|
1130
|
+
return {
|
|
1131
|
+
kind: "media",
|
|
1132
|
+
payload: {
|
|
1133
|
+
...ctx.payload,
|
|
1134
|
+
bytes: encoder.encode(result)
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
};
|
|
1138
|
+
//#endregion
|
|
1139
|
+
//#region src/batteries/media/steps/doc.ts
|
|
1140
|
+
/**
|
|
1141
|
+
* Doc-domain step extensions: format-preserving text mutation for DOCX/PPTX, asset
|
|
1142
|
+
* extraction from OOXML/ODF archives and PDFs, and format conversion via the convert engine.
|
|
1143
|
+
*
|
|
1144
|
+
* @remarks
|
|
1145
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. Ported from the source server's
|
|
1146
|
+
* doc adapters; the format-dispatch contract follows the server's green e2e suite: DOCX and
|
|
1147
|
+
* PPTX redact/sanitize/normalize/update_text mutate text in place inside the container
|
|
1148
|
+
* (preserving the source format); text-like inputs mutate as text; PDF redact falls back to
|
|
1149
|
+
* text extraction (the server's PDF "visual annotation" path is content-stream-preserving and
|
|
1150
|
+
* out of scope here — the failure message says exactly that).
|
|
1151
|
+
*/
|
|
1152
|
+
var zipPromise$2;
|
|
1153
|
+
var jszip$2 = () => {
|
|
1154
|
+
zipPromise$2 ??= import("jszip").then((m) => "default" in m ? m.default : m);
|
|
1155
|
+
return zipPromise$2;
|
|
1156
|
+
};
|
|
1157
|
+
var fail$4 = (verb, message) => {
|
|
1158
|
+
throw new E_MEDIA_STEP_FAILED([verb, message]);
|
|
1159
|
+
};
|
|
1160
|
+
var escapeXml$1 = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1161
|
+
var unescapeXml = (value) => value.replace(/'/g, "'").replace(/"/g, "\"").replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
|
1162
|
+
/**
|
|
1163
|
+
* Replace matches inside text nodes of OOXML, aggregating runs per paragraph so patterns
|
|
1164
|
+
* spanning split runs still match (Word splits text across `w:t` runs on formatting changes).
|
|
1165
|
+
*/
|
|
1166
|
+
var replaceInOoxml = (xml, paragraphTag, textTag, pattern, replacement) => {
|
|
1167
|
+
let totalMatches = 0;
|
|
1168
|
+
const pRegex = new RegExp(`<${paragraphTag}\\b[^>]*>[\\s\\S]*?</${paragraphTag}>`, "g");
|
|
1169
|
+
return {
|
|
1170
|
+
xml: xml.replace(pRegex, (pXml) => {
|
|
1171
|
+
const tElements = [];
|
|
1172
|
+
const tRegex = new RegExp(`<${textTag}(?:\\s[^>]*)?>([^<]*)</${textTag}>`, "g");
|
|
1173
|
+
let tMatch;
|
|
1174
|
+
while (tMatch = tRegex.exec(pXml)) tElements.push({
|
|
1175
|
+
start: tMatch.index,
|
|
1176
|
+
end: tMatch.index + tMatch[0].length,
|
|
1177
|
+
content: unescapeXml(tMatch[1])
|
|
1178
|
+
});
|
|
1179
|
+
if (tElements.length === 0) return pXml;
|
|
1180
|
+
const aggregated = tElements.map((t) => t.content).join("");
|
|
1181
|
+
const p = new RegExp(pattern.source, pattern.flags);
|
|
1182
|
+
const replaced = aggregated.replace(p, replacement);
|
|
1183
|
+
if (replaced === aggregated) return pXml;
|
|
1184
|
+
const counter = new RegExp(pattern.source, pattern.flags);
|
|
1185
|
+
while (counter.exec(aggregated)) {
|
|
1186
|
+
totalMatches += 1;
|
|
1187
|
+
if (!counter.global) break;
|
|
1188
|
+
}
|
|
1189
|
+
let out = pXml;
|
|
1190
|
+
for (let i = tElements.length - 1; i >= 0; i--) {
|
|
1191
|
+
const el = tElements[i];
|
|
1192
|
+
const space = textTag === "w:t" && i === 0 ? " xml:space=\"preserve\"" : "";
|
|
1193
|
+
const content = i === 0 ? escapeXml$1(replaced) : "";
|
|
1194
|
+
out = out.slice(0, el.start) + `<${textTag}${space}>${content}</${textTag}>` + out.slice(el.end);
|
|
1195
|
+
}
|
|
1196
|
+
return out;
|
|
1197
|
+
}),
|
|
1198
|
+
matchCount: totalMatches
|
|
1199
|
+
};
|
|
1200
|
+
};
|
|
1201
|
+
/** Map text-node mutation over every relevant part of a DOCX or PPTX archive. */
|
|
1202
|
+
var mutateOoxmlText = async (ctx, verb, mutateXml) => {
|
|
1203
|
+
const kind = ctx.payload.mimeType.toLowerCase().split(";")[0].trim() === MIME.DOCX ? "docx" : "pptx";
|
|
1204
|
+
const JSZip = await jszip$2();
|
|
1205
|
+
let zip;
|
|
1206
|
+
try {
|
|
1207
|
+
zip = await JSZip.loadAsync(ctx.payload.bytes);
|
|
1208
|
+
} catch (err) {
|
|
1209
|
+
fail$4(verb, `could not open the document container: ${isError(err) ? err.message : String(err)}`);
|
|
1210
|
+
throw err;
|
|
1211
|
+
}
|
|
1212
|
+
const parts = kind === "docx" ? Object.keys(zip.files).filter((n) => /^word\/(document|header\d*|footer\d*)\.xml$/.test(n)) : Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n));
|
|
1213
|
+
if (parts.length === 0) fail$4(verb, "the container has no text parts to mutate (corrupt file?)");
|
|
1214
|
+
for (const part of parts) {
|
|
1215
|
+
const xml = await zip.file(part).async("text");
|
|
1216
|
+
const next = mutateXml(xml, kind);
|
|
1217
|
+
if (next !== xml) zip.file(part, next);
|
|
1218
|
+
}
|
|
1219
|
+
const bytes = await zip.generateAsync({
|
|
1220
|
+
type: "uint8array",
|
|
1221
|
+
compression: "DEFLATE"
|
|
1222
|
+
});
|
|
1223
|
+
return {
|
|
1224
|
+
kind: "media",
|
|
1225
|
+
payload: {
|
|
1226
|
+
...ctx.payload,
|
|
1227
|
+
bytes
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
};
|
|
1231
|
+
var toGlobalRegex = (value) => {
|
|
1232
|
+
if (typeof value === "string") {
|
|
1233
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1234
|
+
return new RegExp(escaped, "g");
|
|
1235
|
+
}
|
|
1236
|
+
const ref = value;
|
|
1237
|
+
return new RegExp(ref.source, ref.flags.includes("g") ? ref.flags : `${ref.flags}g`);
|
|
1238
|
+
};
|
|
1239
|
+
/** `redact` — format dispatch: text media → text path; DOCX/PPTX → in-place container edit. */
|
|
1240
|
+
var docRedactStep = async (ctx) => {
|
|
1241
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
1242
|
+
if (mime.startsWith("text/")) return redactStep(ctx);
|
|
1243
|
+
if (mime === MIME.DOCX || mime === MIME.PPTX) {
|
|
1244
|
+
const raw = ctx.step.args.match;
|
|
1245
|
+
const patterns = Array.isArray(raw) ? raw : [raw];
|
|
1246
|
+
const replacement = argOf(ctx.step, "replace") ?? "█";
|
|
1247
|
+
return mutateOoxmlText(ctx, "redact", (xml, kind) => {
|
|
1248
|
+
let out = xml;
|
|
1249
|
+
for (const p of patterns) out = replaceInOoxml(out, kind === "docx" ? "w:p" : "a:p", kind === "docx" ? "w:t" : "a:t", toGlobalRegex(p), replacement).xml;
|
|
1250
|
+
return out;
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
fail$4("redact", `redaction for ${mime} is not supported in this build (text, DOCX, and PPTX are). For PDF, extract the text first: extract text | redact …`);
|
|
1254
|
+
throw new Error("unreachable");
|
|
1255
|
+
};
|
|
1256
|
+
var BLOCKED_CONTROL = (code) => code >= 0 && code <= 8 || code === 11 || code === 12 || code >= 14 && code <= 31 || code === 127;
|
|
1257
|
+
var stripControlChars = (text) => {
|
|
1258
|
+
let out = "";
|
|
1259
|
+
for (const ch of text) {
|
|
1260
|
+
if (BLOCKED_CONTROL(ch.charCodeAt(0))) continue;
|
|
1261
|
+
out += ch;
|
|
1262
|
+
}
|
|
1263
|
+
return out;
|
|
1264
|
+
};
|
|
1265
|
+
var sanitizeTextNodes = (xml, textTag) => xml.replace(new RegExp(`<${textTag}(?:\\s[^>]*)?>([^<]*)</${textTag}>`, "g"), (full, content) => {
|
|
1266
|
+
const cleaned = stripControlChars(unescapeXml(content));
|
|
1267
|
+
if (cleaned === unescapeXml(content)) return full;
|
|
1268
|
+
return full.replace(content, escapeXml$1(cleaned));
|
|
1269
|
+
});
|
|
1270
|
+
/** `sanitize` — strip blocked control characters from text nodes / text media. */
|
|
1271
|
+
var docSanitizeStep = async (ctx) => {
|
|
1272
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
1273
|
+
if (mime.startsWith("text/")) {
|
|
1274
|
+
const cleaned = stripControlChars(new TextDecoder().decode(ctx.payload.bytes));
|
|
1275
|
+
return {
|
|
1276
|
+
kind: "media",
|
|
1277
|
+
payload: {
|
|
1278
|
+
...ctx.payload,
|
|
1279
|
+
bytes: new TextEncoder().encode(cleaned)
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
if (mime === MIME.DOCX || mime === MIME.PPTX) return mutateOoxmlText(ctx, "sanitize", (xml, kind) => sanitizeTextNodes(xml, kind === "docx" ? "w:t" : "a:t"));
|
|
1284
|
+
fail$4("sanitize", `sanitize for ${mime} is not supported in this build (text, DOCX, PPTX are)`);
|
|
1285
|
+
throw new Error("unreachable");
|
|
1286
|
+
};
|
|
1287
|
+
var normalizeText = (text) => text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/[\t ]+$/gm, "");
|
|
1288
|
+
/** `normalize` — CRLF→LF + trailing-whitespace removal in text nodes / text media. */
|
|
1289
|
+
var docNormalizeStep = async (ctx) => {
|
|
1290
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
1291
|
+
if (mime.startsWith("text/")) {
|
|
1292
|
+
const text = new TextDecoder().decode(ctx.payload.bytes);
|
|
1293
|
+
return {
|
|
1294
|
+
kind: "media",
|
|
1295
|
+
payload: {
|
|
1296
|
+
...ctx.payload,
|
|
1297
|
+
bytes: new TextEncoder().encode(normalizeText(text))
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
if (mime === MIME.DOCX || mime === MIME.PPTX) return mutateOoxmlText(ctx, "normalize", (xml, kind) => {
|
|
1302
|
+
const tag = kind === "docx" ? "w:t" : "a:t";
|
|
1303
|
+
return xml.replace(new RegExp(`<${tag}(?:\\s[^>]*)?>([^<]*)</${tag}>`, "g"), (full, content) => {
|
|
1304
|
+
const normalized = normalizeText(unescapeXml(content));
|
|
1305
|
+
if (normalized === unescapeXml(content)) return full;
|
|
1306
|
+
return full.replace(content, escapeXml$1(normalized));
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
fail$4("normalize", `normalize for ${mime} is not supported in this build (text, DOCX, PPTX are)`);
|
|
1310
|
+
throw new Error("unreachable");
|
|
1311
|
+
};
|
|
1312
|
+
/** `update_text` — anchor replacement; DOCX/PPTX in-place, TARGET_NOT_FOUND when absent. */
|
|
1313
|
+
var docUpdateTextStep = async (ctx) => {
|
|
1314
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
1315
|
+
if (mime.startsWith("text/")) return updateTextStep(ctx);
|
|
1316
|
+
if (mime === MIME.DOCX || mime === MIME.PPTX) {
|
|
1317
|
+
const anchor = argOf(ctx.step, "anchor");
|
|
1318
|
+
const replace = argOf(ctx.step, "replace") ?? "";
|
|
1319
|
+
let found = 0;
|
|
1320
|
+
const result = await mutateOoxmlText(ctx, "update_text", (xml, kind) => {
|
|
1321
|
+
if (found > 0) return xml;
|
|
1322
|
+
const escaped = anchor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1323
|
+
const { xml: next, matchCount } = replaceInOoxml(xml, kind === "docx" ? "w:p" : "a:p", kind === "docx" ? "w:t" : "a:t", new RegExp(escaped), replace);
|
|
1324
|
+
found += matchCount;
|
|
1325
|
+
return next;
|
|
1326
|
+
});
|
|
1327
|
+
if (found === 0) fail$4("update_text", `anchor text not found: "${anchor.slice(0, 80)}"`);
|
|
1328
|
+
return result;
|
|
1329
|
+
}
|
|
1330
|
+
fail$4("update_text", `text update for ${mime} is not supported (text, DOCX, PPTX are; PDF text is not reliably editable)`);
|
|
1331
|
+
throw new Error("unreachable");
|
|
1332
|
+
};
|
|
1333
|
+
var ASSET_PATTERNS = {
|
|
1334
|
+
image: /\.(png|jpe?g|gif|bmp|tiff?|emf|wmf|svg|webp)$/i,
|
|
1335
|
+
font: /\.(ttf|otf|woff2?|odttf)$/i,
|
|
1336
|
+
attachment: /\.(bin|ole|docx?|xlsx?|pptx?|pdf|txt|csv)$/i
|
|
1337
|
+
};
|
|
1338
|
+
var EXT_MIME = {
|
|
1339
|
+
png: "image/png",
|
|
1340
|
+
jpg: "image/jpeg",
|
|
1341
|
+
jpeg: "image/jpeg",
|
|
1342
|
+
gif: "image/gif",
|
|
1343
|
+
bmp: "image/bmp",
|
|
1344
|
+
tif: "image/tiff",
|
|
1345
|
+
tiff: "image/tiff",
|
|
1346
|
+
svg: "image/svg+xml",
|
|
1347
|
+
webp: "image/webp",
|
|
1348
|
+
emf: "image/emf",
|
|
1349
|
+
wmf: "image/wmf",
|
|
1350
|
+
ttf: "font/ttf",
|
|
1351
|
+
otf: "font/otf",
|
|
1352
|
+
woff: "font/woff",
|
|
1353
|
+
woff2: "font/woff2"
|
|
1354
|
+
};
|
|
1355
|
+
/**
|
|
1356
|
+
* `extract.assets` — enumerate embedded assets. OOXML/ODF archives are walked natively
|
|
1357
|
+
* (cross-env); PDF inputs use the pdfImages engine.
|
|
1358
|
+
*/
|
|
1359
|
+
var extractAssetsStep = async (ctx) => {
|
|
1360
|
+
const verb = "extract assets";
|
|
1361
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
1362
|
+
const requested = ctx.step.args.types ?? ["all"];
|
|
1363
|
+
const wantAll = requested.includes("all");
|
|
1364
|
+
const want = (kind) => wantAll || requested.includes(kind);
|
|
1365
|
+
if (mime === MIME.PDF) {
|
|
1366
|
+
if (!want("image")) fail$4(verb, "PDF asset extraction currently supports images (types=image or all)");
|
|
1367
|
+
if (!ctx.engines.hasConvert(mime, "images")) fail$4(verb, "PDF asset extraction requires an engine that converts application/pdf to \"images\", and none is configured. Do not retry this verb on PDFs in this deployment.");
|
|
1368
|
+
const format = argOf(ctx.step, "format");
|
|
1369
|
+
const result = await ctx.engines.convert({
|
|
1370
|
+
bytes: ctx.payload.bytes,
|
|
1371
|
+
mimeType: mime,
|
|
1372
|
+
filename: ctx.payload.filename,
|
|
1373
|
+
to: "images",
|
|
1374
|
+
options: format ? { format } : void 0,
|
|
1375
|
+
signal: ctx.signal
|
|
1376
|
+
});
|
|
1377
|
+
if (result.outputs.length === 0) fail$4(verb, "no embedded images were found in the PDF");
|
|
1378
|
+
const wantedMime = format ? EXT_MIME[format] ?? `image/${format}` : void 0;
|
|
1379
|
+
const payloads = [];
|
|
1380
|
+
for (const [i, img] of result.outputs.entries()) {
|
|
1381
|
+
let bytes = img.bytes;
|
|
1382
|
+
let mimeType = img.mimeType;
|
|
1383
|
+
if (wantedMime && mimeType.toLowerCase() !== wantedMime) {
|
|
1384
|
+
const reencoded = await ctx.engines.mutate({
|
|
1385
|
+
bytes,
|
|
1386
|
+
mimeType,
|
|
1387
|
+
format: { to: format },
|
|
1388
|
+
signal: ctx.signal
|
|
1389
|
+
});
|
|
1390
|
+
bytes = reencoded.bytes;
|
|
1391
|
+
mimeType = reencoded.mimeType;
|
|
1392
|
+
}
|
|
1393
|
+
payloads.push({
|
|
1394
|
+
bytes,
|
|
1395
|
+
mimeType,
|
|
1396
|
+
filename: `asset-${i + 1}.${mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "bin"}`
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
return {
|
|
1400
|
+
kind: "media-list",
|
|
1401
|
+
payloads
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
if (![
|
|
1405
|
+
MIME.DOCX,
|
|
1406
|
+
MIME.XLSX,
|
|
1407
|
+
MIME.PPTX,
|
|
1408
|
+
MIME.ODT,
|
|
1409
|
+
MIME.ODS,
|
|
1410
|
+
MIME.ODP
|
|
1411
|
+
].includes(mime)) fail$4(verb, `asset extraction for ${mime} is not supported (OOXML/ODF archives and PDF are)`);
|
|
1412
|
+
const zip = await (await jszip$2()).loadAsync(ctx.payload.bytes);
|
|
1413
|
+
const payloads = [];
|
|
1414
|
+
for (const [name, entry] of Object.entries(zip.files)) {
|
|
1415
|
+
if (entry.dir) continue;
|
|
1416
|
+
const basename = name.split("/").pop() ?? name;
|
|
1417
|
+
const matchKind = Object.entries(ASSET_PATTERNS).find(([, re]) => re.test(basename))?.[0];
|
|
1418
|
+
if (!matchKind || !want(matchKind)) continue;
|
|
1419
|
+
if (!/(^|\/)(media|embeddings|fonts|Pictures|ObjectReplacements)\//i.test(name)) continue;
|
|
1420
|
+
const ext = basename.split(".").pop()?.toLowerCase() ?? "bin";
|
|
1421
|
+
payloads.push({
|
|
1422
|
+
bytes: await entry.async("uint8array"),
|
|
1423
|
+
mimeType: EXT_MIME[ext] ?? "application/octet-stream",
|
|
1424
|
+
filename: basename
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
if (payloads.length === 0) fail$4(verb, "no embedded assets matched the requested types");
|
|
1428
|
+
return {
|
|
1429
|
+
kind: "media-list",
|
|
1430
|
+
payloads
|
|
1431
|
+
};
|
|
1432
|
+
};
|
|
1433
|
+
/** `convert` — dispatch through the engine registry (direct edge or computed path). */
|
|
1434
|
+
var convertStep = async (ctx) => {
|
|
1435
|
+
const verb = "convert";
|
|
1436
|
+
const to = argOf(ctx.step, "to");
|
|
1437
|
+
const supported = ctx.engines.convertTargets(ctx.payload.mimeType);
|
|
1438
|
+
if (!supported.includes(to)) fail$4(verb, `no configured engine (or computed path) can produce "${to}" from ${ctx.payload.mimeType}; reachable targets: ${supported.join(", ") || "(none)"}`);
|
|
1439
|
+
const output = (await ctx.engines.convert({
|
|
1440
|
+
bytes: ctx.payload.bytes,
|
|
1441
|
+
mimeType: ctx.payload.mimeType,
|
|
1442
|
+
filename: ctx.payload.filename,
|
|
1443
|
+
to,
|
|
1444
|
+
signal: ctx.signal
|
|
1445
|
+
})).outputs[0];
|
|
1446
|
+
if (!output) fail$4(verb, "the conversion produced no output");
|
|
1447
|
+
const dot = ctx.payload.filename.lastIndexOf(".");
|
|
1448
|
+
const base = dot > 0 ? ctx.payload.filename.slice(0, dot) : ctx.payload.filename;
|
|
1449
|
+
return {
|
|
1450
|
+
kind: "media",
|
|
1451
|
+
payload: {
|
|
1452
|
+
bytes: output.bytes,
|
|
1453
|
+
mimeType: output.mimeType,
|
|
1454
|
+
filename: `${base}.${to}`
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
};
|
|
1458
|
+
/** The doc step registry fragment (overrides the Phase 0 text-only redact/update_text). */
|
|
1459
|
+
var DOC_STEPS = [
|
|
1460
|
+
["redact", docRedactStep],
|
|
1461
|
+
["sanitize", docSanitizeStep],
|
|
1462
|
+
["normalize", docNormalizeStep],
|
|
1463
|
+
["update_text", docUpdateTextStep],
|
|
1464
|
+
["extract.assets", extractAssetsStep],
|
|
1465
|
+
["convert", convertStep]
|
|
1466
|
+
];
|
|
1467
|
+
//#endregion
|
|
1468
|
+
//#region src/batteries/media/steps/sheet.ts
|
|
1469
|
+
/**
|
|
1470
|
+
* `sheet.*` step implementations: ExcelJS workbook mutations on in-memory bytes.
|
|
1471
|
+
*
|
|
1472
|
+
* @remarks
|
|
1473
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. Ported from the source server's
|
|
1474
|
+
* sheet adapters (its green e2e suite informs the spec coverage). All ten mutations operate on
|
|
1475
|
+
* xlsx natively via ExcelJS (optional peer, lazily imported); ODS and legacy-xls inputs are
|
|
1476
|
+
* normalized to xlsx first through the configured `sheetNormalize` engine, degrading to a
|
|
1477
|
+
* readable failure when none is configured. Output is always xlsx.
|
|
1478
|
+
*/
|
|
1479
|
+
var excelPromise;
|
|
1480
|
+
var excel = () => {
|
|
1481
|
+
excelPromise ??= import("exceljs").then((m) => "default" in m ? m.default : m);
|
|
1482
|
+
return excelPromise;
|
|
1483
|
+
};
|
|
1484
|
+
var fail$3 = (verb, message) => {
|
|
1485
|
+
throw new E_MEDIA_STEP_FAILED([verb, message]);
|
|
1486
|
+
};
|
|
1487
|
+
/** Acquire xlsx bytes for the step, converting ODS/xls to xlsx via the engine registry. */
|
|
1488
|
+
var acquireXlsx = async (ctx, verb) => {
|
|
1489
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
1490
|
+
if (mime === MIME.XLSX) return ctx.payload.bytes;
|
|
1491
|
+
const mutationBlock = unsupportedForMutationReason(mime);
|
|
1492
|
+
if (mime === MIME.ODS || mime === MIME.XLS) {
|
|
1493
|
+
if (!ctx.engines.hasConvert(mime, "xlsx")) fail$3(verb, `this input is ${mime} — an engine that converts it to xlsx is required first, and none is configured. Do not retry this verb on this media in this deployment.`);
|
|
1494
|
+
const output = (await ctx.engines.convert({
|
|
1495
|
+
bytes: ctx.payload.bytes,
|
|
1496
|
+
mimeType: mime,
|
|
1497
|
+
filename: ctx.payload.filename,
|
|
1498
|
+
to: "xlsx",
|
|
1499
|
+
signal: ctx.signal
|
|
1500
|
+
})).outputs[0];
|
|
1501
|
+
if (!output) fail$3(verb, "normalizing the workbook to xlsx produced no output");
|
|
1502
|
+
return output.bytes;
|
|
1503
|
+
}
|
|
1504
|
+
if (mutationBlock) fail$3(verb, mutationBlock);
|
|
1505
|
+
fail$3(verb, `sheet operations expect a spreadsheet; the media is ${mime}`);
|
|
1506
|
+
throw new Error("unreachable");
|
|
1507
|
+
};
|
|
1508
|
+
/** The shared open → mutate → write-back lifecycle every sheet verb uses. */
|
|
1509
|
+
var withWorkbook = async (ctx, verb, mutate) => {
|
|
1510
|
+
const xlsxBytes = await acquireXlsx(ctx, verb);
|
|
1511
|
+
const wb = new (await (excel())).Workbook();
|
|
1512
|
+
try {
|
|
1513
|
+
await wb.xlsx.load(xlsxBytes.buffer.slice(xlsxBytes.byteOffset, xlsxBytes.byteOffset + xlsxBytes.byteLength));
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
fail$3(verb, `could not open the spreadsheet: ${isError(err) ? err.message : String(err)}`);
|
|
1516
|
+
}
|
|
1517
|
+
await mutate(wb);
|
|
1518
|
+
return {
|
|
1519
|
+
kind: "media",
|
|
1520
|
+
payload: {
|
|
1521
|
+
bytes: new Uint8Array(await wb.xlsx.writeBuffer()),
|
|
1522
|
+
mimeType: MIME.XLSX,
|
|
1523
|
+
filename: replaceExtension(ctx.payload.filename, "xlsx")
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
};
|
|
1527
|
+
/** Resolve the target worksheet from the frozen `sheet=` rule (bare number=index, string=name). */
|
|
1528
|
+
var resolveSheet = (verb, wb, sheetArg) => {
|
|
1529
|
+
if (sheetArg === void 0) {
|
|
1530
|
+
const first = wb.worksheets[0];
|
|
1531
|
+
if (!first) fail$3(verb, "the workbook has no worksheets");
|
|
1532
|
+
return first;
|
|
1533
|
+
}
|
|
1534
|
+
if (typeof sheetArg === "number") {
|
|
1535
|
+
const byIndex = wb.worksheets[sheetArg - 1];
|
|
1536
|
+
if (byIndex) return byIndex;
|
|
1537
|
+
if (wb.getWorksheet(String(sheetArg))) fail$3(verb, `no sheet at index ${sheetArg}, but a sheet NAMED "${sheetArg}" exists — quote it: sheet="${sheetArg}"`);
|
|
1538
|
+
fail$3(verb, `sheet index ${sheetArg} is out of range (1-based; the workbook has ${wb.worksheets.length})`);
|
|
1539
|
+
}
|
|
1540
|
+
const ws = wb.getWorksheet(sheetArg);
|
|
1541
|
+
if (!ws) fail$3(verb, `no sheet named "${sheetArg}". Sheets: ${wb.worksheets.map((w) => `"${w.name}"`).join(", ")}`);
|
|
1542
|
+
return ws;
|
|
1543
|
+
};
|
|
1544
|
+
var columnLetterToNumber = (col) => {
|
|
1545
|
+
let n = 0;
|
|
1546
|
+
for (const ch of col.toUpperCase()) n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
1547
|
+
return n;
|
|
1548
|
+
};
|
|
1549
|
+
var asCellValue = (verb, value) => {
|
|
1550
|
+
if (value === null) return null;
|
|
1551
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
1552
|
+
fail$3(verb, `cell values must be string, number, boolean, or null; got ${typeof value}`);
|
|
1553
|
+
throw new Error("unreachable");
|
|
1554
|
+
};
|
|
1555
|
+
/** `sheet.add_rows` — insert rows before/after an index, or append. */
|
|
1556
|
+
var sheetAddRowsStep = async (ctx) => {
|
|
1557
|
+
const verb = "sheet add_rows";
|
|
1558
|
+
const rows = ctx.step.args.rows;
|
|
1559
|
+
if (!Array.isArray(rows) || rows.length === 0 || !rows.every(Array.isArray)) fail$3(verb, `rows must be a non-empty JSON array of arrays, e.g. rows='[["a",1]]'`);
|
|
1560
|
+
const before = argOf(ctx.step, "before");
|
|
1561
|
+
const after = argOf(ctx.step, "after");
|
|
1562
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1563
|
+
const ws = resolveSheet(verb, wb, argOf(ctx.step, "sheet"));
|
|
1564
|
+
const insertAt = before !== void 0 ? before : after !== void 0 ? after + 1 : ws.rowCount + 1;
|
|
1565
|
+
const cellRows = rows.map((r) => r.map((v) => asCellValue(verb, v)));
|
|
1566
|
+
ws.spliceRows(insertAt, 0, ...cellRows);
|
|
1567
|
+
return { added: cellRows.length };
|
|
1568
|
+
});
|
|
1569
|
+
};
|
|
1570
|
+
/** `sheet.add_columns` — insert columns with headers or full descriptors. */
|
|
1571
|
+
var sheetAddColumnsStep = async (ctx) => {
|
|
1572
|
+
const verb = "sheet add_columns";
|
|
1573
|
+
const headers = ctx.step.args.headers;
|
|
1574
|
+
const columns = ctx.step.args.columns ?? headers?.map((header) => ({ header }));
|
|
1575
|
+
if (!Array.isArray(columns) || columns.length === 0) fail$3(verb, `provide headers=a,b or columns='[{"header":"X","values":[1]}]'`);
|
|
1576
|
+
const before = argOf(ctx.step, "before");
|
|
1577
|
+
const after = argOf(ctx.step, "after");
|
|
1578
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1579
|
+
const ws = resolveSheet(verb, wb, argOf(ctx.step, "sheet"));
|
|
1580
|
+
const insertAt = before !== void 0 ? before : after !== void 0 ? after + 1 : ws.columnCount + 1;
|
|
1581
|
+
for (const [i, col] of columns.entries()) {
|
|
1582
|
+
if (typeof col?.header !== "string") fail$3(verb, "every column needs a string header");
|
|
1583
|
+
ws.spliceColumns(insertAt + i, 0, [col.header, ...(col.values ?? []).map((v) => asCellValue(verb, v))]);
|
|
1584
|
+
}
|
|
1585
|
+
return { added: columns.length };
|
|
1586
|
+
});
|
|
1587
|
+
};
|
|
1588
|
+
/** `sheet.update_cells` — set cells by A1 address or row/col. Leading `=` writes a formula. */
|
|
1589
|
+
var sheetUpdateCellsStep = async (ctx) => {
|
|
1590
|
+
const verb = "sheet update_cells";
|
|
1591
|
+
const updates = ctx.step.args.updates;
|
|
1592
|
+
if (!Array.isArray(updates) || updates.length === 0) fail$3(verb, `updates must be a non-empty JSON array, e.g. updates='[{"address":"B2","value":3}]'`);
|
|
1593
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1594
|
+
const ws = resolveSheet(verb, wb, argOf(ctx.step, "sheet"));
|
|
1595
|
+
let modified = 0;
|
|
1596
|
+
for (const raw of updates) {
|
|
1597
|
+
const address = raw.address;
|
|
1598
|
+
const row = raw.row;
|
|
1599
|
+
const col = raw.col;
|
|
1600
|
+
let cell;
|
|
1601
|
+
if (address) cell = ws.getCell(address);
|
|
1602
|
+
else if (row !== void 0 && col !== void 0) cell = typeof col === "string" ? ws.getCell(row, columnLetterToNumber(col)) : ws.getCell(row, col);
|
|
1603
|
+
else {
|
|
1604
|
+
fail$3(verb, "each update needs address, or both row and col");
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
const value = raw.value;
|
|
1608
|
+
if (typeof value === "string" && value.startsWith("=")) cell.value = { formula: value.slice(1) };
|
|
1609
|
+
else cell.value = asCellValue(verb, value);
|
|
1610
|
+
modified += 1;
|
|
1611
|
+
}
|
|
1612
|
+
return { modified };
|
|
1613
|
+
});
|
|
1614
|
+
};
|
|
1615
|
+
/** `sheet.delete_rows` — delete 1-based rows (bottom-up so indices stay valid). */
|
|
1616
|
+
var sheetDeleteRowsStep = async (ctx) => {
|
|
1617
|
+
const verb = "sheet delete_rows";
|
|
1618
|
+
const rows = ctx.step.args.rows;
|
|
1619
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1620
|
+
const ws = resolveSheet(verb, wb, argOf(ctx.step, "sheet"));
|
|
1621
|
+
for (const r of [...rows].sort((a, b) => b - a)) ws.spliceRows(r, 1);
|
|
1622
|
+
return { removed: rows.length };
|
|
1623
|
+
});
|
|
1624
|
+
};
|
|
1625
|
+
/** `sheet.delete_columns` — delete 1-based columns. */
|
|
1626
|
+
var sheetDeleteColumnsStep = async (ctx) => {
|
|
1627
|
+
const verb = "sheet delete_columns";
|
|
1628
|
+
const columns = ctx.step.args.columns;
|
|
1629
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1630
|
+
const ws = resolveSheet(verb, wb, argOf(ctx.step, "sheet"));
|
|
1631
|
+
for (const c of [...columns].sort((a, b) => b - a)) ws.spliceColumns(c, 1);
|
|
1632
|
+
return { removed: columns.length };
|
|
1633
|
+
});
|
|
1634
|
+
};
|
|
1635
|
+
/** `sheet.rename_sheet` — rename by NAME (the server contract; index targeting is rejected). */
|
|
1636
|
+
var sheetRenameSheetStep = async (ctx) => {
|
|
1637
|
+
const verb = "sheet rename_sheet";
|
|
1638
|
+
const sheet = argOf(ctx.step, "sheet");
|
|
1639
|
+
const to = argOf(ctx.step, "to");
|
|
1640
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1641
|
+
const ws = wb.getWorksheet(sheet);
|
|
1642
|
+
if (!ws) fail$3(verb, `rename targets a sheet NAME; no sheet named "${sheet}". Sheets: ${wb.worksheets.map((w) => `"${w.name}"`).join(", ")}`);
|
|
1643
|
+
ws.name = to;
|
|
1644
|
+
return { modified: 1 };
|
|
1645
|
+
});
|
|
1646
|
+
};
|
|
1647
|
+
/** `sheet.add_sheet` — add a worksheet, optionally at a 1-based position. */
|
|
1648
|
+
var sheetAddSheetStep = async (ctx) => {
|
|
1649
|
+
const verb = "sheet add_sheet";
|
|
1650
|
+
const name = argOf(ctx.step, "name");
|
|
1651
|
+
const at = argOf(ctx.step, "at");
|
|
1652
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1653
|
+
if (wb.getWorksheet(name)) fail$3(verb, `a sheet named "${name}" already exists`);
|
|
1654
|
+
const ws = wb.addWorksheet(name);
|
|
1655
|
+
if (at !== void 0) {
|
|
1656
|
+
const all = wb.worksheets;
|
|
1657
|
+
const wsIdx = all.indexOf(ws);
|
|
1658
|
+
if (wsIdx !== at - 1) all.forEach((w, i) => {
|
|
1659
|
+
w.orderNo = w === ws ? at : i + 1 >= at && i < wsIdx ? i + 2 : i + 1;
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
return { added: 1 };
|
|
1663
|
+
});
|
|
1664
|
+
};
|
|
1665
|
+
/** `sheet.remove_sheet` — remove by NAME (the server contract). */
|
|
1666
|
+
var sheetRemoveSheetStep = async (ctx) => {
|
|
1667
|
+
const verb = "sheet remove_sheet";
|
|
1668
|
+
const sheet = argOf(ctx.step, "sheet");
|
|
1669
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1670
|
+
const ws = wb.getWorksheet(sheet);
|
|
1671
|
+
if (!ws) fail$3(verb, `remove targets a sheet NAME; no sheet named "${sheet}". Sheets: ${wb.worksheets.map((w) => `"${w.name}"`).join(", ")}`);
|
|
1672
|
+
wb.removeWorksheet(ws.id);
|
|
1673
|
+
return { removed: 1 };
|
|
1674
|
+
});
|
|
1675
|
+
};
|
|
1676
|
+
/** `sheet.reorder_sheets` — every sheet exactly once, by name or 1-based index. */
|
|
1677
|
+
var sheetReorderSheetsStep = async (ctx) => {
|
|
1678
|
+
const verb = "sheet reorder_sheets";
|
|
1679
|
+
const order = ctx.step.args.order;
|
|
1680
|
+
if (!Array.isArray(order) || order.length === 0) fail$3(verb, `order must be a JSON array of names/indices, e.g. order='["Summary",2]'`);
|
|
1681
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1682
|
+
const refs = order;
|
|
1683
|
+
if (refs.length !== wb.worksheets.length) fail$3(verb, `order must include every worksheet exactly once (the workbook has ${wb.worksheets.length})`);
|
|
1684
|
+
const reordered = [];
|
|
1685
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1686
|
+
for (const ref of refs) {
|
|
1687
|
+
const ws = typeof ref === "number" ? wb.worksheets[ref - 1] : wb.getWorksheet(ref);
|
|
1688
|
+
if (!ws) fail$3(verb, `sheet not found: ${String(ref)}`);
|
|
1689
|
+
if (seen.has(ws.id)) fail$3(verb, `duplicate sheet reference: ${String(ref)}`);
|
|
1690
|
+
seen.add(ws.id);
|
|
1691
|
+
reordered.push(ws);
|
|
1692
|
+
}
|
|
1693
|
+
reordered.forEach((ws, i) => {
|
|
1694
|
+
ws.orderNo = i + 1;
|
|
1695
|
+
});
|
|
1696
|
+
return { modified: reordered.length };
|
|
1697
|
+
});
|
|
1698
|
+
};
|
|
1699
|
+
/** `sheet.transform_table` — rename/select/drop columns by header name. */
|
|
1700
|
+
var sheetTransformTableStep = async (ctx) => {
|
|
1701
|
+
const verb = "sheet transform_table";
|
|
1702
|
+
const headerRow = argOf(ctx.step, "header_row") ?? 1;
|
|
1703
|
+
const rename = ctx.step.args.rename;
|
|
1704
|
+
const select = ctx.step.args.select;
|
|
1705
|
+
const drop = ctx.step.args.drop;
|
|
1706
|
+
return withWorkbook(ctx, verb, (wb) => {
|
|
1707
|
+
const ws = resolveSheet(verb, wb, argOf(ctx.step, "sheet"));
|
|
1708
|
+
let modified = 0;
|
|
1709
|
+
const headerMap = /* @__PURE__ */ new Map();
|
|
1710
|
+
ws.getRow(headerRow).eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
|
1711
|
+
if (typeof cell.value === "string") headerMap.set(cell.value, colNumber);
|
|
1712
|
+
});
|
|
1713
|
+
if (rename) for (const { from, to } of rename) {
|
|
1714
|
+
const colNum = headerMap.get(from);
|
|
1715
|
+
if (colNum !== void 0) {
|
|
1716
|
+
ws.getRow(headerRow).getCell(colNum).value = to;
|
|
1717
|
+
headerMap.delete(from);
|
|
1718
|
+
headerMap.set(to, colNum);
|
|
1719
|
+
modified += 1;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
if (drop) {
|
|
1723
|
+
const cols = drop.map((name) => headerMap.get(name)).filter((n) => n !== void 0).sort((a, b) => b - a);
|
|
1724
|
+
for (const col of cols) {
|
|
1725
|
+
ws.spliceColumns(col, 1);
|
|
1726
|
+
modified += 1;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
if (select) {
|
|
1730
|
+
const keep = new Set(select);
|
|
1731
|
+
const cols = [];
|
|
1732
|
+
ws.getRow(headerRow).eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
|
1733
|
+
if (typeof cell.value === "string" && !keep.has(cell.value)) cols.push(colNumber);
|
|
1734
|
+
});
|
|
1735
|
+
cols.sort((a, b) => b - a);
|
|
1736
|
+
for (const col of cols) {
|
|
1737
|
+
ws.spliceColumns(col, 1);
|
|
1738
|
+
modified += 1;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
return { modified };
|
|
1742
|
+
});
|
|
1743
|
+
};
|
|
1744
|
+
/** The sheet step registry fragment, keyed by canonical verb id. */
|
|
1745
|
+
var SHEET_STEPS = [
|
|
1746
|
+
["sheet.add_rows", sheetAddRowsStep],
|
|
1747
|
+
["sheet.add_columns", sheetAddColumnsStep],
|
|
1748
|
+
["sheet.update_cells", sheetUpdateCellsStep],
|
|
1749
|
+
["sheet.delete_rows", sheetDeleteRowsStep],
|
|
1750
|
+
["sheet.delete_columns", sheetDeleteColumnsStep],
|
|
1751
|
+
["sheet.rename_sheet", sheetRenameSheetStep],
|
|
1752
|
+
["sheet.add_sheet", sheetAddSheetStep],
|
|
1753
|
+
["sheet.remove_sheet", sheetRemoveSheetStep],
|
|
1754
|
+
["sheet.reorder_sheets", sheetReorderSheetsStep],
|
|
1755
|
+
["sheet.transform_table", sheetTransformTableStep]
|
|
1756
|
+
];
|
|
1757
|
+
//#endregion
|
|
1758
|
+
//#region src/batteries/media/steps/slides.ts
|
|
1759
|
+
/**
|
|
1760
|
+
* `slides.*` step implementations: native PPTX container mutation via JSZip + targeted XML
|
|
1761
|
+
* edits, on in-memory bytes.
|
|
1762
|
+
*
|
|
1763
|
+
* @remarks
|
|
1764
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. Ported from the source server's
|
|
1765
|
+
* slides adapters (regex-over-zip XML surgery — no XML parser dependency). PPTX is mutated
|
|
1766
|
+
* natively; ODP inputs are normalized to PPTX through the configured `convert` engine first,
|
|
1767
|
+
* degrading to a readable failure when none is configured. All implementations are
|
|
1768
|
+
* cross-environment (jszip operates on Uint8Array).
|
|
1769
|
+
*/
|
|
1770
|
+
var zipPromise$1;
|
|
1771
|
+
var jszip$1 = () => {
|
|
1772
|
+
zipPromise$1 ??= import("jszip").then((m) => "default" in m ? m.default : m);
|
|
1773
|
+
return zipPromise$1;
|
|
1774
|
+
};
|
|
1775
|
+
var SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
|
|
1776
|
+
var fail$2 = (verb, message) => {
|
|
1777
|
+
throw new E_MEDIA_STEP_FAILED([verb, message]);
|
|
1778
|
+
};
|
|
1779
|
+
var escapeXml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1780
|
+
var escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1781
|
+
/** Pure posix-style path resolution for zip entries (no node:path). */
|
|
1782
|
+
var resolveZipPath = (basePart, relTarget) => {
|
|
1783
|
+
const parts = [...basePart.split("/").slice(0, -1), ...relTarget.split("/")];
|
|
1784
|
+
const out = [];
|
|
1785
|
+
for (const part of parts) {
|
|
1786
|
+
if (part === "" || part === ".") continue;
|
|
1787
|
+
if (part === "..") out.pop();
|
|
1788
|
+
else out.push(part);
|
|
1789
|
+
}
|
|
1790
|
+
return out.join("/");
|
|
1791
|
+
};
|
|
1792
|
+
var readZipText = async (verb, zip, path) => {
|
|
1793
|
+
const file = zip.file(path);
|
|
1794
|
+
if (!file) fail$2(verb, `the presentation is missing an expected part: ${path}`);
|
|
1795
|
+
return file.async("text");
|
|
1796
|
+
};
|
|
1797
|
+
var parseRelationshipTarget = (relsXml, relId) => {
|
|
1798
|
+
const re = new RegExp(`<Relationship\\b[^>]*Id="${escapeRegex(relId)}"[^>]*Target="([^"]+)"[^>]*>`, "i");
|
|
1799
|
+
return relsXml.match(re)?.[1] ?? null;
|
|
1800
|
+
};
|
|
1801
|
+
var parsePresentation = async (verb, zip) => {
|
|
1802
|
+
const presentationXml = await readZipText(verb, zip, "ppt/presentation.xml");
|
|
1803
|
+
const relsXml = await readZipText(verb, zip, "ppt/_rels/presentation.xml.rels");
|
|
1804
|
+
const sldIdListMatch = presentationXml.match(/<p:sldIdLst>([\s\S]*?)<\/p:sldIdLst>/);
|
|
1805
|
+
if (!sldIdListMatch) fail$2(verb, "the presentation has no slide list (corrupt PPTX?)");
|
|
1806
|
+
const sldIdTags = sldIdListMatch[1].match(/<p:sldId\b[^>]*\/>/g) ?? [];
|
|
1807
|
+
const entries = [];
|
|
1808
|
+
for (const tag of sldIdTags) {
|
|
1809
|
+
const rid = tag.match(/r:id="([^"]+)"/)?.[1];
|
|
1810
|
+
const idText = tag.match(/\bid="(\d+)"/)?.[1];
|
|
1811
|
+
if (!rid || !idText) continue;
|
|
1812
|
+
const target = parseRelationshipTarget(relsXml, rid);
|
|
1813
|
+
if (!target) continue;
|
|
1814
|
+
const slidePath = resolveZipPath("ppt/presentation.xml", target);
|
|
1815
|
+
let title;
|
|
1816
|
+
const slideFile = zip.file(slidePath);
|
|
1817
|
+
if (slideFile) title = (await slideFile.async("text")).match(/<p:cSld\b[^>]*\bname="([^"]*)"/)?.[1];
|
|
1818
|
+
entries.push({
|
|
1819
|
+
rid,
|
|
1820
|
+
slideId: Number(idText),
|
|
1821
|
+
sldIdTag: tag,
|
|
1822
|
+
target,
|
|
1823
|
+
title
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
return {
|
|
1827
|
+
presentationXml,
|
|
1828
|
+
relsXml,
|
|
1829
|
+
entries
|
|
1830
|
+
};
|
|
1831
|
+
};
|
|
1832
|
+
var replaceSlideIdList = (presentationXml, tags) => presentationXml.replace(/<p:sldIdLst>[\s\S]*?<\/p:sldIdLst>/, `<p:sldIdLst>${tags.join("")}</p:sldIdLst>`);
|
|
1833
|
+
/** Resolve a slide by the frozen `slide=` rule: digits = 1-based index, otherwise title. */
|
|
1834
|
+
var resolveSlide = (verb, entries, slideArg) => {
|
|
1835
|
+
if (slideArg === void 0) {
|
|
1836
|
+
const first = entries[0];
|
|
1837
|
+
if (!first) fail$2(verb, "the presentation has no slides");
|
|
1838
|
+
return first;
|
|
1839
|
+
}
|
|
1840
|
+
if (typeof slideArg === "number") {
|
|
1841
|
+
const found = entries[slideArg - 1];
|
|
1842
|
+
if (!found) fail$2(verb, `slide index ${slideArg} is out of range (1-based; ${entries.length} slides)`);
|
|
1843
|
+
return found;
|
|
1844
|
+
}
|
|
1845
|
+
const byTitle = entries.find((e) => e.title === slideArg);
|
|
1846
|
+
if (!byTitle) fail$2(verb, `no slide titled "${slideArg}"`);
|
|
1847
|
+
return byTitle;
|
|
1848
|
+
};
|
|
1849
|
+
var getMaxRidNumber = (relsXml) => {
|
|
1850
|
+
let max = 0;
|
|
1851
|
+
for (const match of relsXml.matchAll(/\bId="rId(\d+)"/g)) max = Math.max(max, Number(match[1]));
|
|
1852
|
+
return max;
|
|
1853
|
+
};
|
|
1854
|
+
var getMaxSlideNumber = (zip) => {
|
|
1855
|
+
let max = 0;
|
|
1856
|
+
for (const name of Object.keys(zip.files)) {
|
|
1857
|
+
const match = name.match(/^ppt\/slides\/slide(\d+)\.xml$/);
|
|
1858
|
+
if (match) max = Math.max(max, Number(match[1]));
|
|
1859
|
+
}
|
|
1860
|
+
return max;
|
|
1861
|
+
};
|
|
1862
|
+
var getMaxSlideId = (entries) => entries.reduce((max, e) => Math.max(max, e.slideId), 255);
|
|
1863
|
+
var ensureSlideContentType = (contentTypesXml, slideNumber) => {
|
|
1864
|
+
const partName = `/ppt/slides/slide${slideNumber}.xml`;
|
|
1865
|
+
if (contentTypesXml.includes(`PartName="${partName}"`)) return contentTypesXml;
|
|
1866
|
+
const override = `<Override PartName="${partName}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`;
|
|
1867
|
+
return contentTypesXml.replace("</Types>", `${override}</Types>`);
|
|
1868
|
+
};
|
|
1869
|
+
var setSlideTitle = (slideXml, title) => {
|
|
1870
|
+
if (/<p:cSld\b[^>]*\bname="[^"]*"/.test(slideXml)) return slideXml.replace(/(<p:cSld\b[^>]*\bname=")[^"]*(")/, `$1${escapeXml(title)}$2`);
|
|
1871
|
+
return slideXml;
|
|
1872
|
+
};
|
|
1873
|
+
/** Acquire pptx bytes, converting ODP to PPTX via the engine registry. */
|
|
1874
|
+
var acquirePptx = async (ctx, verb) => {
|
|
1875
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
1876
|
+
if (mime === MIME.PPTX) return ctx.payload.bytes;
|
|
1877
|
+
if (mime === MIME.ODP) {
|
|
1878
|
+
if (!ctx.engines.hasConvert(mime, "pptx")) fail$2(verb, "this input is ODP — an engine that converts it to PPTX is required first, and none is configured. Do not retry this verb on this media in this deployment.");
|
|
1879
|
+
const output = (await ctx.engines.convert({
|
|
1880
|
+
bytes: ctx.payload.bytes,
|
|
1881
|
+
mimeType: mime,
|
|
1882
|
+
filename: ctx.payload.filename,
|
|
1883
|
+
to: "pptx",
|
|
1884
|
+
signal: ctx.signal
|
|
1885
|
+
})).outputs[0];
|
|
1886
|
+
if (!output) fail$2(verb, "normalizing the presentation to PPTX produced no output");
|
|
1887
|
+
return output.bytes;
|
|
1888
|
+
}
|
|
1889
|
+
const reason = unsupportedForMutationReason(mime);
|
|
1890
|
+
if (reason) fail$2(verb, reason);
|
|
1891
|
+
fail$2(verb, `slides operations expect a presentation; the media is ${mime}`);
|
|
1892
|
+
throw new Error("unreachable");
|
|
1893
|
+
};
|
|
1894
|
+
var withPresentation = async (ctx, verb, mutate) => {
|
|
1895
|
+
const bytes = await acquirePptx(ctx, verb);
|
|
1896
|
+
const JSZip = await jszip$1();
|
|
1897
|
+
let zip;
|
|
1898
|
+
try {
|
|
1899
|
+
zip = await JSZip.loadAsync(bytes);
|
|
1900
|
+
} catch (err) {
|
|
1901
|
+
fail$2(verb, `could not open the presentation: ${isError(err) ? err.message : String(err)}`);
|
|
1902
|
+
throw err;
|
|
1903
|
+
}
|
|
1904
|
+
const parsed = await parsePresentation(verb, zip);
|
|
1905
|
+
await mutate(zip, parsed);
|
|
1906
|
+
return {
|
|
1907
|
+
kind: "media",
|
|
1908
|
+
payload: {
|
|
1909
|
+
bytes: await zip.generateAsync({
|
|
1910
|
+
type: "uint8array",
|
|
1911
|
+
compression: "DEFLATE"
|
|
1912
|
+
}),
|
|
1913
|
+
mimeType: MIME.PPTX,
|
|
1914
|
+
filename: replaceExtension(ctx.payload.filename, "pptx")
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
};
|
|
1918
|
+
/** `slides.add` — clone the last slide as a template, optionally retitle/position it. */
|
|
1919
|
+
var slidesAddStep = async (ctx) => {
|
|
1920
|
+
const verb = "slides add";
|
|
1921
|
+
const at = argOf(ctx.step, "at");
|
|
1922
|
+
const title = argOf(ctx.step, "title");
|
|
1923
|
+
return withPresentation(ctx, verb, async (zip, parsed) => {
|
|
1924
|
+
if (parsed.entries.length === 0) fail$2(verb, "the presentation has no slides to clone from");
|
|
1925
|
+
const template = parsed.entries[parsed.entries.length - 1];
|
|
1926
|
+
const templatePath = resolveZipPath("ppt/presentation.xml", template.target);
|
|
1927
|
+
const templateXml = await readZipText(verb, zip, templatePath);
|
|
1928
|
+
const newSlideNumber = getMaxSlideNumber(zip) + 1;
|
|
1929
|
+
const newSlidePath = `ppt/slides/slide${newSlideNumber}.xml`;
|
|
1930
|
+
zip.file(newSlidePath, title ? setSlideTitle(templateXml, title) : templateXml);
|
|
1931
|
+
const templateNum = templatePath.match(/slide(\d+)\.xml$/)?.[1];
|
|
1932
|
+
const templateRels = templateNum ? zip.file(`ppt/slides/_rels/slide${templateNum}.xml.rels`) : null;
|
|
1933
|
+
if (templateRels) {
|
|
1934
|
+
const relsXml = await templateRels.async("text");
|
|
1935
|
+
zip.file(`ppt/slides/_rels/slide${newSlideNumber}.xml.rels`, relsXml.replace(/<Relationship\b[^>]*Type="[^"]*notesSlide"[^>]*\/>/g, ""));
|
|
1936
|
+
}
|
|
1937
|
+
const newRid = `rId${getMaxRidNumber(parsed.relsXml) + 1}`;
|
|
1938
|
+
const newSldTag = `<p:sldId id="${getMaxSlideId(parsed.entries) + 1}" r:id="${newRid}"/>`;
|
|
1939
|
+
const relNode = `<Relationship Id="${newRid}" Type="${SLIDE_REL_TYPE}" Target="slides/slide${newSlideNumber}.xml"/>`;
|
|
1940
|
+
zip.file("ppt/_rels/presentation.xml.rels", parsed.relsXml.replace("</Relationships>", `${relNode}</Relationships>`));
|
|
1941
|
+
const insertAt = at !== void 0 ? Math.min(Math.max(1, at), parsed.entries.length + 1) : parsed.entries.length + 1;
|
|
1942
|
+
const tags = parsed.entries.map((e) => e.sldIdTag);
|
|
1943
|
+
tags.splice(insertAt - 1, 0, newSldTag);
|
|
1944
|
+
zip.file("ppt/presentation.xml", replaceSlideIdList(parsed.presentationXml, tags));
|
|
1945
|
+
const contentTypes = await readZipText(verb, zip, "[Content_Types].xml");
|
|
1946
|
+
zip.file("[Content_Types].xml", ensureSlideContentType(contentTypes, newSlideNumber));
|
|
1947
|
+
});
|
|
1948
|
+
};
|
|
1949
|
+
/** `slides.update_text` — replace text in the first node or a named placeholder shape. */
|
|
1950
|
+
var slidesUpdateTextStep = async (ctx) => {
|
|
1951
|
+
const verb = "slides update_text";
|
|
1952
|
+
const text = argOf(ctx.step, "text");
|
|
1953
|
+
const placeholder = argOf(ctx.step, "placeholder");
|
|
1954
|
+
return withPresentation(ctx, verb, async (zip, parsed) => {
|
|
1955
|
+
const slidePath = resolveZipPath("ppt/presentation.xml", resolveSlide(verb, parsed.entries, argOf(ctx.step, "slide")).target);
|
|
1956
|
+
const slideXml = await readZipText(verb, zip, slidePath);
|
|
1957
|
+
let updated = slideXml;
|
|
1958
|
+
let changed = false;
|
|
1959
|
+
if (placeholder) {
|
|
1960
|
+
let placeholderFound = false;
|
|
1961
|
+
updated = slideXml.replace(/<p:sp\b[\s\S]*?<\/p:sp>/g, (shapeXml) => {
|
|
1962
|
+
if (!new RegExp(`<p:cNvPr\\b[^>]*\\bname="${escapeRegex(placeholder)}"[^>]*>`, "i").test(shapeXml)) return shapeXml;
|
|
1963
|
+
placeholderFound = true;
|
|
1964
|
+
return shapeXml.replace(/<a:t>[\s\S]*?<\/a:t>/, () => {
|
|
1965
|
+
changed = true;
|
|
1966
|
+
return `<a:t>${escapeXml(text)}</a:t>`;
|
|
1967
|
+
});
|
|
1968
|
+
});
|
|
1969
|
+
if (!placeholderFound) fail$2(verb, `no placeholder named "${placeholder}" on the selected slide`);
|
|
1970
|
+
} else updated = slideXml.replace(/<a:t>[\s\S]*?<\/a:t>/, () => {
|
|
1971
|
+
changed = true;
|
|
1972
|
+
return `<a:t>${escapeXml(text)}</a:t>`;
|
|
1973
|
+
});
|
|
1974
|
+
if (!changed) fail$2(verb, "no text node found on the selected slide");
|
|
1975
|
+
zip.file(slidePath, updated);
|
|
1976
|
+
});
|
|
1977
|
+
};
|
|
1978
|
+
/** `slides.update_table` — set table cell text by 1-based row/col. */
|
|
1979
|
+
var slidesUpdateTableStep = async (ctx) => {
|
|
1980
|
+
const verb = "slides update_table";
|
|
1981
|
+
const updates = ctx.step.args.updates;
|
|
1982
|
+
if (!Array.isArray(updates) || updates.length === 0) fail$2(verb, `updates must be a JSON array: updates='[{"row":1,"col":2,"value":"x"}]'`);
|
|
1983
|
+
return withPresentation(ctx, verb, async (zip, parsed) => {
|
|
1984
|
+
const slidePath = resolveZipPath("ppt/presentation.xml", resolveSlide(verb, parsed.entries, argOf(ctx.step, "slide")).target);
|
|
1985
|
+
let slideXml = await readZipText(verb, zip, slidePath);
|
|
1986
|
+
const tableMatch = slideXml.match(/<a:tbl>[\s\S]*?<\/a:tbl>/);
|
|
1987
|
+
if (!tableMatch) fail$2(verb, "no table found on the selected slide");
|
|
1988
|
+
let tableXml = tableMatch[0];
|
|
1989
|
+
const rowMatches = tableXml.match(/<a:tr\b[\s\S]*?<\/a:tr>/g) ?? [];
|
|
1990
|
+
for (const raw of updates) {
|
|
1991
|
+
const row = Number(raw.row);
|
|
1992
|
+
const col = Number(raw.col);
|
|
1993
|
+
const value = String(raw.value ?? "");
|
|
1994
|
+
const rowIndex = row - 1;
|
|
1995
|
+
const colIndex = col - 1;
|
|
1996
|
+
if (rowIndex < 0 || colIndex < 0 || rowIndex >= rowMatches.length) fail$2(verb, `table cell out of range: r${row}c${col} (1-based)`);
|
|
1997
|
+
const rowXml = rowMatches[rowIndex];
|
|
1998
|
+
const cellMatches = rowXml.match(/<a:tc\b[\s\S]*?<\/a:tc>/g) ?? [];
|
|
1999
|
+
if (colIndex >= cellMatches.length) fail$2(verb, `table cell out of range: r${row}c${col} (1-based)`);
|
|
2000
|
+
const cellXml = cellMatches[colIndex];
|
|
2001
|
+
if (!/<a:t>/.test(cellXml)) fail$2(verb, `cell r${row}c${col} has no text node`);
|
|
2002
|
+
cellMatches[colIndex] = cellXml.replace(/<a:t>[\s\S]*?<\/a:t>/, `<a:t>${escapeXml(value)}</a:t>`);
|
|
2003
|
+
rowMatches[rowIndex] = rowXml.replace(/<a:tc\b[\s\S]*?<\/a:tc>/g, () => cellMatches.shift());
|
|
2004
|
+
}
|
|
2005
|
+
tableXml = tableXml.replace(/<a:tr\b[\s\S]*?<\/a:tr>/g, () => rowMatches.shift());
|
|
2006
|
+
slideXml = slideXml.replace(/<a:tbl>[\s\S]*?<\/a:tbl>/, tableXml);
|
|
2007
|
+
zip.file(slidePath, slideXml);
|
|
2008
|
+
});
|
|
2009
|
+
};
|
|
2010
|
+
/** `slides.update_image` — replace an image part's bytes with another media (`with=@id`). */
|
|
2011
|
+
var slidesUpdateImageStep = async (ctx) => {
|
|
2012
|
+
const verb = "slides update_image";
|
|
2013
|
+
const ref = ctx.step.args.with;
|
|
2014
|
+
const placeholder = argOf(ctx.step, "placeholder");
|
|
2015
|
+
if (ref.kind !== "id") fail$2(verb, "builder refs are not supported here; use an @id ref");
|
|
2016
|
+
const replacement = await ctx.resolveRef(ref.id);
|
|
2017
|
+
return withPresentation(ctx, verb, async (zip, parsed) => {
|
|
2018
|
+
const slidePath = resolveZipPath("ppt/presentation.xml", resolveSlide(verb, parsed.entries, argOf(ctx.step, "slide")).target);
|
|
2019
|
+
const slideXml = await readZipText(verb, zip, slidePath);
|
|
2020
|
+
const slideNum = slidePath.match(/slide(\d+)\.xml$/)?.[1];
|
|
2021
|
+
if (!slideNum) fail$2(verb, "unable to resolve the slide number (corrupt PPTX?)");
|
|
2022
|
+
const relsPath = `ppt/slides/_rels/slide${slideNum}.xml.rels`;
|
|
2023
|
+
const relsXml = await readZipText(verb, zip, relsPath);
|
|
2024
|
+
let imagePartPath = null;
|
|
2025
|
+
if (placeholder) {
|
|
2026
|
+
for (const picXml of slideXml.match(/<p:pic\b[\s\S]*?<\/p:pic>/g) ?? []) {
|
|
2027
|
+
if (!new RegExp(`<p:cNvPr\\b[^>]*\\bname="${escapeRegex(placeholder)}"[^>]*>`, "i").test(picXml)) continue;
|
|
2028
|
+
const rid = picXml.match(/<a:blip\b[^>]*\br:embed="([^"]+)"/i)?.[1];
|
|
2029
|
+
if (rid) {
|
|
2030
|
+
const relTarget = parseRelationshipTarget(relsXml, rid);
|
|
2031
|
+
if (relTarget) imagePartPath = resolveZipPath(relsPath, relTarget);
|
|
2032
|
+
}
|
|
2033
|
+
break;
|
|
2034
|
+
}
|
|
2035
|
+
if (!imagePartPath) fail$2(verb, `no image placeholder named "${placeholder}" on the selected slide`);
|
|
2036
|
+
} else {
|
|
2037
|
+
const fallback = relsXml.match(/<Relationship\b[^>]*Type="[^"]*\/image"[^>]*Target="([^"]+)"[^>]*>/i);
|
|
2038
|
+
if (fallback) imagePartPath = resolveZipPath(relsPath, fallback[1]);
|
|
2039
|
+
}
|
|
2040
|
+
if (!imagePartPath) fail$2(verb, "no image relationship found on the selected slide");
|
|
2041
|
+
zip.file(imagePartPath, replacement.bytes);
|
|
2042
|
+
});
|
|
2043
|
+
};
|
|
2044
|
+
/** `slides.update_chart` — overwrite the chart's cached values in document order. */
|
|
2045
|
+
var slidesUpdateChartStep = async (ctx) => {
|
|
2046
|
+
const verb = "slides update_chart";
|
|
2047
|
+
const data = ctx.step.args.data;
|
|
2048
|
+
if (!Array.isArray(data) || data.length === 0) fail$2(verb, `data must be a non-empty JSON array of arrays: data='[["Q1",10]]'`);
|
|
2049
|
+
return withPresentation(ctx, verb, async (zip, parsed) => {
|
|
2050
|
+
const slideNum = resolveZipPath("ppt/presentation.xml", resolveSlide(verb, parsed.entries, argOf(ctx.step, "slide")).target).match(/slide(\d+)\.xml$/)?.[1];
|
|
2051
|
+
if (!slideNum) fail$2(verb, "unable to resolve the slide number (corrupt PPTX?)");
|
|
2052
|
+
const relsPath = `ppt/slides/_rels/slide${slideNum}.xml.rels`;
|
|
2053
|
+
const chartRel = (await readZipText(verb, zip, relsPath)).match(/<Relationship\b[^>]*Type="[^"]*\/chart"[^>]*Target="([^"]+)"[^>]*>/i);
|
|
2054
|
+
if (!chartRel) fail$2(verb, "no chart found on the selected slide");
|
|
2055
|
+
const chartPath = resolveZipPath(relsPath, chartRel[1]);
|
|
2056
|
+
let chartXml = await readZipText(verb, zip, chartPath);
|
|
2057
|
+
const values = data.flat().map((v) => String(v));
|
|
2058
|
+
let replaced = 0;
|
|
2059
|
+
chartXml = chartXml.replace(/<c:v>[\s\S]*?<\/c:v>/g, (full) => {
|
|
2060
|
+
if (replaced >= values.length) return full;
|
|
2061
|
+
const next = `<c:v>${escapeXml(values[replaced])}</c:v>`;
|
|
2062
|
+
replaced += 1;
|
|
2063
|
+
return next;
|
|
2064
|
+
});
|
|
2065
|
+
if (replaced === 0) fail$2(verb, "the chart has no cached values to update");
|
|
2066
|
+
zip.file(chartPath, chartXml);
|
|
2067
|
+
});
|
|
2068
|
+
};
|
|
2069
|
+
/** `slides.delete` — remove slides by 1-based index (cannot remove all). */
|
|
2070
|
+
var slidesDeleteStep = async (ctx) => {
|
|
2071
|
+
const verb = "slides delete";
|
|
2072
|
+
const slides = ctx.step.args.slides;
|
|
2073
|
+
return withPresentation(ctx, verb, async (zip, parsed) => {
|
|
2074
|
+
const unique = [...new Set(slides)];
|
|
2075
|
+
for (const index of unique) if (!Number.isInteger(index) || index < 1 || index > parsed.entries.length) fail$2(verb, `slide index ${index} is out of range (1-based; ${parsed.entries.length} slides)`);
|
|
2076
|
+
const toDelete = new Set(unique);
|
|
2077
|
+
const kept = parsed.entries.filter((_, idx) => !toDelete.has(idx + 1));
|
|
2078
|
+
if (kept.length === 0) fail$2(verb, "cannot delete every slide in the presentation");
|
|
2079
|
+
zip.file("ppt/presentation.xml", replaceSlideIdList(parsed.presentationXml, kept.map((e) => e.sldIdTag)));
|
|
2080
|
+
let relsXml = parsed.relsXml;
|
|
2081
|
+
parsed.entries.forEach((entry, idx) => {
|
|
2082
|
+
if (!toDelete.has(idx + 1)) return;
|
|
2083
|
+
const targetPath = resolveZipPath("ppt/presentation.xml", entry.target);
|
|
2084
|
+
zip.remove(targetPath);
|
|
2085
|
+
const slideNum = targetPath.match(/slide(\d+)\.xml$/)?.[1];
|
|
2086
|
+
if (slideNum) zip.remove(`ppt/slides/_rels/slide${slideNum}.xml.rels`);
|
|
2087
|
+
relsXml = relsXml.replace(new RegExp(`<Relationship\\b[^>]*Id="${entry.rid}"[^>]*/>`, "g"), "");
|
|
2088
|
+
});
|
|
2089
|
+
zip.file("ppt/_rels/presentation.xml.rels", relsXml);
|
|
2090
|
+
});
|
|
2091
|
+
};
|
|
2092
|
+
/** `slides.reorder` — every slide exactly once, by 1-based index. */
|
|
2093
|
+
var slidesReorderStep = async (ctx) => {
|
|
2094
|
+
const verb = "slides reorder";
|
|
2095
|
+
const order = ctx.step.args.order;
|
|
2096
|
+
return withPresentation(ctx, verb, async (zip, parsed) => {
|
|
2097
|
+
if (order.length !== parsed.entries.length) fail$2(verb, `order must list all ${parsed.entries.length} slides exactly once`);
|
|
2098
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2099
|
+
const reordered = [];
|
|
2100
|
+
for (const index of order) {
|
|
2101
|
+
if (!Number.isInteger(index) || index < 1 || index > parsed.entries.length) fail$2(verb, `slide index ${index} is out of range (1-based)`);
|
|
2102
|
+
if (seen.has(index)) fail$2(verb, `duplicate slide index: ${index}`);
|
|
2103
|
+
seen.add(index);
|
|
2104
|
+
reordered.push(parsed.entries[index - 1]);
|
|
2105
|
+
}
|
|
2106
|
+
zip.file("ppt/presentation.xml", replaceSlideIdList(parsed.presentationXml, reordered.map((e) => e.sldIdTag)));
|
|
2107
|
+
});
|
|
2108
|
+
};
|
|
2109
|
+
/** `slides.duplicate` — copy a slide, optionally inserting at a position. */
|
|
2110
|
+
var slidesDuplicateStep = async (ctx) => {
|
|
2111
|
+
const verb = "slides duplicate";
|
|
2112
|
+
const slide = argOf(ctx.step, "slide");
|
|
2113
|
+
const at = argOf(ctx.step, "at");
|
|
2114
|
+
return withPresentation(ctx, verb, async (zip, parsed) => {
|
|
2115
|
+
if (!Number.isInteger(slide) || slide < 1 || slide > parsed.entries.length) fail$2(verb, `slide index ${slide} is out of range (1-based; ${parsed.entries.length} slides)`);
|
|
2116
|
+
const source = parsed.entries[slide - 1];
|
|
2117
|
+
const sourcePath = resolveZipPath("ppt/presentation.xml", source.target);
|
|
2118
|
+
const sourceXml = await readZipText(verb, zip, sourcePath);
|
|
2119
|
+
const newSlideNumber = getMaxSlideNumber(zip) + 1;
|
|
2120
|
+
zip.file(`ppt/slides/slide${newSlideNumber}.xml`, sourceXml);
|
|
2121
|
+
const sourceNum = sourcePath.match(/slide(\d+)\.xml$/)?.[1];
|
|
2122
|
+
if (sourceNum) {
|
|
2123
|
+
const sourceRels = zip.file(`ppt/slides/_rels/slide${sourceNum}.xml.rels`);
|
|
2124
|
+
if (sourceRels) {
|
|
2125
|
+
const relsXml = await sourceRels.async("text");
|
|
2126
|
+
zip.file(`ppt/slides/_rels/slide${newSlideNumber}.xml.rels`, relsXml.replace(/<Relationship\b[^>]*Type="[^"]*notesSlide"[^>]*\/>/g, ""));
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
const newRid = `rId${getMaxRidNumber(parsed.relsXml) + 1}`;
|
|
2130
|
+
const newTag = `<p:sldId id="${getMaxSlideId(parsed.entries) + 1}" r:id="${newRid}"/>`;
|
|
2131
|
+
const relNode = `<Relationship Id="${newRid}" Type="${SLIDE_REL_TYPE}" Target="slides/slide${newSlideNumber}.xml"/>`;
|
|
2132
|
+
zip.file("ppt/_rels/presentation.xml.rels", parsed.relsXml.replace("</Relationships>", `${relNode}</Relationships>`));
|
|
2133
|
+
const targetPos = at !== void 0 ? Math.min(Math.max(1, at), parsed.entries.length + 1) : slide + 1;
|
|
2134
|
+
const tags = parsed.entries.map((e) => e.sldIdTag);
|
|
2135
|
+
tags.splice(targetPos - 1, 0, newTag);
|
|
2136
|
+
zip.file("ppt/presentation.xml", replaceSlideIdList(parsed.presentationXml, tags));
|
|
2137
|
+
const contentTypes = await readZipText(verb, zip, "[Content_Types].xml");
|
|
2138
|
+
zip.file("[Content_Types].xml", ensureSlideContentType(contentTypes, newSlideNumber));
|
|
2139
|
+
});
|
|
2140
|
+
};
|
|
2141
|
+
/** The slides step registry fragment, keyed by canonical verb id. */
|
|
2142
|
+
var SLIDES_STEPS = [
|
|
2143
|
+
["slides.add", slidesAddStep],
|
|
2144
|
+
["slides.update_text", slidesUpdateTextStep],
|
|
2145
|
+
["slides.update_table", slidesUpdateTableStep],
|
|
2146
|
+
["slides.update_image", slidesUpdateImageStep],
|
|
2147
|
+
["slides.update_chart", slidesUpdateChartStep],
|
|
2148
|
+
["slides.delete", slidesDeleteStep],
|
|
2149
|
+
["slides.reorder", slidesReorderStep],
|
|
2150
|
+
["slides.duplicate", slidesDuplicateStep]
|
|
2151
|
+
];
|
|
2152
|
+
//#endregion
|
|
2153
|
+
//#region src/batteries/media/steps/ingest.ts
|
|
2154
|
+
/**
|
|
2155
|
+
* Ingest-depth step extensions: the unified `extract.text` format dispatch (PDF text layer,
|
|
2156
|
+
* DOCX via mammoth, XLSX via ExcelJS, ODT/ODS via ODF XML, PPTX via slide XML, images via the
|
|
2157
|
+
* OCR engine) and format-aware `extract.metadata`.
|
|
2158
|
+
*
|
|
2159
|
+
* @remarks
|
|
2160
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. Ported from the source server's
|
|
2161
|
+
* four extractors, collapsed into one verb that routes on the resolved media's format family
|
|
2162
|
+
* (the single biggest order-out-of-chaos win — the model says `extract text` and doesn't care
|
|
2163
|
+
* what the container is). OCR runs through the configured `ocr` engine when the input is an
|
|
2164
|
+
* image, or when a PDF has no text layer with `ocr=auto`, or always with `ocr=force`.
|
|
2165
|
+
*/
|
|
2166
|
+
var zipPromise;
|
|
2167
|
+
var jszip = () => {
|
|
2168
|
+
zipPromise ??= import("jszip").then((m) => "default" in m ? m.default : m);
|
|
2169
|
+
return zipPromise;
|
|
2170
|
+
};
|
|
2171
|
+
var fail$1 = (verb, message) => {
|
|
2172
|
+
throw new E_MEDIA_STEP_FAILED([verb, message]);
|
|
2173
|
+
};
|
|
2174
|
+
var VERB = "extract text";
|
|
2175
|
+
var extractPdfText = async (ctx) => {
|
|
2176
|
+
const { PDFParse } = await import("pdf-parse");
|
|
2177
|
+
try {
|
|
2178
|
+
return (await new PDFParse({ data: ctx.payload.bytes }).getText()).text ?? "";
|
|
2179
|
+
} catch (err) {
|
|
2180
|
+
fail$1(VERB, `could not read the PDF: ${isError(err) ? err.message : String(err)}`);
|
|
2181
|
+
throw err;
|
|
2182
|
+
}
|
|
2183
|
+
};
|
|
2184
|
+
var extractDocxText = async (ctx) => {
|
|
2185
|
+
const mod = await import("mammoth");
|
|
2186
|
+
const mammoth = "extractRawText" in mod ? mod : mod.default;
|
|
2187
|
+
const bytes = ctx.payload.bytes;
|
|
2188
|
+
const input = typeof globalThis.Buffer !== "undefined" ? { buffer: globalThis.Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) } : { arrayBuffer: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
|
|
2189
|
+
return (await mammoth.extractRawText(input)).value;
|
|
2190
|
+
};
|
|
2191
|
+
var extractXlsxText = async (ctx) => {
|
|
2192
|
+
const mod = await import("exceljs");
|
|
2193
|
+
const wb = new ("default" in mod ? mod.default : mod).Workbook();
|
|
2194
|
+
await wb.xlsx.load(ctx.payload.bytes.buffer.slice(ctx.payload.bytes.byteOffset, ctx.payload.bytes.byteOffset + ctx.payload.bytes.byteLength));
|
|
2195
|
+
const lines = [];
|
|
2196
|
+
for (const ws of wb.worksheets) {
|
|
2197
|
+
lines.push(`# ${ws.name}`);
|
|
2198
|
+
ws.eachRow({ includeEmpty: false }, (row) => {
|
|
2199
|
+
const cells = [];
|
|
2200
|
+
row.eachCell({ includeEmpty: false }, (cell) => {
|
|
2201
|
+
cells.push(String(cell.value ?? ""));
|
|
2202
|
+
});
|
|
2203
|
+
lines.push(cells.join(" "));
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
return lines.join("\n");
|
|
2207
|
+
};
|
|
2208
|
+
/** ODF content.xml → visible text (ported transformation order). */
|
|
2209
|
+
var extractOdfXmlText = (xml) => xml.replace(/<\/text:p>/g, "\n").replace(/<\/text:h>/g, "\n").replace(/<\/table:table-row>/g, "\n").replace(/<text:tab[^>]*\/>/g, " ").replace(/<text:line-break[^>]*\/>/g, "\n").replace(/<[^>]+>/g, "").replace(/'/g, "'").replace(/"/g, "\"").replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
2210
|
+
var extractOdfText = async (ctx) => {
|
|
2211
|
+
const content = (await (await jszip()).loadAsync(ctx.payload.bytes)).file("content.xml");
|
|
2212
|
+
if (!content) fail$1(VERB, "the ODF archive is missing content.xml (corrupt file?)");
|
|
2213
|
+
return extractOdfXmlText(await content.async("text"));
|
|
2214
|
+
};
|
|
2215
|
+
var extractPptxText = async (ctx) => {
|
|
2216
|
+
const zip = await (await jszip()).loadAsync(ctx.payload.bytes);
|
|
2217
|
+
const slideNames = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n)).sort((a, b) => {
|
|
2218
|
+
return Number(a.match(/slide(\d+)/)?.[1] ?? 0) - Number(b.match(/slide(\d+)/)?.[1] ?? 0);
|
|
2219
|
+
});
|
|
2220
|
+
if (slideNames.length === 0) fail$1(VERB, "the presentation has no slides");
|
|
2221
|
+
const parts = [];
|
|
2222
|
+
for (const name of slideNames) {
|
|
2223
|
+
const texts = ((await zip.file(name).async("text")).match(/<a:t>([^<]*)<\/a:t>/g) ?? []).map((t) => t.replace(/<[^>]+>/g, "").replace(/'/g, "'").replace(/"/g, "\"").replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&"));
|
|
2224
|
+
parts.push(texts.join("\n"));
|
|
2225
|
+
}
|
|
2226
|
+
return parts.join("\n\n");
|
|
2227
|
+
};
|
|
2228
|
+
var runOcr = async (ctx) => {
|
|
2229
|
+
const out = argOf(ctx.step, "ocr_out") ?? "txt";
|
|
2230
|
+
if (!ctx.engines.hasConvert(ctx.payload.mimeType, out)) fail$1(VERB, `this input requires OCR, and no engine that converts ${ctx.payload.mimeType} to "${out}" is configured. Do not retry this verb on this media in this deployment.`);
|
|
2231
|
+
const langArg = ctx.step.args.lang;
|
|
2232
|
+
const languages = langArg === void 0 ? void 0 : Array.isArray(langArg) ? langArg : [langArg];
|
|
2233
|
+
const output = (await ctx.engines.convert({
|
|
2234
|
+
bytes: ctx.payload.bytes,
|
|
2235
|
+
mimeType: ctx.payload.mimeType,
|
|
2236
|
+
filename: ctx.payload.filename,
|
|
2237
|
+
to: out,
|
|
2238
|
+
options: { languages },
|
|
2239
|
+
signal: ctx.signal
|
|
2240
|
+
})).outputs[0];
|
|
2241
|
+
if (!output) fail$1(VERB, "OCR produced no output");
|
|
2242
|
+
return new TextDecoder().decode(output.bytes);
|
|
2243
|
+
};
|
|
2244
|
+
/**
|
|
2245
|
+
* `extract.text` — full format dispatch. Routes by MIME, with OCR engine fallback for images
|
|
2246
|
+
* and force-OCR for any input via `ocr=force`.
|
|
2247
|
+
*/
|
|
2248
|
+
var extractTextDeepStep = async (ctx) => {
|
|
2249
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
2250
|
+
const ocrMode = argOf(ctx.step, "ocr") ?? "auto";
|
|
2251
|
+
const asData = (text) => ({
|
|
2252
|
+
kind: "data",
|
|
2253
|
+
data: text,
|
|
2254
|
+
asText: text
|
|
2255
|
+
});
|
|
2256
|
+
if (mime.startsWith("image/")) return asData(await runOcr(ctx));
|
|
2257
|
+
if (ocrMode === "force") return asData(await runOcr(ctx));
|
|
2258
|
+
if (mime === MIME.PDF) {
|
|
2259
|
+
const text = await extractPdfText(ctx);
|
|
2260
|
+
if (text.trim().length === 0 && ocrMode === "auto") {
|
|
2261
|
+
if (!ctx.engines.hasConvert(mime, "txt")) fail$1(VERB, "this PDF has no text layer (scanned?). An engine that converts application/pdf to text (OCR) is required to read it, and none is configured. Do not retry this verb on this media in this deployment.");
|
|
2262
|
+
return asData(await runOcr(ctx));
|
|
2263
|
+
}
|
|
2264
|
+
return asData(text);
|
|
2265
|
+
}
|
|
2266
|
+
if (mime === MIME.DOCX || mime === MIME.DOC) return asData(await extractDocxText(ctx));
|
|
2267
|
+
if (mime === MIME.XLSX) return asData(await extractXlsxText(ctx));
|
|
2268
|
+
if (mime === MIME.ODT || mime === MIME.ODS || mime === MIME.ODP) return asData(await extractOdfText(ctx));
|
|
2269
|
+
if (mime === MIME.PPTX) return asData(await extractPptxText(ctx));
|
|
2270
|
+
if (mime.startsWith("text/") || mime === MIME.JSON) return asData(decodeText(ctx.payload.bytes));
|
|
2271
|
+
fail$1(VERB, `text extraction for ${mime} is not supported in this build. For PPT/legacy formats: convert to=pptx | extract text (requires a convert engine).`);
|
|
2272
|
+
throw new Error("unreachable");
|
|
2273
|
+
};
|
|
2274
|
+
/** `extract.metadata` — format-aware metadata (page counts, sheet/slide inventories). */
|
|
2275
|
+
var extractMetadataDeepStep = async (ctx) => {
|
|
2276
|
+
const mime = ctx.payload.mimeType.toLowerCase().split(";")[0].trim();
|
|
2277
|
+
const meta = {
|
|
2278
|
+
filename: ctx.payload.filename,
|
|
2279
|
+
mime_type: ctx.payload.mimeType,
|
|
2280
|
+
size_bytes: ctx.payload.bytes.byteLength,
|
|
2281
|
+
family: familyOf(ctx.payload.mimeType)
|
|
2282
|
+
};
|
|
2283
|
+
try {
|
|
2284
|
+
if (mime === MIME.PDF) {
|
|
2285
|
+
const { PDFDocument } = await import("pdf-lib");
|
|
2286
|
+
const doc = await PDFDocument.load(ctx.payload.bytes, { ignoreEncryption: true });
|
|
2287
|
+
meta.page_count = doc.getPageCount();
|
|
2288
|
+
meta.title = doc.getTitle() ?? void 0;
|
|
2289
|
+
meta.author = doc.getAuthor() ?? void 0;
|
|
2290
|
+
} else if (mime === MIME.XLSX) {
|
|
2291
|
+
const mod = await import("exceljs");
|
|
2292
|
+
const wb = new ("default" in mod ? mod.default : mod).Workbook();
|
|
2293
|
+
await wb.xlsx.load(ctx.payload.bytes.buffer.slice(ctx.payload.bytes.byteOffset, ctx.payload.bytes.byteOffset + ctx.payload.bytes.byteLength));
|
|
2294
|
+
meta.sheets = wb.worksheets.map((ws) => ({
|
|
2295
|
+
name: ws.name,
|
|
2296
|
+
rows: ws.rowCount
|
|
2297
|
+
}));
|
|
2298
|
+
} else if (mime === MIME.PPTX) {
|
|
2299
|
+
const zip = await (await jszip()).loadAsync(ctx.payload.bytes);
|
|
2300
|
+
meta.slide_count = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n)).length;
|
|
2301
|
+
}
|
|
2302
|
+
} catch {}
|
|
2303
|
+
return {
|
|
2304
|
+
kind: "data",
|
|
2305
|
+
data: meta,
|
|
2306
|
+
asText: JSON.stringify(meta)
|
|
2307
|
+
};
|
|
2308
|
+
};
|
|
2309
|
+
/** The ingest step registry fragment (overrides the Phase 0 native-text-only versions). */
|
|
2310
|
+
var INGEST_STEPS = [["extract.text", extractTextDeepStep], ["extract.metadata", extractMetadataDeepStep]];
|
|
2311
|
+
//#endregion
|
|
2312
|
+
//#region src/batteries/media/registry.ts
|
|
2313
|
+
/**
|
|
2314
|
+
* The engine registry: capability-filtered, middleware-arbitrated, ordered dispatch over the
|
|
2315
|
+
* pipeline's engine array.
|
|
2316
|
+
*
|
|
2317
|
+
* @remarks
|
|
2318
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry (re-exported through the barrel).
|
|
2319
|
+
* One selection rule everywhere: gather every engine whose declarations match the request
|
|
2320
|
+
* (capability filter), run the consumer's selection onion (stages may exclude or reorder
|
|
2321
|
+
* candidates, never add), then the first survivor in supply order wins.
|
|
2322
|
+
*
|
|
2323
|
+
* Convert dispatch additionally computes multi-hop paths: when no single engine declares a
|
|
2324
|
+
* direct edge for the requested (input, target) pair, a breadth-first search over the format
|
|
2325
|
+
* graph finds the shortest chain of hops (capped at {@link MAX_HOPS}), with supply order
|
|
2326
|
+
* breaking ties at equal length. Pathfinding explores capability declarations only — cheap
|
|
2327
|
+
* and synchronous; the selection onion runs once per executed hop with real bytes in hand.
|
|
2328
|
+
*/
|
|
2329
|
+
/** The maximum number of conversion hops a computed path may take. */
|
|
2330
|
+
var MAX_HOPS = 3;
|
|
2331
|
+
/**
|
|
2332
|
+
* Format tokens that are valid conversion endpoints but never intermediate path nodes.
|
|
2333
|
+
* Without this rule the pathfinder would happily route `docx → txt → …` (lossy garbage in,
|
|
2334
|
+
* garbage out) or auto-chain `audio → pcm → txt` (skipping the resample the transcription
|
|
2335
|
+
* step performs between those legs).
|
|
2336
|
+
*/
|
|
2337
|
+
var TERMINAL_TOKENS = new Set([
|
|
2338
|
+
"txt",
|
|
2339
|
+
"json",
|
|
2340
|
+
"hocr",
|
|
2341
|
+
"srt",
|
|
2342
|
+
"vtt",
|
|
2343
|
+
"md",
|
|
2344
|
+
"csv",
|
|
2345
|
+
"pcm",
|
|
2346
|
+
"images"
|
|
2347
|
+
]);
|
|
2348
|
+
/** Normalize a MIME type for comparison (lowercase, parameters stripped). */
|
|
2349
|
+
var normalizeMime = (mimeType) => mimeType.toLowerCase().split(";")[0].trim();
|
|
2350
|
+
/** `true` when `mime` matches `pattern` (exact, or a `family/*` wildcard). */
|
|
2351
|
+
var matchesMime = (pattern, mime) => {
|
|
2352
|
+
const p = pattern.toLowerCase();
|
|
2353
|
+
if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
|
|
2354
|
+
return p === mime;
|
|
2355
|
+
};
|
|
2356
|
+
/** `true` when any pattern in `patterns` matches `mime`. */
|
|
2357
|
+
var matchesAny = (patterns, mime) => patterns.some((p) => matchesMime(p, mime));
|
|
2358
|
+
/** The mutate operations requested by a fused request. */
|
|
2359
|
+
var requestedOps = (request) => {
|
|
2360
|
+
const ops = [];
|
|
2361
|
+
if (request.resize) ops.push("resize");
|
|
2362
|
+
if (request.rotate !== void 0) ops.push("rotate");
|
|
2363
|
+
if (request.flip) ops.push("flip");
|
|
2364
|
+
if (request.stripMetadata) ops.push("strip_metadata");
|
|
2365
|
+
return ops;
|
|
2366
|
+
};
|
|
2367
|
+
/**
|
|
2368
|
+
* Build the registry over a resolved, validated engine array.
|
|
2369
|
+
*
|
|
2370
|
+
* @param engines - The engines, in supply (priority) order.
|
|
2371
|
+
* @param selection - Optional selection-middleware stages arbitrating multi-candidate dispatches.
|
|
2372
|
+
* @returns The registry.
|
|
2373
|
+
*/
|
|
2374
|
+
var buildEngineRegistry = (engines, selection = []) => {
|
|
2375
|
+
const convertCandidates = (fromMime, to) => {
|
|
2376
|
+
const mime = normalizeMime(fromMime);
|
|
2377
|
+
const out = [];
|
|
2378
|
+
for (const engine of engines) for (const capability of engine.converts ?? []) if (capability.to.includes(to) && matchesAny(capability.from, mime)) {
|
|
2379
|
+
out.push({
|
|
2380
|
+
engine,
|
|
2381
|
+
capability
|
|
2382
|
+
});
|
|
2383
|
+
break;
|
|
2384
|
+
}
|
|
2385
|
+
return out;
|
|
2386
|
+
};
|
|
2387
|
+
const mutateCandidates = (request) => {
|
|
2388
|
+
const mime = normalizeMime(request.mimeType);
|
|
2389
|
+
const ops = requestedOps(request);
|
|
2390
|
+
const out = [];
|
|
2391
|
+
for (const engine of engines) for (const capability of engine.mutates ?? []) {
|
|
2392
|
+
if (!matchesAny(capability.over, mime)) continue;
|
|
2393
|
+
if (!ops.every((op) => capability.ops.includes(op))) continue;
|
|
2394
|
+
if (request.format && !capability.encodes.includes(request.format.to)) continue;
|
|
2395
|
+
out.push({
|
|
2396
|
+
engine,
|
|
2397
|
+
capability
|
|
2398
|
+
});
|
|
2399
|
+
break;
|
|
2400
|
+
}
|
|
2401
|
+
return out;
|
|
2402
|
+
};
|
|
2403
|
+
const arbitrate = async (kind, request, candidates) => {
|
|
2404
|
+
if (candidates.length === 0) return void 0;
|
|
2405
|
+
if (selection.length === 0 || candidates.length === 1) return candidates[0];
|
|
2406
|
+
const ctx = {
|
|
2407
|
+
kind,
|
|
2408
|
+
request,
|
|
2409
|
+
candidates: candidates.map((c) => c.engine)
|
|
2410
|
+
};
|
|
2411
|
+
const mw = new Middleware();
|
|
2412
|
+
for (const fn of selection) mw.add(fn);
|
|
2413
|
+
let failed;
|
|
2414
|
+
await mw.runner().errorHandler(async (error) => {
|
|
2415
|
+
failed = error;
|
|
2416
|
+
}).finalHandler(async () => {}).run((fn, next) => Promise.resolve(fn(ctx, next)));
|
|
2417
|
+
if (failed !== void 0) throw failed;
|
|
2418
|
+
for (const engine of ctx.candidates) {
|
|
2419
|
+
const survivor = candidates.find((c) => c.engine === engine);
|
|
2420
|
+
if (survivor) return survivor;
|
|
2421
|
+
}
|
|
2422
|
+
};
|
|
2423
|
+
const findPath = (fromMime, target) => {
|
|
2424
|
+
const start = normalizeMime(fromMime);
|
|
2425
|
+
const queue = [{
|
|
2426
|
+
mime: start,
|
|
2427
|
+
path: []
|
|
2428
|
+
}];
|
|
2429
|
+
const visited = new Set([start]);
|
|
2430
|
+
while (queue.length > 0) {
|
|
2431
|
+
const { mime, path } = queue.shift();
|
|
2432
|
+
if (path.length >= MAX_HOPS) continue;
|
|
2433
|
+
if (convertCandidates(mime, target).length > 0) return [...path, {
|
|
2434
|
+
fromMime: mime,
|
|
2435
|
+
to: target
|
|
2436
|
+
}];
|
|
2437
|
+
for (const engine of engines) for (const capability of engine.converts ?? []) {
|
|
2438
|
+
if (!matchesAny(capability.from, mime)) continue;
|
|
2439
|
+
for (const to of capability.to) {
|
|
2440
|
+
if (TERMINAL_TOKENS.has(to)) continue;
|
|
2441
|
+
const nextMime = EXT_TO_MIME[to];
|
|
2442
|
+
if (!nextMime || visited.has(nextMime)) continue;
|
|
2443
|
+
visited.add(nextMime);
|
|
2444
|
+
queue.push({
|
|
2445
|
+
mime: nextMime,
|
|
2446
|
+
path: [...path, {
|
|
2447
|
+
fromMime: mime,
|
|
2448
|
+
to
|
|
2449
|
+
}]
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
};
|
|
2455
|
+
const hasConvert = (from, to) => {
|
|
2456
|
+
for (const engine of engines) for (const capability of engine.converts ?? []) {
|
|
2457
|
+
if (from !== void 0 && !matchesAny(capability.from, normalizeMime(from))) continue;
|
|
2458
|
+
if (to !== void 0 && !capability.to.includes(to)) continue;
|
|
2459
|
+
return true;
|
|
2460
|
+
}
|
|
2461
|
+
return false;
|
|
2462
|
+
};
|
|
2463
|
+
const convertTargets = (fromMime) => {
|
|
2464
|
+
const reachable = [];
|
|
2465
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2466
|
+
let frontier = [normalizeMime(fromMime)];
|
|
2467
|
+
const visitedMimes = new Set(frontier);
|
|
2468
|
+
for (let hop = 0; hop < MAX_HOPS && frontier.length > 0; hop++) {
|
|
2469
|
+
const next = [];
|
|
2470
|
+
for (const mime of frontier) for (const engine of engines) for (const capability of engine.converts ?? []) {
|
|
2471
|
+
if (!matchesAny(capability.from, mime)) continue;
|
|
2472
|
+
for (const to of capability.to) {
|
|
2473
|
+
if (!seen.has(to)) {
|
|
2474
|
+
seen.add(to);
|
|
2475
|
+
reachable.push(to);
|
|
2476
|
+
}
|
|
2477
|
+
if (TERMINAL_TOKENS.has(to)) continue;
|
|
2478
|
+
const nextMime = EXT_TO_MIME[to];
|
|
2479
|
+
if (nextMime && !visitedMimes.has(nextMime)) {
|
|
2480
|
+
visitedMimes.add(nextMime);
|
|
2481
|
+
next.push(nextMime);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
frontier = next;
|
|
2486
|
+
}
|
|
2487
|
+
return reachable;
|
|
2488
|
+
};
|
|
2489
|
+
const ids = () => engines.map((e) => e.id).join(", ") || "(none)";
|
|
2490
|
+
const convert = async (request) => {
|
|
2491
|
+
const path = findPath(request.mimeType, request.to);
|
|
2492
|
+
if (!path) {
|
|
2493
|
+
const supported = convertTargets(request.mimeType);
|
|
2494
|
+
throw new Error(`no configured engine (or computed path) can convert ${request.mimeType} to "${request.to}"; reachable targets: ${supported.join(", ") || "(none)"} (engines: ${ids()})`);
|
|
2495
|
+
}
|
|
2496
|
+
let bytes = request.bytes;
|
|
2497
|
+
let mimeType = request.mimeType;
|
|
2498
|
+
let result;
|
|
2499
|
+
for (const hop of path) {
|
|
2500
|
+
const candidates = convertCandidates(mimeType, hop.to);
|
|
2501
|
+
const chosen = await arbitrate("convert", {
|
|
2502
|
+
mimeType,
|
|
2503
|
+
filename: request.filename,
|
|
2504
|
+
bytes,
|
|
2505
|
+
to: hop.to
|
|
2506
|
+
}, candidates);
|
|
2507
|
+
if (!chosen) throw new Error(`all engines capable of converting ${mimeType} to "${hop.to}" (${candidates.map((c) => c.engine.id).join(", ")}) were excluded by selection middleware`);
|
|
2508
|
+
result = await chosen.capability.convert({
|
|
2509
|
+
bytes,
|
|
2510
|
+
mimeType,
|
|
2511
|
+
filename: request.filename,
|
|
2512
|
+
to: hop.to,
|
|
2513
|
+
options: request.options,
|
|
2514
|
+
signal: request.signal
|
|
2515
|
+
});
|
|
2516
|
+
const first = result.outputs[0];
|
|
2517
|
+
if (!first) throw new Error(`engine "${chosen.engine.id}" converted ${mimeType} to "${hop.to}" but produced no outputs`);
|
|
2518
|
+
bytes = first.bytes;
|
|
2519
|
+
mimeType = first.mimeType;
|
|
2520
|
+
}
|
|
2521
|
+
return result;
|
|
2522
|
+
};
|
|
2523
|
+
const mutate = async (request) => {
|
|
2524
|
+
const candidates = mutateCandidates(request);
|
|
2525
|
+
if (candidates.length === 0) {
|
|
2526
|
+
const allOps = /* @__PURE__ */ new Set();
|
|
2527
|
+
const allEncodes = /* @__PURE__ */ new Set();
|
|
2528
|
+
for (const engine of engines) for (const capability of engine.mutates ?? []) {
|
|
2529
|
+
for (const op of capability.ops) allOps.add(op);
|
|
2530
|
+
for (const enc of capability.encodes) allEncodes.add(enc);
|
|
2531
|
+
}
|
|
2532
|
+
const wanted = requestedOps(request).join(", ") || "(none)";
|
|
2533
|
+
const encodeNote = request.format ? `; requested encoding "${request.format.to}" — declared encodings: ${[...allEncodes].join(", ") || "(none)"}. A different engine (e.g. sharp) may support it` : "";
|
|
2534
|
+
throw new Error(`no configured engine can mutate ${request.mimeType} with ops [${wanted}]${encodeNote} (engines: ${ids()})`);
|
|
2535
|
+
}
|
|
2536
|
+
const chosen = await arbitrate("mutate", {
|
|
2537
|
+
mimeType: request.mimeType,
|
|
2538
|
+
filename: "",
|
|
2539
|
+
bytes: request.bytes,
|
|
2540
|
+
to: request.format?.to,
|
|
2541
|
+
ops: requestedOps(request)
|
|
2542
|
+
}, candidates);
|
|
2543
|
+
if (!chosen) throw new Error(`all engines capable of this mutate (${candidates.map((c) => c.engine.id).join(", ")}) were excluded by selection middleware`);
|
|
2544
|
+
return chosen.capability.mutate(request);
|
|
2545
|
+
};
|
|
2546
|
+
return {
|
|
2547
|
+
engines,
|
|
2548
|
+
hasConvert,
|
|
2549
|
+
hasMutate: () => engines.some((e) => (e.mutates ?? []).length > 0),
|
|
2550
|
+
convertTargets,
|
|
2551
|
+
convert,
|
|
2552
|
+
mutate
|
|
2553
|
+
};
|
|
2554
|
+
};
|
|
2555
|
+
//#endregion
|
|
2556
|
+
//#region src/batteries/media/steps/image_audio.ts
|
|
2557
|
+
/**
|
|
2558
|
+
* `image.*` and `audio.*` step implementations: fused raster transforms via the
|
|
2559
|
+
* imageTransform engine, and transcription via the audioDecode + asr engines.
|
|
2560
|
+
*
|
|
2561
|
+
* @remarks
|
|
2562
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. The split image verbs
|
|
2563
|
+
* (`resize`/`format`/`rotate`/`flip`/`strip_metadata`) read pipe-naturally, and the runtime
|
|
2564
|
+
* fuses **adjacent** image steps into a single engine invocation so a resize→format→rotate
|
|
2565
|
+
* chain costs one decode/encode (frozen design 0.13). Fusion happens here at execution time:
|
|
2566
|
+
* each image step folds its args into a shared fused request stored on the execution stash;
|
|
2567
|
+
* the LAST image step in a run performs the engine call.
|
|
2568
|
+
*
|
|
2569
|
+
* Audio transcription composes two engines: `audioDecode` (container → mono PCM) and `asr`
|
|
2570
|
+
* (PCM → text/subtitles). A small pure resampler bridges arbitrary sample rates to the
|
|
2571
|
+
* 16 kHz mono Float32Array ASR engines expect.
|
|
2572
|
+
*/
|
|
2573
|
+
var fail = (verb, message) => {
|
|
2574
|
+
throw new E_MEDIA_STEP_FAILED([verb, message]);
|
|
2575
|
+
};
|
|
2576
|
+
var IMAGE_VERBS = new Set([
|
|
2577
|
+
"image.resize",
|
|
2578
|
+
"image.format",
|
|
2579
|
+
"image.rotate",
|
|
2580
|
+
"image.flip",
|
|
2581
|
+
"image.strip_metadata"
|
|
2582
|
+
]);
|
|
2583
|
+
var requireImage = (ctx, verb) => {
|
|
2584
|
+
if (!ctx.payload.mimeType.toLowerCase().startsWith("image/")) fail(verb, `image operations expect an image; the media is ${ctx.payload.mimeType}`);
|
|
2585
|
+
};
|
|
2586
|
+
/** Fold this step's args into the fused request. */
|
|
2587
|
+
var foldImageStep = (ctx, fused) => {
|
|
2588
|
+
const args = ctx.step.args;
|
|
2589
|
+
switch (ctx.step.verb) {
|
|
2590
|
+
case "image.resize": return {
|
|
2591
|
+
...fused,
|
|
2592
|
+
resize: {
|
|
2593
|
+
width: args.width,
|
|
2594
|
+
height: args.height,
|
|
2595
|
+
fit: args.fit
|
|
2596
|
+
}
|
|
2597
|
+
};
|
|
2598
|
+
case "image.format": return {
|
|
2599
|
+
...fused,
|
|
2600
|
+
format: {
|
|
2601
|
+
to: args.to,
|
|
2602
|
+
quality: args.quality
|
|
2603
|
+
},
|
|
2604
|
+
...args.strip_metadata !== void 0 ? { stripMetadata: args.strip_metadata } : {}
|
|
2605
|
+
};
|
|
2606
|
+
case "image.rotate": {
|
|
2607
|
+
const deg = Number(args.deg);
|
|
2608
|
+
const total = ((fused.rotate ?? 0) + deg) % 360;
|
|
2609
|
+
return {
|
|
2610
|
+
...fused,
|
|
2611
|
+
rotate: total === 0 ? void 0 : total
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2614
|
+
case "image.flip": {
|
|
2615
|
+
const axis = args.axis;
|
|
2616
|
+
const flip = { ...fused.flip ?? {} };
|
|
2617
|
+
if (axis === "horizontal" || axis === "both") flip.horizontal = !flip.horizontal;
|
|
2618
|
+
if (axis === "vertical" || axis === "both") flip.vertical = !flip.vertical;
|
|
2619
|
+
return {
|
|
2620
|
+
...fused,
|
|
2621
|
+
flip
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
case "image.strip_metadata": return {
|
|
2625
|
+
...fused,
|
|
2626
|
+
stripMetadata: true
|
|
2627
|
+
};
|
|
2628
|
+
default: return fused;
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
/** `true` when the NEXT step in the plan is also an image verb (so we defer the engine call). */
|
|
2632
|
+
var nextIsImageStep = (ctx) => {
|
|
2633
|
+
const next = ctx.plan.steps[ctx.stepIndex + 1];
|
|
2634
|
+
return next !== void 0 && IMAGE_VERBS.has(next.verb);
|
|
2635
|
+
};
|
|
2636
|
+
var FUSION_KEY = "media.image.fused";
|
|
2637
|
+
var imageStep = async (ctx) => {
|
|
2638
|
+
const verb = ctx.step.verb.replace(/\./g, " ");
|
|
2639
|
+
requireImage(ctx, verb);
|
|
2640
|
+
const fused = foldImageStep(ctx, ctx.stash.get(FUSION_KEY) ?? {});
|
|
2641
|
+
if (nextIsImageStep(ctx)) {
|
|
2642
|
+
ctx.stash.set(FUSION_KEY, fused);
|
|
2643
|
+
return {
|
|
2644
|
+
kind: "media",
|
|
2645
|
+
payload: ctx.payload
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
ctx.stash.delete(FUSION_KEY);
|
|
2649
|
+
if (!ctx.engines.hasMutate()) fail(verb, "no image-mutating engine is configured. Do not retry image verbs in this deployment.");
|
|
2650
|
+
const result = await ctx.engines.mutate({
|
|
2651
|
+
...fused,
|
|
2652
|
+
bytes: ctx.payload.bytes,
|
|
2653
|
+
mimeType: ctx.payload.mimeType,
|
|
2654
|
+
signal: ctx.signal
|
|
2655
|
+
});
|
|
2656
|
+
const ext = result.mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "bin";
|
|
2657
|
+
const dot = ctx.payload.filename.lastIndexOf(".");
|
|
2658
|
+
const base = dot > 0 ? ctx.payload.filename.slice(0, dot) : ctx.payload.filename;
|
|
2659
|
+
return {
|
|
2660
|
+
kind: "media",
|
|
2661
|
+
payload: {
|
|
2662
|
+
bytes: result.bytes,
|
|
2663
|
+
mimeType: result.mimeType,
|
|
2664
|
+
filename: `${base}.${ext}`
|
|
2665
|
+
}
|
|
2666
|
+
};
|
|
2667
|
+
};
|
|
2668
|
+
/** Downmix to mono and linearly resample to the target rate. Pure, cross-env. */
|
|
2669
|
+
var resampleTo = (pcm, fromRate, toRate) => {
|
|
2670
|
+
if (fromRate === toRate) return pcm;
|
|
2671
|
+
const ratio = fromRate / toRate;
|
|
2672
|
+
const outLength = Math.floor(pcm.length / ratio);
|
|
2673
|
+
const out = new Float32Array(outLength);
|
|
2674
|
+
for (let i = 0; i < outLength; i++) {
|
|
2675
|
+
const pos = i * ratio;
|
|
2676
|
+
const left = Math.floor(pos);
|
|
2677
|
+
const right = Math.min(left + 1, pcm.length - 1);
|
|
2678
|
+
const frac = pos - left;
|
|
2679
|
+
out[i] = pcm[left] * (1 - frac) + pcm[right] * frac;
|
|
2680
|
+
}
|
|
2681
|
+
return out;
|
|
2682
|
+
};
|
|
2683
|
+
var ASR_SAMPLE_RATE = 16e3;
|
|
2684
|
+
/**
|
|
2685
|
+
* `audio.transcribe` — an explicit two-stage composition: convert the container to PCM,
|
|
2686
|
+
* resample to 16 kHz mono, then convert the PCM to the requested text form. The registry
|
|
2687
|
+
* never auto-chains through PCM (it is a terminal token), precisely because this resample
|
|
2688
|
+
* leg must happen between the two converts.
|
|
2689
|
+
*/
|
|
2690
|
+
var audioTranscribeStep = async (ctx) => {
|
|
2691
|
+
const verb = "audio transcribe";
|
|
2692
|
+
if (!ctx.payload.mimeType.toLowerCase().startsWith("audio/")) fail(verb, `transcription expects audio; the media is ${ctx.payload.mimeType}`);
|
|
2693
|
+
const out = argOf(ctx.step, "out") ?? "txt";
|
|
2694
|
+
if (!ctx.engines.hasConvert(ctx.payload.mimeType, "pcm")) fail(verb, `no engine that decodes ${ctx.payload.mimeType} to PCM is configured. Do not retry this verb in this deployment.`);
|
|
2695
|
+
if (!ctx.engines.hasConvert("audio/x-adk-pcm", out)) fail(verb, `no engine that transcribes PCM to "${out}" is configured. Do not retry this verb in this deployment.`);
|
|
2696
|
+
const pcmOut = (await ctx.engines.convert({
|
|
2697
|
+
bytes: ctx.payload.bytes,
|
|
2698
|
+
mimeType: ctx.payload.mimeType,
|
|
2699
|
+
filename: ctx.payload.filename,
|
|
2700
|
+
to: "pcm",
|
|
2701
|
+
signal: ctx.signal
|
|
2702
|
+
})).outputs[0];
|
|
2703
|
+
if (!pcmOut) fail(verb, "audio decoding produced no output");
|
|
2704
|
+
const sourceRate = Number(pcmOut.meta?.sampleRate);
|
|
2705
|
+
if (!Number.isFinite(sourceRate) || sourceRate <= 0) fail(verb, "the decode engine did not report meta.sampleRate on its PCM output");
|
|
2706
|
+
const pcm = resampleTo(bytesToPcm(pcmOut.bytes), sourceRate, ASR_SAMPLE_RATE);
|
|
2707
|
+
const final = (await ctx.engines.convert({
|
|
2708
|
+
bytes: pcmToBytes(pcm),
|
|
2709
|
+
mimeType: PCM_MIME,
|
|
2710
|
+
filename: ctx.payload.filename,
|
|
2711
|
+
to: out,
|
|
2712
|
+
options: {
|
|
2713
|
+
lang: argOf(ctx.step, "lang"),
|
|
2714
|
+
translate: argOf(ctx.step, "translate")
|
|
2715
|
+
},
|
|
2716
|
+
signal: ctx.signal
|
|
2717
|
+
})).outputs[0];
|
|
2718
|
+
if (!final) fail(verb, "transcription produced no output");
|
|
2719
|
+
const text = new TextDecoder().decode(final.bytes);
|
|
2720
|
+
return {
|
|
2721
|
+
kind: "data",
|
|
2722
|
+
data: text,
|
|
2723
|
+
asText: text
|
|
2724
|
+
};
|
|
2725
|
+
};
|
|
2726
|
+
/** The image + audio step registry fragment. */
|
|
2727
|
+
var IMAGE_AUDIO_STEPS = [
|
|
2728
|
+
["image.resize", imageStep],
|
|
2729
|
+
["image.format", imageStep],
|
|
2730
|
+
["image.rotate", imageStep],
|
|
2731
|
+
["image.flip", imageStep],
|
|
2732
|
+
["image.strip_metadata", imageStep],
|
|
2733
|
+
["audio.transcribe", audioTranscribeStep]
|
|
2734
|
+
];
|
|
2735
|
+
//#endregion
|
|
2736
|
+
//#region src/batteries/media/steps/pages.ts
|
|
2737
|
+
/**
|
|
2738
|
+
* Pure page-operation step implementations for PDFs: select, split, merge, reorder.
|
|
2739
|
+
*
|
|
2740
|
+
* @remarks
|
|
2741
|
+
* Internal sibling of the `@nhtio/adk/batteries/media` entry. Implemented with `pdf-lib`
|
|
2742
|
+
* (optional peer, lazily imported) on in-memory bytes — no binaries, cross-environment.
|
|
2743
|
+
* Phase 0 covers PDF inputs; PPTX slide operations and DOCX section operations join in the
|
|
2744
|
+
* sheet/slides/doc phases, sharing these verb ids with format dispatch.
|
|
2745
|
+
*/
|
|
2746
|
+
var pdfLibPromise;
|
|
2747
|
+
var pdfLib = () => {
|
|
2748
|
+
pdfLibPromise ??= import("pdf-lib");
|
|
2749
|
+
return pdfLibPromise;
|
|
2750
|
+
};
|
|
2751
|
+
var requirePdf = (verb, payload) => {
|
|
2752
|
+
if (payload.mimeType.toLowerCase().split(";")[0].trim() !== MIME.PDF) throw new E_MEDIA_STEP_FAILED([verb, `page operations for ${payload.mimeType} are not yet implemented in this build (PDF is supported)`]);
|
|
2753
|
+
};
|
|
2754
|
+
var loadPdf = async (verb, bytes) => {
|
|
2755
|
+
const { PDFDocument } = await pdfLib();
|
|
2756
|
+
try {
|
|
2757
|
+
return await PDFDocument.load(bytes, { ignoreEncryption: false });
|
|
2758
|
+
} catch (err) {
|
|
2759
|
+
throw new E_MEDIA_STEP_FAILED([verb, `could not read the PDF: ${isError(err) ? err.message : String(err)}`], { cause: err });
|
|
2760
|
+
}
|
|
2761
|
+
};
|
|
2762
|
+
var checkIndices = (verb, indices, pageCount) => {
|
|
2763
|
+
for (const i of indices) if (i < 1 || i > pageCount) throw new E_MEDIA_STEP_FAILED([verb, `page ${i} is out of range — the document has ${pageCount} pages (pages are 1-based)`]);
|
|
2764
|
+
};
|
|
2765
|
+
/** `select` — keep only the listed 1-based pages, producing one document. */
|
|
2766
|
+
var selectStep = async (ctx) => {
|
|
2767
|
+
requirePdf("select", ctx.payload);
|
|
2768
|
+
const pages = argOf(ctx.step, "pages");
|
|
2769
|
+
const source = await loadPdf("select", ctx.payload.bytes);
|
|
2770
|
+
checkIndices("select", pages, source.getPageCount());
|
|
2771
|
+
const { PDFDocument } = await pdfLib();
|
|
2772
|
+
const out = await PDFDocument.create();
|
|
2773
|
+
const copied = await out.copyPages(source, pages.map((p) => p - 1));
|
|
2774
|
+
for (const page of copied) out.addPage(page);
|
|
2775
|
+
const bytes = await out.save();
|
|
2776
|
+
return {
|
|
2777
|
+
kind: "media",
|
|
2778
|
+
payload: {
|
|
2779
|
+
...ctx.payload,
|
|
2780
|
+
bytes: new Uint8Array(bytes)
|
|
2781
|
+
}
|
|
2782
|
+
};
|
|
2783
|
+
};
|
|
2784
|
+
/** `split` — split into multiple documents by page (optionally grouped by JSON ranges). */
|
|
2785
|
+
var splitStep = async (ctx) => {
|
|
2786
|
+
requirePdf("split", ctx.payload);
|
|
2787
|
+
const by = argOf(ctx.step, "by") ?? "page";
|
|
2788
|
+
if (by !== "page") throw new E_MEDIA_STEP_FAILED(["split", `split by=${by} is not yet implemented in this build (page is supported)`]);
|
|
2789
|
+
const source = await loadPdf("split", ctx.payload.bytes);
|
|
2790
|
+
const pageCount = source.getPageCount();
|
|
2791
|
+
const rawRanges = ctx.step.args.ranges;
|
|
2792
|
+
const groups = rawRanges !== void 0 ? rawRanges.map((pair) => {
|
|
2793
|
+
if (!Array.isArray(pair) || pair.length !== 2) throw new E_MEDIA_STEP_FAILED(["split", `ranges must be [start,end] pairs, e.g. ranges='[[1,3],[5,7]]'`]);
|
|
2794
|
+
const [start, end] = pair;
|
|
2795
|
+
const pages = [];
|
|
2796
|
+
for (let p = start; p <= end; p++) pages.push(p);
|
|
2797
|
+
return pages;
|
|
2798
|
+
}) : Array.from({ length: pageCount }, (_, i) => [i + 1]);
|
|
2799
|
+
const { PDFDocument } = await pdfLib();
|
|
2800
|
+
const payloads = [];
|
|
2801
|
+
let part = 1;
|
|
2802
|
+
for (const group of groups) {
|
|
2803
|
+
checkIndices("split", group, pageCount);
|
|
2804
|
+
const out = await PDFDocument.create();
|
|
2805
|
+
const copied = await out.copyPages(source, group.map((p) => p - 1));
|
|
2806
|
+
for (const page of copied) out.addPage(page);
|
|
2807
|
+
const bytes = new Uint8Array(await out.save());
|
|
2808
|
+
payloads.push({
|
|
2809
|
+
bytes,
|
|
2810
|
+
mimeType: MIME.PDF,
|
|
2811
|
+
filename: replaceExtension(ctx.payload.filename, "").replace(/\.$/, "") + `.part${part}.pdf`
|
|
2812
|
+
});
|
|
2813
|
+
part += 1;
|
|
2814
|
+
}
|
|
2815
|
+
return {
|
|
2816
|
+
kind: "media-list",
|
|
2817
|
+
payloads
|
|
2818
|
+
};
|
|
2819
|
+
};
|
|
2820
|
+
/** `merge` — append the referenced media's pages, in order. */
|
|
2821
|
+
var mergeStep = async (ctx) => {
|
|
2822
|
+
requirePdf("merge", ctx.payload);
|
|
2823
|
+
const raw = ctx.step.args.with;
|
|
2824
|
+
const refs = Array.isArray(raw) ? raw : [raw];
|
|
2825
|
+
const { PDFDocument } = await pdfLib();
|
|
2826
|
+
const out = await PDFDocument.create();
|
|
2827
|
+
const appendAll = async (doc) => {
|
|
2828
|
+
const copied = await out.copyPages(doc, doc.getPageIndices());
|
|
2829
|
+
for (const page of copied) out.addPage(page);
|
|
2830
|
+
};
|
|
2831
|
+
await appendAll(await loadPdf("merge", ctx.payload.bytes));
|
|
2832
|
+
for (const ref of refs) {
|
|
2833
|
+
if (ref.kind !== "id") throw new E_MEDIA_STEP_FAILED(["merge", "builder refs are not yet supported here"]);
|
|
2834
|
+
const other = await ctx.resolveRef(ref.id);
|
|
2835
|
+
requirePdf("merge", other);
|
|
2836
|
+
await appendAll(await loadPdf("merge", other.bytes));
|
|
2837
|
+
}
|
|
2838
|
+
const bytes = new Uint8Array(await out.save());
|
|
2839
|
+
return {
|
|
2840
|
+
kind: "media",
|
|
2841
|
+
payload: {
|
|
2842
|
+
...ctx.payload,
|
|
2843
|
+
bytes
|
|
2844
|
+
}
|
|
2845
|
+
};
|
|
2846
|
+
};
|
|
2847
|
+
/** `reorder` — rebuild the document with pages in the given 1-based order. */
|
|
2848
|
+
var reorderStep = async (ctx) => {
|
|
2849
|
+
requirePdf("reorder", ctx.payload);
|
|
2850
|
+
const order = argOf(ctx.step, "order");
|
|
2851
|
+
const source = await loadPdf("reorder", ctx.payload.bytes);
|
|
2852
|
+
const pageCount = source.getPageCount();
|
|
2853
|
+
checkIndices("reorder", order, pageCount);
|
|
2854
|
+
if (order.length !== pageCount) throw new E_MEDIA_STEP_FAILED(["reorder", `order lists ${order.length} pages but the document has ${pageCount} — list every page exactly once`]);
|
|
2855
|
+
const { PDFDocument } = await pdfLib();
|
|
2856
|
+
const out = await PDFDocument.create();
|
|
2857
|
+
const copied = await out.copyPages(source, order.map((p) => p - 1));
|
|
2858
|
+
for (const page of copied) out.addPage(page);
|
|
2859
|
+
const bytes = new Uint8Array(await out.save());
|
|
2860
|
+
return {
|
|
2861
|
+
kind: "media",
|
|
2862
|
+
payload: {
|
|
2863
|
+
...ctx.payload,
|
|
2864
|
+
bytes
|
|
2865
|
+
}
|
|
2866
|
+
};
|
|
2867
|
+
};
|
|
2868
|
+
//#endregion
|
|
2869
|
+
//#region src/batteries/media/index.ts
|
|
2870
|
+
/**
|
|
2871
|
+
* A knex-inspired local media pipeline: one declarative plan, three front-ends (chainable
|
|
2872
|
+
* builder, pipe string, JSON ops), engines as composable seams.
|
|
2873
|
+
*
|
|
2874
|
+
* @module @nhtio/adk/batteries/media
|
|
2875
|
+
*
|
|
2876
|
+
* @remarks
|
|
2877
|
+
* Most implementations give you two ways to process media — ship it to an external API, or
|
|
2878
|
+
* flood the model's context window with the bytes. This battery is the third option: **full
|
|
2879
|
+
* control of media — local processing, no external APIs by default, cross-environment where
|
|
2880
|
+
* possible.** Your data stays in your application and infrastructure unless you deliberately
|
|
2881
|
+
* wire an engine that sends it elsewhere.
|
|
2882
|
+
*
|
|
2883
|
+
* The entry point is {@link createMediaPipeline}:
|
|
2884
|
+
*
|
|
2885
|
+
* ```ts
|
|
2886
|
+
* import { createMediaPipeline } from '@nhtio/adk/batteries/media'
|
|
2887
|
+
*
|
|
2888
|
+
* const mp = await createMediaPipeline({
|
|
2889
|
+
* engines: [
|
|
2890
|
+
* // an ordered array of self-declaring engines; resolvers run eagerly at construction,
|
|
2891
|
+
* // but each engine's heavy peer dependency loads lazily on first actual use
|
|
2892
|
+
* () => import('@nhtio/adk/batteries/media/engines/jimp').then((m) => m.jimpEngine()),
|
|
2893
|
+
* ],
|
|
2894
|
+
* })
|
|
2895
|
+
*
|
|
2896
|
+
* // chainable builder (implementor DX)
|
|
2897
|
+
* const redacted = await mp(payload)
|
|
2898
|
+
* .select({ pages: [2, 3] })
|
|
2899
|
+
* .redact({ match: [/\d{3}-\d{2}-\d{4}/] })
|
|
2900
|
+
*
|
|
2901
|
+
* // pipe string (the LLM-facing DSL — same plan underneath)
|
|
2902
|
+
* const result = await mp.query(payload, 'select pages=2-3 | redact match=/\\d{3}-\\d{2}-\\d{4}/')
|
|
2903
|
+
* ```
|
|
2904
|
+
*
|
|
2905
|
+
* Media pipelines have no standard — every tool invented its own grammar. LLMs don't share one
|
|
2906
|
+
* mental model of that chaos, so the pipe DSL projects every operation onto the one
|
|
2907
|
+
* transformation idiom every model already groks: shell pipes with `key=value` args. The verbs
|
|
2908
|
+
* a deployment advertises narrow to the engines it configured; everything else fails loud with
|
|
2909
|
+
* a model-actionable message.
|
|
2910
|
+
*/
|
|
2911
|
+
var configSchema = validator.object({
|
|
2912
|
+
engines: validator.array().items(validator.alternatives().try(validator.function(), validator.object().unknown(true))).optional(),
|
|
2913
|
+
selection: validator.array().items(validator.function()).optional(),
|
|
2914
|
+
use: validator.array().items(validator.function()).optional(),
|
|
2915
|
+
resolveRef: validator.function().optional()
|
|
2916
|
+
}).unknown(false);
|
|
2917
|
+
/** The Phase 0 step registry: pure steps available in every deployment. */
|
|
2918
|
+
var PURE_STEPS = [
|
|
2919
|
+
["select", selectStep],
|
|
2920
|
+
["split", splitStep],
|
|
2921
|
+
["merge", mergeStep],
|
|
2922
|
+
["reorder", reorderStep],
|
|
2923
|
+
["redact", redactStep],
|
|
2924
|
+
["update_text", updateTextStep],
|
|
2925
|
+
["diff", diffStep],
|
|
2926
|
+
["apply_patch", applyPatchStep],
|
|
2927
|
+
["extract.text", extractTextStep],
|
|
2928
|
+
["extract.metadata", extractMetadataStep],
|
|
2929
|
+
["chunk", chunkStep],
|
|
2930
|
+
...SHEET_STEPS,
|
|
2931
|
+
...SLIDES_STEPS,
|
|
2932
|
+
...DOC_STEPS,
|
|
2933
|
+
...INGEST_STEPS,
|
|
2934
|
+
...IMAGE_AUDIO_STEPS
|
|
2935
|
+
];
|
|
2936
|
+
/** Resolve one engine entry: invoke a resolver, unwrap `{default}`, guard-check the result. */
|
|
2937
|
+
var resolveEngine = async (entry, index) => {
|
|
2938
|
+
let value = entry;
|
|
2939
|
+
if (typeof value === "function") {
|
|
2940
|
+
try {
|
|
2941
|
+
value = await value();
|
|
2942
|
+
} catch (err) {
|
|
2943
|
+
throw new E_INVALID_MEDIA_PIPELINE_CONFIG([`engines[${index}] resolver failed: ${isError(err) ? err.message : String(err)}`]);
|
|
2944
|
+
}
|
|
2945
|
+
if (isObject(value) && "default" in value && !implementsMediaEngine(value)) value = value.default;
|
|
2946
|
+
}
|
|
2947
|
+
if (!implementsMediaEngine(value)) {
|
|
2948
|
+
const candidate = value;
|
|
2949
|
+
throw new E_INVALID_MEDIA_PIPELINE_CONFIG([`engines[${index}]${isObject(value) && typeof candidate?.id === "string" ? ` ("${candidate.id}")` : ""} does not implement the MediaEngine contract (a string id plus at least one well-formed converts/mutates capability entry)`]);
|
|
2950
|
+
}
|
|
2951
|
+
return value;
|
|
2952
|
+
};
|
|
2953
|
+
/**
|
|
2954
|
+
* Create a media pipeline.
|
|
2955
|
+
*
|
|
2956
|
+
* @remarks
|
|
2957
|
+
* Config is validated eagerly, and so are the engines: every resolver in the array runs at
|
|
2958
|
+
* construction (capability declarations drive verb narrowing, so they must be known up
|
|
2959
|
+
* front), each result is contract-checked, and failures throw
|
|
2960
|
+
* `E_INVALID_MEDIA_PIPELINE_CONFIG` naming the offending index. Construction stays cheap —
|
|
2961
|
+
* bundled engine modules import their heavy peer dependencies lazily inside capability
|
|
2962
|
+
* methods, on first actual use.
|
|
2963
|
+
*
|
|
2964
|
+
* @param config - Engines, selection stages, step interceptors, and the media-ref resolver.
|
|
2965
|
+
* @returns The callable {@link MediaPipeline}.
|
|
2966
|
+
*/
|
|
2967
|
+
var createMediaPipeline = async (config = {}) => {
|
|
2968
|
+
const { error } = configSchema.validate(config, { abortEarly: true });
|
|
2969
|
+
if (error) throw new E_INVALID_MEDIA_PIPELINE_CONFIG([error.message]);
|
|
2970
|
+
const use = config.use ?? [];
|
|
2971
|
+
const resolveRef = config.resolveRef ?? (() => {
|
|
2972
|
+
throw new E_INVALID_MEDIA_PIPELINE_CONFIG(["this chain references another media (@id) but no resolveRef was configured"]);
|
|
2973
|
+
});
|
|
2974
|
+
const registry = buildEngineRegistry(await Promise.all((config.engines ?? []).map(resolveEngine)), config.selection ?? []);
|
|
2975
|
+
const steps = new Map(PURE_STEPS);
|
|
2976
|
+
const run = async (input, plan, options) => {
|
|
2977
|
+
const refResolver = options?.resolveRef ?? resolveRef;
|
|
2978
|
+
return executePlan(plan, {
|
|
2979
|
+
input,
|
|
2980
|
+
steps,
|
|
2981
|
+
use,
|
|
2982
|
+
engines: registry,
|
|
2983
|
+
resolveRef: async (id) => refResolver(id),
|
|
2984
|
+
signal: options?.signal
|
|
2985
|
+
});
|
|
2986
|
+
};
|
|
2987
|
+
const compile = (statement) => typeof statement === "string" ? validateSegments(parsePipeRaw(statement), { capabilities: registry }) : validateOps(statement, { capabilities: registry });
|
|
2988
|
+
const callable = ((input) => {
|
|
2989
|
+
return new MediaChain(async (ops) => {
|
|
2990
|
+
return run(input, validateOps(ops, { capabilities: registry }));
|
|
2991
|
+
});
|
|
2992
|
+
});
|
|
2993
|
+
Object.defineProperties(callable, {
|
|
2994
|
+
query: { value: async (input, q, options) => {
|
|
2995
|
+
return run(input, validateSegments(parsePipeRaw(q), { capabilities: registry }), options);
|
|
2996
|
+
} },
|
|
2997
|
+
ops: { value: async (input, ops, options) => {
|
|
2998
|
+
return run(input, validateOps(ops, { capabilities: registry }), options);
|
|
2999
|
+
} },
|
|
3000
|
+
compile: { value: compile },
|
|
3001
|
+
capabilities: { value: registry },
|
|
3002
|
+
engines: { value: registry.engines }
|
|
3003
|
+
});
|
|
3004
|
+
return callable;
|
|
3005
|
+
};
|
|
3006
|
+
//#endregion
|
|
3007
|
+
export { EXT_TO_MIME, E_INVALID_MEDIA_PIPELINE_CONFIG, E_MEDIA_BAD_ARG, E_MEDIA_ENGINE_REQUIRED, E_MEDIA_MISSING_ARG, E_MEDIA_NOT_PIPE_EXPRESSIBLE, E_MEDIA_PIPE_SYNTAX, E_MEDIA_STEP_FAILED, E_MEDIA_STEP_UNAVAILABLE, E_MEDIA_UNKNOWN_ARG, E_MEDIA_UNKNOWN_VERB, E_MEDIA_UNSUPPORTED_OP, FOLDED_VERBS, MIME, MediaChain, PCM_MIME, VERBS, VERB_INDEX, availableVerbs, buildEngineRegistry, bytesToPcm, createMediaPipeline, familyOf, foldVerb, fromOps, implementsMediaEngine, isMediaRef, isRegExpRef, lowerSegments, parsePipeRaw, pcmToBytes, replaceExtension, suggestVerbs, toOps, toPipe, unsupportedForMutationReason, validateOps, validateSegments };
|
|
3008
|
+
|
|
3009
|
+
//# sourceMappingURL=media.mjs.map
|