document-operations 0.0.0 → 1.0.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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/index.cjs +1010 -0
- package/dist/index.d.cts +2259 -0
- package/dist/index.d.ts +2259 -0
- package/dist/index.js +973 -0
- package/package.json +106 -2
package/dist/index.js
ADDED
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
import { DocumentFormatSchema, UnrecognizedDocumentSchemaError, base64ToBytes, buildDocumentBytes, bytesToBase64, collectDocumentFormulas, createDocx, createLocalDocumentConverter, createMarkdownEditor, createOdg, createOdp, createOds, createOdt, createPdf, createPptx, decodeMarkdownText, decodeOdbPackage, decodePackage, describeFontFace, documentFromJson, documentSchemaKindOf, encodeMarkdownText, evaluateSelect, extractSourceFontsForFormat, odbReportToDocx, odbReportToOdt, odbReportToPdf, odbToCsv, odbToXlsx, odmToPdf, openDocx, openMarkdown, openOdt, parseSelect, readDocumentMetadata, readDocxExtras, readNativeDocumentTree, readOdbForms, readOdbInventory, readOdbReportContent, readOdbReports, readOdbTables, readPdf, rgbHexToColor, setDocumentMetadata } from "documents.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { evaluate } from "document-compute.js";
|
|
5
|
+
import { ContentDocumentSchema, EvaluationValueSchema, FormulaBindingsSchema, flattenTree } from "document-schema.js";
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
import { buildOutline, isOutlineNode, outlineLeafText } from "document-outline.js";
|
|
9
|
+
//#region src/operation.ts
|
|
10
|
+
/**
|
|
11
|
+
* Builds a DocumentOperation whose input and output types are both inferred from real Zod schemas, so a call site never has to repeat `z.infer<typeof Schema>` itself. Use `defineOperationWithoutOutputSchema` instead for the rare operation whose original MCP tool registration declared no outputSchema at all.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately returns the parameter object's own inferred literal type rather than an explicit `DocumentOperation<...>` return annotation. Zod 4's ZodType carries its own output type as one of three generic parameters (ZodType<Output, Input, Internals>), and TypeScript cannot prove, from inside this function body, that a generic `InputSchema extends z.ZodType` also extends `z.ZodType<z.infer<InputSchema>>` -- the two-step indirection through an abstract type parameter defeats it even though it is true for every concrete schema. Letting the literal's own type flow out instead sidesteps that: a caller's own concrete schema (e.g. `ConvertDocumentInputSchema`) is trivially assignable to `DocumentOperation<In, Out>`'s `z.ZodType<In>` field wherever that supertype is actually needed (the registry array below, or a future MCP/REST/CLI adapter parameter), because widening a CONCRETE schema's output type to line up is a normal covariant check, not the same self-referential generic one.
|
|
14
|
+
*/
|
|
15
|
+
function defineOperation(operation) {
|
|
16
|
+
return operation;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The `defineOperation` counterpart for an operation with no output schema to infer from (the original MCP tool registration declared none) -- `Out` is inferred from `run`'s own return type instead. See `defineOperation`'s own comment for why this returns the parameter object's inferred literal type rather than an explicit `DocumentOperation<...>` annotation.
|
|
20
|
+
*/
|
|
21
|
+
function defineOperationWithoutOutputSchema(operation) {
|
|
22
|
+
return operation;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/io/document-input.ts
|
|
26
|
+
const EXTENSION_TO_FORMAT = {
|
|
27
|
+
docx: "docx",
|
|
28
|
+
dotx: "docx",
|
|
29
|
+
docm: "docx",
|
|
30
|
+
pptx: "pptx",
|
|
31
|
+
potx: "pptx",
|
|
32
|
+
pptm: "pptx",
|
|
33
|
+
xlsx: "xlsx",
|
|
34
|
+
xltx: "xlsx",
|
|
35
|
+
xlsm: "xlsx",
|
|
36
|
+
odt: "odt",
|
|
37
|
+
ott: "odt",
|
|
38
|
+
odp: "odp",
|
|
39
|
+
otp: "odp",
|
|
40
|
+
ods: "ods",
|
|
41
|
+
ots: "ods",
|
|
42
|
+
odg: "odg",
|
|
43
|
+
otg: "odg",
|
|
44
|
+
odf: "odf",
|
|
45
|
+
otf: "odf",
|
|
46
|
+
markdown: "markdown",
|
|
47
|
+
md: "markdown",
|
|
48
|
+
rtf: "rtf",
|
|
49
|
+
wpd: "wpd",
|
|
50
|
+
doc: "doc",
|
|
51
|
+
xls: "xls",
|
|
52
|
+
ppt: "ppt",
|
|
53
|
+
epub: "epub",
|
|
54
|
+
csv: "csv",
|
|
55
|
+
svg: "svg",
|
|
56
|
+
pdf: "pdf"
|
|
57
|
+
};
|
|
58
|
+
function inferFormatFromExtension(path) {
|
|
59
|
+
const lastSegment = path.split(/[/\\]/).pop() ?? path;
|
|
60
|
+
const dotIndex = lastSegment.lastIndexOf(".");
|
|
61
|
+
if (dotIndex <= 0) return;
|
|
62
|
+
const extension = lastSegment.slice(dotIndex + 1).toLowerCase();
|
|
63
|
+
return EXTENSION_TO_FORMAT[extension];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The hybrid input shape every MCP tool that accepts a document accepts: either a filesystem path (format inferred from its extension) or inline base64-encoded bytes (format required, since there is no filename to infer it from).
|
|
67
|
+
*/
|
|
68
|
+
const DocumentInputSchema = z.union([z.object({ path: z.string().describe("Filesystem path to the document to read. The document format is inferred from the file extension.") }), z.object({
|
|
69
|
+
bytesBase64: z.string().describe("Base64-encoded document bytes."),
|
|
70
|
+
format: DocumentFormatSchema.describe("The document format of bytesBase64 -- required, since inline bytes carry no filename to infer it from.")
|
|
71
|
+
})]);
|
|
72
|
+
/**
|
|
73
|
+
* Resolves the hybrid DocumentInput union to concrete bytes and a format: for the path shape, reads the file from disk and infers its format from the extension (throwing if the extension is unrecognised); for the bytesBase64 shape, decodes the inline payload and uses the caller-supplied format directly.
|
|
74
|
+
*/
|
|
75
|
+
async function resolveDocumentInput(input, options) {
|
|
76
|
+
if ("path" in input) {
|
|
77
|
+
const format = inferFormatFromExtension(input.path);
|
|
78
|
+
if (format === void 0) throw new Error(`Could not infer a document format from the file extension of "${input.path}". Recognised extensions: ${Object.keys(EXTENSION_TO_FORMAT).join(", ")}. Pass an explicit format via the bytesBase64 input shape instead.`);
|
|
79
|
+
const buffer = await readFile(input.path, { signal: options?.signal });
|
|
80
|
+
return {
|
|
81
|
+
bytes: new Uint8Array(buffer),
|
|
82
|
+
format
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
bytes: base64ToBytes(input.bytesBase64),
|
|
87
|
+
format: input.format
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/io/document-output.ts
|
|
92
|
+
/**
|
|
93
|
+
* The hybrid output shape every MCP tool that produces a document accepts: an optional filesystem path to write the result to. Omitting it returns the bytes inline, base64-encoded, instead.
|
|
94
|
+
*/
|
|
95
|
+
const DocumentOutputSchema = z.object({ outputPath: z.string().optional().describe("Filesystem path to write the resulting document to. Omit to receive the document bytes inline, base64-encoded, in the tool result instead.") });
|
|
96
|
+
/**
|
|
97
|
+
* Above this many bytes, an inline base64 result is flagged `large: true` so a caller/LLM can see the response is sizeable before deciding whether to consume it directly. Purely advisory: `resolveDocumentOutput` never truncates or refuses to return large bytes, it only flags them -- silently truncating a document would produce a corrupt file with no indication anything was lost. 5 MB is a reasonable default order of magnitude for "an LLM context probably wants to know before this lands inline", well under typical MCP stdio transport limits.
|
|
98
|
+
*/
|
|
99
|
+
const LARGE_RESULT_THRESHOLD_BYTES = 5242880;
|
|
100
|
+
/**
|
|
101
|
+
* Resolves output bytes against the hybrid DocumentOutput shape: writes to `outputPath` and reports the path plus byte length when one is given, otherwise returns the bytes inline as base64 (flagged `large: true` above `LARGE_RESULT_THRESHOLD_BYTES`, never truncated or refused).
|
|
102
|
+
*/
|
|
103
|
+
async function resolveDocumentOutput(bytes, output) {
|
|
104
|
+
if (output.outputPath !== void 0) {
|
|
105
|
+
await writeFile(output.outputPath, bytes);
|
|
106
|
+
return {
|
|
107
|
+
path: output.outputPath,
|
|
108
|
+
byteLength: bytes.byteLength
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const bytesBase64 = bytesToBase64(bytes);
|
|
112
|
+
if (bytes.byteLength > 5242880) return {
|
|
113
|
+
bytesBase64,
|
|
114
|
+
byteLength: bytes.byteLength,
|
|
115
|
+
large: true
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
bytesBase64,
|
|
119
|
+
byteLength: bytes.byteLength
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/operations/convert.ts
|
|
124
|
+
const DiagnosticSchema = z.object({
|
|
125
|
+
severity: z.enum(["info", "warning"]),
|
|
126
|
+
code: z.string(),
|
|
127
|
+
message: z.string(),
|
|
128
|
+
pageIndex: z.number().optional()
|
|
129
|
+
});
|
|
130
|
+
const FontSubstitutionSchema$1 = z.object({
|
|
131
|
+
requestedFamily: z.string(),
|
|
132
|
+
requestedBold: z.boolean(),
|
|
133
|
+
requestedItalic: z.boolean(),
|
|
134
|
+
reason: z.enum(["missing-face", "vendored-substitute"]),
|
|
135
|
+
resolvedFamily: z.string()
|
|
136
|
+
});
|
|
137
|
+
const ResolvedDocumentOutputSchema$3 = z.union([z.object({
|
|
138
|
+
path: z.string(),
|
|
139
|
+
byteLength: z.number()
|
|
140
|
+
}), z.object({
|
|
141
|
+
bytesBase64: z.string(),
|
|
142
|
+
byteLength: z.number(),
|
|
143
|
+
large: z.literal(true).optional()
|
|
144
|
+
})]);
|
|
145
|
+
const FontInputSchema = z.object({
|
|
146
|
+
family: z.string().describe("The font family name this face provides."),
|
|
147
|
+
bold: z.boolean().describe("Whether this face is the bold weight."),
|
|
148
|
+
italic: z.boolean().describe("Whether this face is the italic slope."),
|
|
149
|
+
bytesBase64: z.string().describe("Base64-encoded font program bytes (TrueType/OpenType/CFF) for this family/weight/style combination.")
|
|
150
|
+
});
|
|
151
|
+
const ConvertDocumentInputSchema = z.object({
|
|
152
|
+
source: DocumentInputSchema.describe("The document to convert."),
|
|
153
|
+
targetFormat: DocumentFormatSchema.describe("The format to convert the document to. Not every (source, targetFormat) pair is supported directly -- call list_document_conversions first to confirm this one is."),
|
|
154
|
+
output: DocumentOutputSchema.optional().describe("Where to write the converted document. Omit entirely to receive the bytes inline, base64-encoded, instead."),
|
|
155
|
+
fonts: z.array(FontInputSchema).optional().describe("Extra font faces to make available to the conversion, for a family the source document does not already embed. Only consulted by a conversion that runs a layout engine (a <format>-to-pdf conversion); every other conversion ignores this option entirely."),
|
|
156
|
+
onSubstitutionDiagnostics: z.boolean().optional().describe("When true, additionally report each individual font-substitution event as structured fontSubstitutions (which family/weight/style was requested, what it resolved to instead, and why). Every substitution is always reported as a plain diagnostic in `diagnostics` regardless of this flag -- this only controls whether the fuller, structured event is also collected."),
|
|
157
|
+
images: z.record(z.string(), z.string()).optional().describe("A map from a markdown image destination (the part in the parentheses of ) to its base64-encoded PNG/JPEG bytes, for resolving a markdown source's own non-data: images. Only consulted by a markdown-sourced conversion; every other conversion ignores it. A destination absent from the map degrades to alt text, matching documents.js's own MarkdownImageResolver port -- a caller with no filesystem context has no other way to supply a relative path's bytes.")
|
|
158
|
+
});
|
|
159
|
+
const ConvertDocumentOutputSchema = z.object({
|
|
160
|
+
targetFormat: DocumentFormatSchema,
|
|
161
|
+
output: ResolvedDocumentOutputSchema$3,
|
|
162
|
+
diagnostics: z.array(DiagnosticSchema),
|
|
163
|
+
fontSubstitutions: z.array(FontSubstitutionSchema$1).optional()
|
|
164
|
+
});
|
|
165
|
+
const ListDocumentConversionsInputSchema = z.object({});
|
|
166
|
+
const ListDocumentConversionsOutputSchema = z.object({ conversions: z.array(z.object({
|
|
167
|
+
source: DocumentFormatSchema,
|
|
168
|
+
target: DocumentFormatSchema
|
|
169
|
+
})) });
|
|
170
|
+
const converter = createLocalDocumentConverter();
|
|
171
|
+
const convertDocumentOperation = defineOperation({
|
|
172
|
+
name: "convert_document",
|
|
173
|
+
title: "Convert document",
|
|
174
|
+
description: "Converts a document from one supported format to another via documents.js's DocumentConverter port -- docx, pptx, xlsx, odt, odp, ods, odg, odf, markdown, rtf, wpd, doc, xls, ppt, epub, csv, svg, and pdf. Not every (source, targetFormat) pair is supported directly (odf, for instance, only ever converts to pdf); call list_document_conversions first to see which pairs actually are.",
|
|
175
|
+
inputSchema: ConvertDocumentInputSchema,
|
|
176
|
+
outputSchema: ConvertDocumentOutputSchema,
|
|
177
|
+
async run({ source, targetFormat, output, fonts, onSubstitutionDiagnostics, images }, context) {
|
|
178
|
+
const signal = context?.signal;
|
|
179
|
+
const { bytes, format } = await resolveDocumentInput(source, { signal });
|
|
180
|
+
const fontSubstitutions = [];
|
|
181
|
+
const conversionSignal = signal ?? new AbortController().signal;
|
|
182
|
+
const result = await converter.convert({
|
|
183
|
+
source: {
|
|
184
|
+
format,
|
|
185
|
+
bytes
|
|
186
|
+
},
|
|
187
|
+
targetFormat
|
|
188
|
+
}, {
|
|
189
|
+
signal: conversionSignal,
|
|
190
|
+
fonts: fonts?.map((font) => ({
|
|
191
|
+
family: font.family,
|
|
192
|
+
bold: font.bold,
|
|
193
|
+
italic: font.italic,
|
|
194
|
+
bytes: base64ToBytes(font.bytesBase64)
|
|
195
|
+
})),
|
|
196
|
+
onFontSubstitution: onSubstitutionDiagnostics === true ? (substitution) => fontSubstitutions.push(substitution) : void 0,
|
|
197
|
+
images: images === void 0 ? void 0 : (destination) => {
|
|
198
|
+
const base64 = images[destination];
|
|
199
|
+
return base64 === void 0 ? void 0 : { bytes: base64ToBytes(base64) };
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
const resolvedOutput = await resolveDocumentOutput(result.document.bytes, output ?? {});
|
|
203
|
+
return {
|
|
204
|
+
targetFormat: result.document.format,
|
|
205
|
+
output: resolvedOutput,
|
|
206
|
+
diagnostics: [...result.diagnostics],
|
|
207
|
+
...onSubstitutionDiagnostics === true ? { fontSubstitutions: [...fontSubstitutions] } : {}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
const listDocumentConversionsOperation = defineOperation({
|
|
212
|
+
name: "list_document_conversions",
|
|
213
|
+
title: "List document conversions",
|
|
214
|
+
description: "Lists every (source, target) format pair convert_document actually supports, straight from documents.js's own DocumentConverter port -- the definitive source of truth for what convert_document will and will not accept as a (source, targetFormat) combination.",
|
|
215
|
+
inputSchema: ListDocumentConversionsInputSchema,
|
|
216
|
+
outputSchema: ListDocumentConversionsOutputSchema,
|
|
217
|
+
run() {
|
|
218
|
+
return Promise.resolve({ conversions: converter.conversions.map((conversion) => ({
|
|
219
|
+
source: conversion.source,
|
|
220
|
+
target: conversion.target
|
|
221
|
+
})) });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/operations/compute-formula.ts
|
|
226
|
+
function evaluateFormula(formula, bindings, symbolTable) {
|
|
227
|
+
if (formula.content === void 0) return { status: "no-content" };
|
|
228
|
+
try {
|
|
229
|
+
return {
|
|
230
|
+
status: "evaluated",
|
|
231
|
+
result: evaluate(formula.content, bindings, symbolTable)
|
|
232
|
+
};
|
|
233
|
+
} catch (error) {
|
|
234
|
+
return {
|
|
235
|
+
status: "error",
|
|
236
|
+
errorType: error instanceof Error ? error.name : String(error),
|
|
237
|
+
message: error instanceof Error ? error.message : String(error)
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const FormulaOutcomeSchema = z.union([
|
|
242
|
+
z.object({
|
|
243
|
+
status: z.literal("evaluated"),
|
|
244
|
+
result: EvaluationValueSchema
|
|
245
|
+
}),
|
|
246
|
+
z.object({ status: z.literal("no-content") }),
|
|
247
|
+
z.object({
|
|
248
|
+
status: z.literal("error"),
|
|
249
|
+
errorType: z.string(),
|
|
250
|
+
message: z.string()
|
|
251
|
+
})
|
|
252
|
+
]);
|
|
253
|
+
const documentKindValues = ContentDocumentSchema.options.map((option) => option.shape.kind.value);
|
|
254
|
+
const ComputeFormulaOutputSchema = z.object({
|
|
255
|
+
sourceFormat: DocumentFormatSchema,
|
|
256
|
+
documentKind: z.enum(documentKindValues),
|
|
257
|
+
formulaCount: z.number(),
|
|
258
|
+
formulas: z.array(z.object({
|
|
259
|
+
index: z.number(),
|
|
260
|
+
sourcePath: z.string().optional(),
|
|
261
|
+
locate: z.string(),
|
|
262
|
+
latex: z.string().optional(),
|
|
263
|
+
outcome: FormulaOutcomeSchema
|
|
264
|
+
}))
|
|
265
|
+
});
|
|
266
|
+
const computeFormulaOperation = defineOperation({
|
|
267
|
+
name: "compute_formula",
|
|
268
|
+
title: "Compute document formulas",
|
|
269
|
+
description: "Reads every formula a document embeds (docx/odt/markdown paragraphs and pptx/odp slide shapes; a table cell's own blocks too -- docx's own reader recovers a real equation nested inside a table cell; structurally, the walk also covers a drawing page's own shape flow, though no writer in the family populates a formula there today; a spreadsheet's own cell-anchored formula objects; a standalone .odf formula document; and a formula nested inside another embedded object at any depth, e.g. a formula embedded in a drawing embedded in a spreadsheet) and evaluates each through document-compute.js's units-typed evaluate() -- a caller's way to check whether a document's stated arithmetic actually checks out. A formula referencing a symbol (e.g. F = m * a) needs that symbol's value supplied via bindings, keyed by the document's own symbol-table id (see the document's symbolTable.symbols[].id, or a returned formula's own latex to identify which symbol is which); a formula with no free symbols (units and numeric literals only) evaluates with no bindings at all. Each formula reports its own outcome independently -- 'evaluated' with the result, 'no-content' when the formula was never lowered to a computable MathExpression (the common case for a spreadsheet's own embedded formula object, or any format other than markdown, none of which yet stores the semantic layer on disk), or 'error' naming which document-compute.js error it hit (e.g. UnboundSymbolError) -- so one formula needing more bindings never blocks the others. Each entry's own `locate` field is the reliable way to tell two formulas in the same document apart -- a structural path guaranteed unique per formula -- unlike `sourcePath`, which several formats leave undefined or stamp with the identical constant across sibling formulas (markdown's own display-math lowering among them).",
|
|
270
|
+
inputSchema: z.object({
|
|
271
|
+
source: DocumentInputSchema.describe("The document to read formulas from."),
|
|
272
|
+
bindings: FormulaBindingsSchema.optional().describe("Known values for symbols the document's formulas reference, keyed by symbol-table id (a Quantity { kind: 'quantity', magnitude, dimension } or an Interval { kind: 'interval', min, max, dimension } per symbol). Omit for a document whose formulas are fully closed (units and numeric literals only, no 'sym' nodes).")
|
|
273
|
+
}),
|
|
274
|
+
outputSchema: ComputeFormulaOutputSchema,
|
|
275
|
+
async run({ source, bindings }, context) {
|
|
276
|
+
const signal = context?.signal;
|
|
277
|
+
const { bytes, format } = await resolveDocumentInput(source, { signal });
|
|
278
|
+
const tree = readNativeDocumentTree(format, bytes, { signal });
|
|
279
|
+
const document = flattenTree(tree);
|
|
280
|
+
const entries = collectDocumentFormulas(document);
|
|
281
|
+
const resolvedBindings = bindings ?? {};
|
|
282
|
+
const formulas = entries.map((entry, index) => ({
|
|
283
|
+
index,
|
|
284
|
+
sourcePath: entry.sourcePath,
|
|
285
|
+
locate: entry.locate,
|
|
286
|
+
latex: entry.formula.presentation?.latex,
|
|
287
|
+
outcome: evaluateFormula(entry.formula, resolvedBindings, entry.symbolTable)
|
|
288
|
+
}));
|
|
289
|
+
return {
|
|
290
|
+
sourceFormat: format,
|
|
291
|
+
documentKind: document.kind,
|
|
292
|
+
formulaCount: formulas.length,
|
|
293
|
+
formulas
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
const docxExtrasOperation = defineOperationWithoutOutputSchema({
|
|
298
|
+
name: "docx_extras",
|
|
299
|
+
title: "Docx extras",
|
|
300
|
+
description: "Reads a docx's own comments, footnotes, header/footer parts, and numbering definitions -- data documents.js's ContentDocument pivot cannot carry, so ordinary document-reading operations never see it. Returns the real DocxExtras object (comments/footnotes/headerFooterParts/sectionHeaderFooters/numbering) as structured data.",
|
|
301
|
+
inputSchema: z.object({ source: DocumentInputSchema.describe("The docx document to read.") }),
|
|
302
|
+
async run({ source }) {
|
|
303
|
+
const { bytes } = await resolveDocumentInput(source);
|
|
304
|
+
const pkg = decodePackage(bytes);
|
|
305
|
+
return readDocxExtras(pkg);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/operations/editor.ts
|
|
310
|
+
const WritableFormatSchema = z.enum([
|
|
311
|
+
"docx",
|
|
312
|
+
"pptx",
|
|
313
|
+
"odt",
|
|
314
|
+
"odp",
|
|
315
|
+
"ods",
|
|
316
|
+
"odg",
|
|
317
|
+
"pdf",
|
|
318
|
+
"markdown"
|
|
319
|
+
]);
|
|
320
|
+
const RunSchema = z.object({
|
|
321
|
+
text: z.string().optional().describe("The run's own text content."),
|
|
322
|
+
bold: z.boolean().optional(),
|
|
323
|
+
italic: z.boolean().optional(),
|
|
324
|
+
strike: z.boolean().optional(),
|
|
325
|
+
underline: z.boolean().optional().describe("docx/odt only."),
|
|
326
|
+
fontFamily: z.string().optional().describe("docx/odt only."),
|
|
327
|
+
sizePt: z.number().positive().optional().describe("docx/odt only."),
|
|
328
|
+
colorHex: z.string().regex(/^#?[0-9a-fA-F]{6}$/).optional().describe("6-digit hex colour (e.g. 'ff0000' or '#ff0000'). docx/odt only."),
|
|
329
|
+
hyperlink: z.string().optional().describe("markdown only."),
|
|
330
|
+
code: z.boolean().optional().describe("markdown only -- renders the run as an inline code span.")
|
|
331
|
+
});
|
|
332
|
+
const ParagraphSchema = z.object({
|
|
333
|
+
text: z.string().optional().describe("Plain paragraph text, as a single run. Omit and use `runs` instead for mixed formatting within one paragraph."),
|
|
334
|
+
styleId: z.string().optional(),
|
|
335
|
+
headingLevel: z.number().int().min(1).max(6).optional().describe("docx/odt only."),
|
|
336
|
+
alignment: z.enum([
|
|
337
|
+
"left",
|
|
338
|
+
"center",
|
|
339
|
+
"right",
|
|
340
|
+
"justify"
|
|
341
|
+
]).optional().describe("docx/odt only."),
|
|
342
|
+
runs: z.array(RunSchema).optional().describe("Runs to append to this paragraph, each with its own formatting. Combine with `text` for a paragraph that opens with plain text before its first formatted run, or omit `text` entirely for a paragraph built purely from runs.")
|
|
343
|
+
});
|
|
344
|
+
function unsupportedFieldNames(input, unsupported) {
|
|
345
|
+
return unsupported.filter((key) => input[key] !== void 0);
|
|
346
|
+
}
|
|
347
|
+
function rejectUnsupportedFields(input, unsupported, formatLabel, context) {
|
|
348
|
+
const present = unsupportedFieldNames(input, unsupported);
|
|
349
|
+
if (present.length > 0) throw new Error(`${context} does not support ${present.join(", ")} for ${formatLabel} -- remove ${present.length === 1 ? "it" : "them"} or target docx/odt instead.`);
|
|
350
|
+
}
|
|
351
|
+
const DOCX_ODT_UNSUPPORTED_RUN_FIELDS = ["hyperlink", "code"];
|
|
352
|
+
const MARKDOWN_UNSUPPORTED_RUN_FIELDS = [
|
|
353
|
+
"underline",
|
|
354
|
+
"fontFamily",
|
|
355
|
+
"sizePt",
|
|
356
|
+
"colorHex"
|
|
357
|
+
];
|
|
358
|
+
const MARKDOWN_UNSUPPORTED_PARAGRAPH_FIELDS = ["headingLevel", "alignment"];
|
|
359
|
+
function appendDocxOdtRun(paragraph, run) {
|
|
360
|
+
rejectUnsupportedFields(run, DOCX_ODT_UNSUPPORTED_RUN_FIELDS, "docx/odt", "A run");
|
|
361
|
+
paragraph.appendRun({
|
|
362
|
+
text: run.text,
|
|
363
|
+
bold: run.bold,
|
|
364
|
+
italic: run.italic,
|
|
365
|
+
underline: run.underline,
|
|
366
|
+
strike: run.strike,
|
|
367
|
+
fontFamily: run.fontFamily,
|
|
368
|
+
sizePt: run.sizePt,
|
|
369
|
+
color: run.colorHex === void 0 ? void 0 : rgbHexToColor(run.colorHex)
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function appendDocxOdtParagraphs(body, paragraphs) {
|
|
373
|
+
for (const paragraph of paragraphs) {
|
|
374
|
+
const built = body.appendParagraph({
|
|
375
|
+
text: paragraph.text,
|
|
376
|
+
styleId: paragraph.styleId,
|
|
377
|
+
headingLevel: paragraph.headingLevel,
|
|
378
|
+
alignment: paragraph.alignment
|
|
379
|
+
});
|
|
380
|
+
for (const run of paragraph.runs ?? []) appendDocxOdtRun(built, run);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function appendMarkdownParagraphs(body, paragraphs) {
|
|
384
|
+
for (const paragraph of paragraphs) {
|
|
385
|
+
rejectUnsupportedFields(paragraph, MARKDOWN_UNSUPPORTED_PARAGRAPH_FIELDS, "markdown", "A paragraph");
|
|
386
|
+
const built = body.appendParagraph({
|
|
387
|
+
text: paragraph.text,
|
|
388
|
+
styleId: paragraph.styleId
|
|
389
|
+
});
|
|
390
|
+
for (const run of paragraph.runs ?? []) {
|
|
391
|
+
rejectUnsupportedFields(run, MARKDOWN_UNSUPPORTED_RUN_FIELDS, "markdown", "A run");
|
|
392
|
+
built.appendRun({
|
|
393
|
+
text: run.text,
|
|
394
|
+
bold: run.bold,
|
|
395
|
+
italic: run.italic,
|
|
396
|
+
strike: run.strike,
|
|
397
|
+
hyperlink: run.hyperlink,
|
|
398
|
+
code: run.code
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function createDocumentBytes(format) {
|
|
404
|
+
switch (format) {
|
|
405
|
+
case "docx": return createDocx().toBytes();
|
|
406
|
+
case "pptx": return createPptx().toBytes();
|
|
407
|
+
case "odt": return createOdt().toBytes();
|
|
408
|
+
case "odp": return createOdp().toBytes();
|
|
409
|
+
case "ods": return createOds().toBytes();
|
|
410
|
+
case "odg": return createOdg().toBytes();
|
|
411
|
+
case "pdf": return createPdf().toBytes();
|
|
412
|
+
case "markdown": return encodeMarkdownText(createMarkdownEditor().toMarkdownText());
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const ResolvedDocumentOutputSchema$2 = z.union([z.object({
|
|
416
|
+
path: z.string(),
|
|
417
|
+
byteLength: z.number()
|
|
418
|
+
}), z.object({
|
|
419
|
+
bytesBase64: z.string(),
|
|
420
|
+
byteLength: z.number(),
|
|
421
|
+
large: z.literal(true).optional()
|
|
422
|
+
})]);
|
|
423
|
+
const documentCreateOperation = defineOperation({
|
|
424
|
+
name: "document_create",
|
|
425
|
+
title: "Create a blank document",
|
|
426
|
+
description: "Creates a fresh, blank document in the given format via documents.js's own live-view editors (createDocx/createOdt/createMarkdownEditor/...), the same construction document_append_paragraphs's own edit calls build on. Returns an empty document with no content -- follow with document_append_paragraphs (docx/odt/markdown) to add text.",
|
|
427
|
+
inputSchema: z.object({
|
|
428
|
+
format: WritableFormatSchema.describe("The format of document to create."),
|
|
429
|
+
output: DocumentOutputSchema.optional().describe("Where to write the created document. Omit entirely to receive the bytes inline, base64-encoded.")
|
|
430
|
+
}),
|
|
431
|
+
outputSchema: ResolvedDocumentOutputSchema$2,
|
|
432
|
+
async run({ format, output }) {
|
|
433
|
+
return resolveDocumentOutput(createDocumentBytes(format), output ?? {});
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
const documentAppendParagraphsOperation = defineOperation({
|
|
437
|
+
name: "document_append_paragraphs",
|
|
438
|
+
title: "Append paragraphs to a wordprocessing document",
|
|
439
|
+
description: "Appends one or more paragraphs -- each optionally built from several independently-formatted runs -- to the end of a docx, odt, or markdown document, through documents.js's own live-view editors (the same DocxBody.appendParagraph/OdtBody.appendParagraph/MarkdownBody.appendParagraph document-cli's own TUI uses). Does not convert format -- the source document's own format and targetFormat must match. docx and odt share an identical field set (underline, fontFamily, sizePt, colorHex, headingLevel, alignment all supported); markdown supports a different, smaller set (hyperlink, code) and rejects the docx/odt-only fields outright rather than silently dropping them. This covers wordprocessing paragraph/run editing only -- slides, sheets, drawings, tables, lists, and images are a separate, larger editing surface not exposed here yet.",
|
|
440
|
+
inputSchema: z.object({
|
|
441
|
+
source: DocumentInputSchema.describe("The document to append paragraphs to."),
|
|
442
|
+
targetFormat: z.enum([
|
|
443
|
+
"docx",
|
|
444
|
+
"odt",
|
|
445
|
+
"markdown"
|
|
446
|
+
]).describe("Must match the source document's own format. document_append_paragraphs never converts format."),
|
|
447
|
+
paragraphs: z.array(ParagraphSchema).min(1).describe("The paragraphs to append, in order."),
|
|
448
|
+
output: DocumentOutputSchema.optional().describe("Where to write the edited document. Omit entirely to receive the bytes inline, base64-encoded.")
|
|
449
|
+
}),
|
|
450
|
+
outputSchema: ResolvedDocumentOutputSchema$2,
|
|
451
|
+
async run({ source, targetFormat, paragraphs, output }) {
|
|
452
|
+
const { bytes, format } = await resolveDocumentInput(source);
|
|
453
|
+
if (format !== targetFormat) throw new Error(`source document is "${format}", but targetFormat is "${targetFormat}" -- document_append_paragraphs never converts format, so the two must match.`);
|
|
454
|
+
let resultBytes;
|
|
455
|
+
if (targetFormat === "markdown") {
|
|
456
|
+
const editor = openMarkdown(decodeMarkdownText(bytes));
|
|
457
|
+
appendMarkdownParagraphs(editor.body, paragraphs);
|
|
458
|
+
resultBytes = encodeMarkdownText(editor.toMarkdownText());
|
|
459
|
+
} else {
|
|
460
|
+
const editor = targetFormat === "docx" ? openDocx(bytes) : openOdt(bytes);
|
|
461
|
+
appendDocxOdtParagraphs(editor.body, paragraphs);
|
|
462
|
+
resultBytes = editor.toBytes();
|
|
463
|
+
}
|
|
464
|
+
return resolveDocumentOutput(resultBytes, output ?? {});
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region src/operations/fonts.ts
|
|
469
|
+
const FontFaceSummarySchema = z.object({
|
|
470
|
+
family: z.string(),
|
|
471
|
+
bold: z.boolean(),
|
|
472
|
+
italic: z.boolean(),
|
|
473
|
+
byteLength: z.number()
|
|
474
|
+
});
|
|
475
|
+
const fontsOperation = defineOperation({
|
|
476
|
+
name: "fonts",
|
|
477
|
+
title: "List document fonts",
|
|
478
|
+
description: "Lists every source-embedded font face a docx/pptx/odt/odp/ods/odg document carries (family, weight/style, byte length).",
|
|
479
|
+
inputSchema: z.object({ source: DocumentInputSchema.describe("The docx/pptx/odt/odp/ods/odg document to extract source-embedded font faces from.") }),
|
|
480
|
+
outputSchema: z.object({ faces: z.array(FontFaceSummarySchema) }),
|
|
481
|
+
async run({ source }) {
|
|
482
|
+
const { bytes, format } = await resolveDocumentInput(source);
|
|
483
|
+
return { faces: extractSourceFontsForFormat(format, bytes).map((face) => ({
|
|
484
|
+
family: face.family,
|
|
485
|
+
bold: face.bold,
|
|
486
|
+
italic: face.italic,
|
|
487
|
+
byteLength: face.bytes.length
|
|
488
|
+
})) };
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
const FontFileInputSchema = z.union([z.object({ path: z.string().describe("Filesystem path to the font file (.ttf/.otf) to read.") }), z.object({ bytesBase64: z.string().describe("Base64-encoded font file bytes.") })]);
|
|
492
|
+
async function resolveFontFileInput(input) {
|
|
493
|
+
if ("path" in input) {
|
|
494
|
+
const buffer = await readFile(input.path);
|
|
495
|
+
return {
|
|
496
|
+
bytes: new Uint8Array(buffer),
|
|
497
|
+
source: input.path
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
bytes: base64ToBytes(input.bytesBase64),
|
|
502
|
+
source: "inline font bytes"
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const describeFontFileOperation = defineOperation({
|
|
506
|
+
name: "describe_font_file",
|
|
507
|
+
title: "Describe font file",
|
|
508
|
+
description: "Reads a standalone TrueType/OpenType font file (.ttf/.otf) and reports the family/bold/italic triple it declares about itself.",
|
|
509
|
+
inputSchema: z.object({ source: FontFileInputSchema.describe("The standalone .ttf/.otf font file to inspect -- not a document.") }),
|
|
510
|
+
outputSchema: z.object({
|
|
511
|
+
family: z.string(),
|
|
512
|
+
bold: z.boolean(),
|
|
513
|
+
italic: z.boolean()
|
|
514
|
+
}),
|
|
515
|
+
async run({ source }) {
|
|
516
|
+
const { bytes, source: label } = await resolveFontFileInput(source);
|
|
517
|
+
const face = describeFontFace(bytes, label);
|
|
518
|
+
return {
|
|
519
|
+
family: face.family,
|
|
520
|
+
bold: face.bold,
|
|
521
|
+
italic: face.italic
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/operations/from-package.ts
|
|
527
|
+
async function readSourceBytes(source) {
|
|
528
|
+
if ("path" in source) {
|
|
529
|
+
const buffer = await readFile(source.path);
|
|
530
|
+
return new Uint8Array(buffer);
|
|
531
|
+
}
|
|
532
|
+
return base64ToBytes(source.bytesBase64);
|
|
533
|
+
}
|
|
534
|
+
const fromPackageOperation = defineOperation({
|
|
535
|
+
name: "from_package",
|
|
536
|
+
title: "Build document from package",
|
|
537
|
+
description: "Rebuilds real document bytes in a target format from a DocumentTree previously serialised to JSON (e.g. by a caller's own --dump-package-equivalent step) -- the read side of the DocumentTree round trip a conversion's onDocument callback produces.",
|
|
538
|
+
inputSchema: z.object({
|
|
539
|
+
source: DocumentInputSchema.describe("The DocumentTree JSON to read. 'path' points at a JSON file on disk -- its extension is never used to infer a document format, since the file holds a DocumentTree, not a document. 'bytesBase64' carries the JSON inline; its 'format' field is required by the shared hybrid input shape but unused by this operation."),
|
|
540
|
+
targetFormat: DocumentFormatSchema.describe("The document format to build from the DocumentTree."),
|
|
541
|
+
output: DocumentOutputSchema.optional().describe("Where to write the resulting document. Omit entirely (or omit outputPath within it) to receive the bytes inline instead.")
|
|
542
|
+
}),
|
|
543
|
+
outputSchema: z.union([z.object({
|
|
544
|
+
path: z.string(),
|
|
545
|
+
byteLength: z.number()
|
|
546
|
+
}), z.object({
|
|
547
|
+
bytesBase64: z.string(),
|
|
548
|
+
byteLength: z.number(),
|
|
549
|
+
large: z.literal(true).optional()
|
|
550
|
+
})]),
|
|
551
|
+
async run({ source, targetFormat, output }) {
|
|
552
|
+
const bytes = await readSourceBytes(source);
|
|
553
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
554
|
+
let parsed;
|
|
555
|
+
try {
|
|
556
|
+
parsed = JSON.parse(text);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
throw new Error(`'source' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
559
|
+
}
|
|
560
|
+
let result;
|
|
561
|
+
try {
|
|
562
|
+
result = documentFromJson(parsed);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
if (error instanceof UnrecognizedDocumentSchemaError) throw new Error("'source' has no recognised $schema -- only a file carrying a real DocumentTree (e.g. written by a caller's own --dump-package-equivalent step) can be read back by this operation", { cause: error });
|
|
565
|
+
if (error instanceof z.ZodError) throw new Error(`'source' failed ${documentSchemaKindOf(parsed) ?? "document schema"} validation: ${error.message}`, { cause: error });
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
if (result.kind !== "DocumentTree") throw new Error(`'source' is a ${result.kind}, not a DocumentTree -- only a file carrying a real DocumentTree (e.g. written by a caller's own --dump-package-equivalent step) can be read back by this operation`);
|
|
569
|
+
return resolveDocumentOutput(buildDocumentBytes(result.value, targetFormat), output ?? {});
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
const metadataReadOperation = defineOperationWithoutOutputSchema({
|
|
573
|
+
name: "metadata_read",
|
|
574
|
+
title: "Read document metadata",
|
|
575
|
+
description: "Reads a document's own title/author/subject/keywords/creator/producer/created-and-modified-timestamp metadata. Works across every supported format, including xlsx (read via a throwaway xlsx-to-pdf preview, since documents.js has no dedicated xlsx metadata reader of its own) and odf (a standalone formula document).",
|
|
576
|
+
inputSchema: z.object({ source: DocumentInputSchema.describe("The document to read metadata from.") }),
|
|
577
|
+
async run({ source }) {
|
|
578
|
+
const { bytes, format } = await resolveDocumentInput(source);
|
|
579
|
+
return readDocumentMetadata(format, bytes);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
const ResolvedDocumentOutputSchema = z.union([z.object({
|
|
583
|
+
path: z.string(),
|
|
584
|
+
byteLength: z.number()
|
|
585
|
+
}), z.object({
|
|
586
|
+
bytesBase64: z.string(),
|
|
587
|
+
byteLength: z.number(),
|
|
588
|
+
large: z.literal(true).optional()
|
|
589
|
+
})]);
|
|
590
|
+
const metadataWriteOperation = defineOperation({
|
|
591
|
+
name: "metadata_write",
|
|
592
|
+
title: "Write document metadata",
|
|
593
|
+
description: "Patches a document's own title/author/subject/keywords, leaving every other field and every other flag as-is. Does not convert format -- the source document's own format and targetFormat must match (or both be 'pdf'); odf (a standalone formula document) is rejected outright as either a source or a target, since it has no write path back out at all. Convert the document to a different format first (e.g. with a documents.js conversion operation) if metadata needs to be set on the result of a format change.",
|
|
594
|
+
inputSchema: z.object({
|
|
595
|
+
source: DocumentInputSchema.describe("The document to patch metadata on."),
|
|
596
|
+
targetFormat: DocumentFormatSchema.describe("The format to write the patched document back out as -- must match the source document's own format (or both be 'pdf'). metadata_write never converts format."),
|
|
597
|
+
output: DocumentOutputSchema.optional().describe("Where to write the patched document. Omit entirely to receive the bytes inline, base64-encoded."),
|
|
598
|
+
setTitle: z.string().optional().describe("Set the title field. Omit to leave it exactly as the source document already has it."),
|
|
599
|
+
setAuthor: z.string().optional().describe("Set the author field. Omit to leave it exactly as the source document already has it."),
|
|
600
|
+
setSubject: z.string().optional().describe("Set the subject field. Omit to leave it exactly as the source document already has it."),
|
|
601
|
+
setKeywords: z.array(z.string()).optional().describe("Set the keywords field. Omit to leave it exactly as the source document already has it.")
|
|
602
|
+
}),
|
|
603
|
+
outputSchema: ResolvedDocumentOutputSchema,
|
|
604
|
+
async run({ source, targetFormat, output, setTitle, setAuthor, setSubject, setKeywords }) {
|
|
605
|
+
const { bytes, format } = await resolveDocumentInput(source);
|
|
606
|
+
return resolveDocumentOutput(setDocumentMetadata(format, targetFormat, bytes, {
|
|
607
|
+
title: setTitle,
|
|
608
|
+
author: setAuthor,
|
|
609
|
+
subject: setSubject,
|
|
610
|
+
keywords: setKeywords
|
|
611
|
+
}), output ?? {});
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/operations/odb.ts
|
|
616
|
+
const ODB_SOURCE_DESCRIPTION = ".odb database to read. 'path' points at the .odb file on disk -- its extension is never used to infer a document format, since documents.js deliberately excludes 'odb' from DocumentFormat (an embedded database front end has no single natural target format -- tables, saved queries, and reports are three unrelated output shapes -- see that package's own README). 'bytesBase64' carries the .odb bytes inline; its 'format' field is required by the shared hybrid input shape but unused by every odb operation.";
|
|
617
|
+
async function resolveOdbBytes(source) {
|
|
618
|
+
if ("path" in source) {
|
|
619
|
+
const buffer = await readFile(source.path);
|
|
620
|
+
return new Uint8Array(buffer);
|
|
621
|
+
}
|
|
622
|
+
return base64ToBytes(source.bytesBase64);
|
|
623
|
+
}
|
|
624
|
+
/** Resolves an .odb DocumentInput straight through to a decoded Package (via documents.js's own decodeOdbPackage) -- the shape every read (as opposed to export) odb operation needs. */
|
|
625
|
+
async function resolveOdbPackage(source) {
|
|
626
|
+
return decodeOdbPackage(await resolveOdbBytes(source));
|
|
627
|
+
}
|
|
628
|
+
/** Resolves a saved query's own SQL text by name against the .odb's own db:queries (read via readOdbInventory) -- odb_query's own equivalent of document-cli's resolveQuerySql, for the case where the caller named a saved query rather than supplying SQL directly. Throws, naming every available query, when the name doesn't resolve. */
|
|
629
|
+
function resolveSavedQuerySql(pkg, name) {
|
|
630
|
+
const inventory = readOdbInventory(pkg);
|
|
631
|
+
const saved = inventory.queries.find((candidate) => candidate.name === name);
|
|
632
|
+
if (saved === void 0) {
|
|
633
|
+
const available = inventory.queries.map((candidate) => candidate.name);
|
|
634
|
+
throw new Error(`This .odb declares no saved query named "${name}".${available.length === 0 ? "" : ` Available: ${available.join(", ")}.`}`);
|
|
635
|
+
}
|
|
636
|
+
return saved.command;
|
|
637
|
+
}
|
|
638
|
+
/** Classifies odb_query's own sql/query pair before any package is read: exactly one of the two must be given. Throws for either "both given" or "neither given". Kept as a pure, synchronous check (no Package involved) so both variables narrow cleanly through ordinary control flow, rather than needing a cross-variable inference TypeScript can't derive from two separate `!== undefined` checks. */
|
|
639
|
+
function classifyQueryInput(sql, query) {
|
|
640
|
+
if (sql !== void 0 && query !== void 0) throw new Error("Provide \"sql\" or \"query\", not both.");
|
|
641
|
+
if (sql !== void 0) return {
|
|
642
|
+
kind: "literal",
|
|
643
|
+
sql
|
|
644
|
+
};
|
|
645
|
+
if (query !== void 0) return {
|
|
646
|
+
kind: "saved",
|
|
647
|
+
name: query
|
|
648
|
+
};
|
|
649
|
+
throw new Error("Provide either \"sql\" (a literal SELECT statement) or \"query\" (the name of one of this .odb's own saved queries).");
|
|
650
|
+
}
|
|
651
|
+
const OdbSourceInputSchema = z.object({ source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION) });
|
|
652
|
+
const odbTablesOperation = defineOperationWithoutOutputSchema({
|
|
653
|
+
name: "odb_tables",
|
|
654
|
+
title: "List .odb tables",
|
|
655
|
+
description: "Lists every table an embedded .odb database declares -- column names, types, and row data -- across every storage tier documents.js supports (HSQLDB TEXT/CACHED/BINARY script formats, Firebird gbak backups).",
|
|
656
|
+
inputSchema: OdbSourceInputSchema,
|
|
657
|
+
async run({ source }) {
|
|
658
|
+
return readOdbTables(await resolveOdbPackage(source));
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
const odbFormsOperation = defineOperationWithoutOutputSchema({
|
|
662
|
+
name: "odb_forms",
|
|
663
|
+
title: "List .odb forms",
|
|
664
|
+
description: "Lists every form an .odb database declares, with each form's own data source and field-bound controls.",
|
|
665
|
+
inputSchema: OdbSourceInputSchema,
|
|
666
|
+
async run({ source }) {
|
|
667
|
+
return readOdbForms(await resolveOdbPackage(source));
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
const odbReportsOperation = defineOperationWithoutOutputSchema({
|
|
671
|
+
name: "odb_reports",
|
|
672
|
+
title: "List .odb reports",
|
|
673
|
+
description: "Lists every report an .odb database declares, with each report's own data-source command, band/group structure, and rpt: formula expressions.",
|
|
674
|
+
inputSchema: OdbSourceInputSchema,
|
|
675
|
+
async run({ source }) {
|
|
676
|
+
return readOdbReports(await resolveOdbPackage(source));
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
const odbQueryOperation = defineOperationWithoutOutputSchema({
|
|
680
|
+
name: "odb_query",
|
|
681
|
+
title: "Query an .odb database",
|
|
682
|
+
description: "Runs a bounded SELECT (with optional JOINs of any kind, table aliases, a derived table in FROM, and IN/EXISTS subqueries) over an embedded .odb database's own extracted tables, given directly as SQL or by naming one of the database's saved queries. No database engine is involved -- the query runs in memory over the same tables odb_tables would return, against a closed grammar (SELECT/FROM/JOIN [INNER|LEFT [OUTER]|RIGHT [OUTER]|FULL [OUTER]|CROSS|NATURAL] ... [ON|USING]/WHERE/GROUP BY/ORDER BY, with an optional [AS] alias on any table, WHERE and ON also accepting [NOT] IN (SELECT ...) and [NOT] EXISTS (SELECT ...) -- no column aliases); an unsupported construct is reported as an error naming it, never silently ignored.",
|
|
683
|
+
inputSchema: z.object({
|
|
684
|
+
source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION),
|
|
685
|
+
sql: z.string().describe("A literal SELECT statement to run. Mutually exclusive with \"query\".").optional(),
|
|
686
|
+
query: z.string().describe("The name of one of the .odb's own saved queries to run. Mutually exclusive with \"sql\".").optional()
|
|
687
|
+
}),
|
|
688
|
+
async run({ source, sql, query }) {
|
|
689
|
+
const spec = classifyQueryInput(sql, query);
|
|
690
|
+
const pkg = await resolveOdbPackage(source);
|
|
691
|
+
const resolvedSql = spec.kind === "literal" ? spec.sql : resolveSavedQuerySql(pkg, spec.name);
|
|
692
|
+
return evaluateSelect(parseSelect(resolvedSql), readOdbTables(pkg));
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
const odbToCsvOperation = defineOperationWithoutOutputSchema({
|
|
696
|
+
name: "odb_to_csv",
|
|
697
|
+
title: "Export one .odb table to CSV",
|
|
698
|
+
description: "Extracts exactly one named table from an embedded .odb database as CSV bytes. The table name is required whenever the database declares more than one table.",
|
|
699
|
+
inputSchema: z.object({
|
|
700
|
+
source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION),
|
|
701
|
+
table: z.string().describe("The table to export -- required when the .odb declares more than one table.").optional(),
|
|
702
|
+
output: DocumentOutputSchema.optional().describe("Where to write the resulting CSV. Omit entirely (or omit outputPath within it) to receive the bytes inline instead.")
|
|
703
|
+
}),
|
|
704
|
+
async run({ source, table, output }, context) {
|
|
705
|
+
const bytes = await resolveOdbBytes(source);
|
|
706
|
+
return resolveDocumentOutput(odbToCsv(bytes, {
|
|
707
|
+
signal: context?.signal,
|
|
708
|
+
table
|
|
709
|
+
}), output ?? {});
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
const odbToXlsxOperation = defineOperationWithoutOutputSchema({
|
|
713
|
+
name: "odb_to_xlsx",
|
|
714
|
+
title: "Export .odb tables to xlsx",
|
|
715
|
+
description: "Extracts every table an embedded .odb database declares into one xlsx workbook, one sheet per table.",
|
|
716
|
+
inputSchema: z.object({
|
|
717
|
+
source: DocumentInputSchema.describe(ODB_SOURCE_DESCRIPTION),
|
|
718
|
+
output: DocumentOutputSchema.optional().describe("Where to write the resulting xlsx workbook. Omit entirely (or omit outputPath within it) to receive the bytes inline instead.")
|
|
719
|
+
}),
|
|
720
|
+
async run({ source, output }, context) {
|
|
721
|
+
const bytes = await resolveOdbBytes(source);
|
|
722
|
+
return resolveDocumentOutput(odbToXlsx(bytes, { signal: context?.signal }), output ?? {});
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/operations/odb-render-report.ts
|
|
727
|
+
const OdbRenderReportTargetFormatSchema = z.enum([
|
|
728
|
+
"docx",
|
|
729
|
+
"odt",
|
|
730
|
+
"pdf"
|
|
731
|
+
]);
|
|
732
|
+
const OdbRenderReportInputSchema = z.object({
|
|
733
|
+
source: DocumentInputSchema.describe(".odb database to render a report from. 'path' points at the .odb file on disk -- its extension is never used to infer a document format, since documents.js deliberately excludes 'odb' from DocumentFormat (an embedded database front end has no single natural target format -- tables, saved queries, and reports are three unrelated output shapes -- see that package's own README). 'bytesBase64' carries the .odb bytes inline; its 'format' field is required by the shared hybrid input shape but unused by this operation."),
|
|
734
|
+
report: z.string().optional().describe("The name of the report to render. Required only when the .odb declares more than one report -- omitting it when exactly one is declared renders that one automatically."),
|
|
735
|
+
targetFormat: OdbRenderReportTargetFormatSchema.describe("The format to render the report into."),
|
|
736
|
+
output: DocumentOutputSchema.optional().describe("Where to write the rendered report. Omit entirely (or omit outputPath within it) to receive the bytes inline instead."),
|
|
737
|
+
fonts: z.array(FontInputSchema).optional().describe("Extra font faces to make available, for a family the rendered report's own text otherwise falls back on. Only consulted when targetFormat is 'pdf' -- docx and odt output is genuine editable text with no font-embedding step of its own, so fonts is ignored for those two targets.")
|
|
738
|
+
});
|
|
739
|
+
const MathDiagnosticSchema = z.object({
|
|
740
|
+
kind: z.enum(["unsupported-element", "approximated-element"]),
|
|
741
|
+
detail: z.string(),
|
|
742
|
+
sourcePath: z.string().optional()
|
|
743
|
+
});
|
|
744
|
+
const FontSubstitutionSchema = z.object({
|
|
745
|
+
requestedFamily: z.string(),
|
|
746
|
+
requestedBold: z.boolean(),
|
|
747
|
+
requestedItalic: z.boolean(),
|
|
748
|
+
reason: z.enum(["missing-face", "vendored-substitute"]),
|
|
749
|
+
resolvedFamily: z.string()
|
|
750
|
+
});
|
|
751
|
+
const CharSubstitutionSchema = z.object({
|
|
752
|
+
from: z.string(),
|
|
753
|
+
to: z.string(),
|
|
754
|
+
pageIndex: z.number()
|
|
755
|
+
});
|
|
756
|
+
const diagnosticsShape = {
|
|
757
|
+
mathDiagnostics: z.array(MathDiagnosticSchema),
|
|
758
|
+
fontSubstitutions: z.array(FontSubstitutionSchema),
|
|
759
|
+
charSubstitutions: z.array(CharSubstitutionSchema)
|
|
760
|
+
};
|
|
761
|
+
const OdbRenderReportOutputSchema = z.union([z.object({
|
|
762
|
+
path: z.string(),
|
|
763
|
+
byteLength: z.number(),
|
|
764
|
+
...diagnosticsShape
|
|
765
|
+
}), z.object({
|
|
766
|
+
bytesBase64: z.string(),
|
|
767
|
+
byteLength: z.number(),
|
|
768
|
+
large: z.literal(true).optional(),
|
|
769
|
+
...diagnosticsShape
|
|
770
|
+
})]);
|
|
771
|
+
const odbRenderReportOperation = defineOperation({
|
|
772
|
+
name: "odb_render_report",
|
|
773
|
+
title: "Render .odb report",
|
|
774
|
+
description: "Resolves one of an .odb database's own reports -- its data-bound command run through the bounded SQL engine, its rpt: formulas evaluated, its bands laid out -- and renders the result to docx, odt, or pdf.",
|
|
775
|
+
inputSchema: OdbRenderReportInputSchema,
|
|
776
|
+
outputSchema: OdbRenderReportOutputSchema,
|
|
777
|
+
async run({ source, report, targetFormat, output, fonts }, context) {
|
|
778
|
+
const signal = context?.signal;
|
|
779
|
+
const inputBytes = await resolveOdbBytes(source);
|
|
780
|
+
const pkg = decodeOdbPackage(inputBytes);
|
|
781
|
+
const content = readOdbReportContent(pkg, { report });
|
|
782
|
+
const mathDiagnostics = [];
|
|
783
|
+
const recordMathDiagnostic = (diagnostic, diagnosticContext) => {
|
|
784
|
+
mathDiagnostics.push({
|
|
785
|
+
kind: diagnostic.kind,
|
|
786
|
+
detail: diagnostic.detail,
|
|
787
|
+
sourcePath: diagnosticContext.sourcePath
|
|
788
|
+
});
|
|
789
|
+
};
|
|
790
|
+
const fontSubstitutions = [];
|
|
791
|
+
const charSubstitutions = [];
|
|
792
|
+
let bytes;
|
|
793
|
+
if (targetFormat === "docx") bytes = odbReportToDocx(content, {
|
|
794
|
+
signal,
|
|
795
|
+
onMathDiagnostic: recordMathDiagnostic
|
|
796
|
+
});
|
|
797
|
+
else if (targetFormat === "odt") bytes = odbReportToOdt(content, { signal });
|
|
798
|
+
else bytes = odbReportToPdf(content, {
|
|
799
|
+
signal,
|
|
800
|
+
fonts: fonts?.map((font) => ({
|
|
801
|
+
family: font.family,
|
|
802
|
+
bold: font.bold,
|
|
803
|
+
italic: font.italic,
|
|
804
|
+
bytes: base64ToBytes(font.bytesBase64)
|
|
805
|
+
})),
|
|
806
|
+
onFontSubstitution: (substitution) => fontSubstitutions.push(substitution),
|
|
807
|
+
onSubstitution: (substitution, substitutionContext) => {
|
|
808
|
+
charSubstitutions.push({
|
|
809
|
+
from: substitution.from,
|
|
810
|
+
to: substitution.to,
|
|
811
|
+
pageIndex: substitutionContext.pageIndex
|
|
812
|
+
});
|
|
813
|
+
},
|
|
814
|
+
onMathDiagnostic: recordMathDiagnostic
|
|
815
|
+
});
|
|
816
|
+
return {
|
|
817
|
+
...await resolveDocumentOutput(bytes, output ?? {}),
|
|
818
|
+
mathDiagnostics,
|
|
819
|
+
fontSubstitutions,
|
|
820
|
+
charSubstitutions
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/operations/odm.ts
|
|
826
|
+
const OdmMasterSourceSchema = z.union([z.object({ path: z.string().describe("Filesystem path to the .odm master document to convert.") }), z.object({ bytesBase64: z.string().describe("Base64-encoded .odm master document bytes.") })]);
|
|
827
|
+
async function resolveOdmMasterBytes(source) {
|
|
828
|
+
if ("path" in source) return new Uint8Array(await readFile(source.path));
|
|
829
|
+
return base64ToBytes(source.bytesBase64);
|
|
830
|
+
}
|
|
831
|
+
const OdmChapterInputSchema = z.object({
|
|
832
|
+
href: z.string().describe("The chapter's own text:section-source href as declared inside the .odm master document (e.g. '../chapter1.odt')."),
|
|
833
|
+
source: DocumentInputSchema.describe("The chapter document's own bytes -- always read as odt, regardless of the format this hybrid input declares.")
|
|
834
|
+
});
|
|
835
|
+
const OdmToPdfInputSchema = z.object({
|
|
836
|
+
source: OdmMasterSourceSchema.describe("The .odm master document to convert."),
|
|
837
|
+
chapters: z.array(OdmChapterInputSchema).default([]).describe("Explicit href -> chapter document overrides. Checked before chaptersDir for a given href."),
|
|
838
|
+
chaptersDir: z.string().optional().describe("Directory to search for each unresolved chapter href, matched by the href's own basename. Checked after chapters."),
|
|
839
|
+
output: DocumentOutputSchema.default({}).describe("Where to write the resulting PDF. Omit to receive the bytes inline, base64-encoded.")
|
|
840
|
+
});
|
|
841
|
+
const OdmToPdfOutputSchema = z.union([z.object({
|
|
842
|
+
path: z.string(),
|
|
843
|
+
byteLength: z.number()
|
|
844
|
+
}), z.object({
|
|
845
|
+
bytesBase64: z.string(),
|
|
846
|
+
byteLength: z.number(),
|
|
847
|
+
large: z.literal(true).optional()
|
|
848
|
+
})]);
|
|
849
|
+
const odmToPdfOperation = defineOperation({
|
|
850
|
+
name: "odm_to_pdf",
|
|
851
|
+
title: "Convert ODM master document to PDF",
|
|
852
|
+
description: "Converts a .odm (ODF master document) to PDF. A .odm never carries its own chapters' content inline -- every text:section is a bare external reference to a standalone .odt file -- so each chapter the master document declares must resolve through `chapters` (an explicit href -> document override) and/or `chaptersDir` (a directory searched by the href's own basename), checked in that order. A chapter left unresolved by both fails the whole conversion, naming every unresolved href.",
|
|
853
|
+
inputSchema: OdmToPdfInputSchema,
|
|
854
|
+
outputSchema: OdmToPdfOutputSchema,
|
|
855
|
+
async run({ source, chapters, chaptersDir, output }) {
|
|
856
|
+
const masterBytes = await resolveOdmMasterBytes(source);
|
|
857
|
+
const overrides = /* @__PURE__ */ new Map();
|
|
858
|
+
for (const chapter of chapters) {
|
|
859
|
+
const { bytes: chapterBytes } = await resolveDocumentInput(chapter.source);
|
|
860
|
+
overrides.set(chapter.href, chapterBytes);
|
|
861
|
+
}
|
|
862
|
+
const resolveSubDocument = (href) => {
|
|
863
|
+
const overrideBytes = overrides.get(href);
|
|
864
|
+
if (overrideBytes !== void 0) return overrideBytes;
|
|
865
|
+
if (chaptersDir === void 0) return;
|
|
866
|
+
const candidate = join(chaptersDir, basename(href));
|
|
867
|
+
if (!existsSync(candidate)) return;
|
|
868
|
+
return new Uint8Array(readFileSync(candidate));
|
|
869
|
+
};
|
|
870
|
+
return resolveDocumentOutput(odmToPdf(masterBytes, { resolveSubDocument }), output);
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region src/operations/outline.ts
|
|
875
|
+
function leafKind(leaf) {
|
|
876
|
+
if ("kind" in leaf) return leaf.kind;
|
|
877
|
+
if ("mathml" in leaf) return "formula";
|
|
878
|
+
return "embeddedObject";
|
|
879
|
+
}
|
|
880
|
+
function toOutlineJson(children) {
|
|
881
|
+
return children.map((child) => isOutlineNode(child) ? {
|
|
882
|
+
text: child.text,
|
|
883
|
+
level: child.level,
|
|
884
|
+
children: toOutlineJson(child.children)
|
|
885
|
+
} : {
|
|
886
|
+
kind: leafKind(child),
|
|
887
|
+
text: outlineLeafText(child)
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
const outlineDocumentOperation = defineOperationWithoutOutputSchema({
|
|
891
|
+
name: "outline_document",
|
|
892
|
+
title: "Outline document",
|
|
893
|
+
description: "Projects a document's table of contents as a structured outline: reads the source's own native DocumentTree directly (documents.js's readNativeDocumentTree -- no bridging conversion, no discarded output bytes) and runs document-outline.js's buildOutline over it. Groups carry { text, level, children } (a heading's, list item's, slide's, sheet's, or page's own label plus nested children); leaves carry { kind, text }.",
|
|
894
|
+
inputSchema: z.object({ source: DocumentInputSchema.describe("The document to outline.") }),
|
|
895
|
+
async run({ source }, context) {
|
|
896
|
+
const signal = context?.signal;
|
|
897
|
+
const { bytes, format } = await resolveDocumentInput(source, { signal });
|
|
898
|
+
const tree = readNativeDocumentTree(format, bytes, { signal });
|
|
899
|
+
return {
|
|
900
|
+
sourceFormat: format,
|
|
901
|
+
kind: tree.kind,
|
|
902
|
+
outline: toOutlineJson(buildOutline(tree))
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
//#endregion
|
|
907
|
+
//#region src/operations/pdf-inspect.ts
|
|
908
|
+
function buildItemKindHistogram(items) {
|
|
909
|
+
const histogram = /* @__PURE__ */ new Map();
|
|
910
|
+
for (const item of items) histogram.set(item.kind, (histogram.get(item.kind) ?? 0) + 1);
|
|
911
|
+
return histogram;
|
|
912
|
+
}
|
|
913
|
+
function countImagesByFormat(images) {
|
|
914
|
+
const counts = /* @__PURE__ */ new Map();
|
|
915
|
+
for (const asset of Object.values(images)) counts.set(asset.format, (counts.get(asset.format) ?? 0) + 1);
|
|
916
|
+
return counts;
|
|
917
|
+
}
|
|
918
|
+
const pdfInspectOperation = defineOperationWithoutOutputSchema({
|
|
919
|
+
name: "pdf_inspect",
|
|
920
|
+
title: "Inspect PDF",
|
|
921
|
+
description: "Parses a PDF (documents.js's readPdf) and reports a summary: page count, each page's own size and item-kind histogram, document metadata, and embedded image formats. Pass full: true to return the entire parsed LayoutDocument instead of a summary.",
|
|
922
|
+
inputSchema: z.object({
|
|
923
|
+
source: DocumentInputSchema.describe("The PDF document to inspect."),
|
|
924
|
+
full: z.boolean().optional().describe("When true, return the entire parsed LayoutDocument instead of a summary. Defaults to false.")
|
|
925
|
+
}),
|
|
926
|
+
async run({ source, full }, context) {
|
|
927
|
+
const signal = context?.signal;
|
|
928
|
+
const { bytes, format } = await resolveDocumentInput(source, { signal });
|
|
929
|
+
if (format !== "pdf") throw new Error(`pdf_inspect requires a PDF document, received a "${format}" document instead.`);
|
|
930
|
+
const layout = readPdf(bytes, { signal });
|
|
931
|
+
if (full === true) return layout;
|
|
932
|
+
return {
|
|
933
|
+
pageCount: layout.pages.length,
|
|
934
|
+
pages: layout.pages.map((page) => ({
|
|
935
|
+
widthPt: page.widthPt,
|
|
936
|
+
heightPt: page.heightPt,
|
|
937
|
+
itemKinds: Object.fromEntries(buildItemKindHistogram(page.items))
|
|
938
|
+
})),
|
|
939
|
+
metadata: layout.metadata,
|
|
940
|
+
imagesByFormat: Object.fromEntries(countImagesByFormat(layout.images))
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
//#endregion
|
|
945
|
+
//#region src/registry.ts
|
|
946
|
+
/**
|
|
947
|
+
* Every document operation this package defines, one entry per MCP tool/CLI command/REST route -- "the same registry as the MCP" a consumer that needs to enumerate every operation (rather than importing one by name) reaches for: an MCP server registers each entry as a tool, a REST server adds one route per entry, and a CLI can validate its own parsed flags against an entry's inputSchema before dispatching to its run().
|
|
948
|
+
*/
|
|
949
|
+
const DOCUMENT_OPERATIONS = [
|
|
950
|
+
convertDocumentOperation,
|
|
951
|
+
listDocumentConversionsOperation,
|
|
952
|
+
metadataReadOperation,
|
|
953
|
+
metadataWriteOperation,
|
|
954
|
+
documentCreateOperation,
|
|
955
|
+
documentAppendParagraphsOperation,
|
|
956
|
+
fontsOperation,
|
|
957
|
+
describeFontFileOperation,
|
|
958
|
+
docxExtrasOperation,
|
|
959
|
+
fromPackageOperation,
|
|
960
|
+
outlineDocumentOperation,
|
|
961
|
+
pdfInspectOperation,
|
|
962
|
+
computeFormulaOperation,
|
|
963
|
+
odmToPdfOperation,
|
|
964
|
+
odbTablesOperation,
|
|
965
|
+
odbFormsOperation,
|
|
966
|
+
odbReportsOperation,
|
|
967
|
+
odbQueryOperation,
|
|
968
|
+
odbToCsvOperation,
|
|
969
|
+
odbToXlsxOperation,
|
|
970
|
+
odbRenderReportOperation
|
|
971
|
+
];
|
|
972
|
+
//#endregion
|
|
973
|
+
export { ComputeFormulaOutputSchema, ConvertDocumentOutputSchema, DOCUMENT_OPERATIONS, DocumentInputSchema, DocumentOutputSchema, FontInputSchema, LARGE_RESULT_THRESHOLD_BYTES, ListDocumentConversionsOutputSchema, OdbRenderReportOutputSchema, OdmToPdfOutputSchema, computeFormulaOperation, convertDocumentOperation, defineOperation, defineOperationWithoutOutputSchema, describeFontFileOperation, documentAppendParagraphsOperation, documentCreateOperation, docxExtrasOperation, fontsOperation, fromPackageOperation, inferFormatFromExtension, listDocumentConversionsOperation, metadataReadOperation, metadataWriteOperation, odbFormsOperation, odbQueryOperation, odbRenderReportOperation, odbReportsOperation, odbTablesOperation, odbToCsvOperation, odbToXlsxOperation, odmToPdfOperation, outlineDocumentOperation, pdfInspectOperation, resolveDocumentInput, resolveDocumentOutput, resolveOdbBytes };
|