@tiwater/office-mcp 0.6.0 → 0.8.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/office/README.md +6 -5
- package/office/index.mjs +260 -33
- package/package.json +1 -1
package/office/README.md
CHANGED
|
@@ -10,8 +10,8 @@ Shared stdio MCP server for Office document workflows.
|
|
|
10
10
|
- `docx_migrate_template`
|
|
11
11
|
- `docx_verify_migration`
|
|
12
12
|
- `docx_compare`
|
|
13
|
-
- `docx_validate_template_transform`
|
|
14
13
|
- `docx_export_json`
|
|
14
|
+
- `office_render_pdf`
|
|
15
15
|
- `xlsx_inspect`
|
|
16
16
|
- `xlsx_export_json`
|
|
17
17
|
- `xlsx_validate`
|
|
@@ -23,9 +23,9 @@ Shared stdio MCP server for Office document workflows.
|
|
|
23
23
|
Install `@tiwater/office-mcp` together with the runtime versions required by
|
|
24
24
|
the consumer, then run `tiwater-office-mcp` as a stdio MCP server.
|
|
25
25
|
|
|
26
|
-
The server invokes published `tiwater-docx`, `tiwater-xlsx`,
|
|
27
|
-
`tiwater-pptx` commands from `PATH`. It does not require
|
|
28
|
-
fall back to local projects.
|
|
26
|
+
The server invokes published `tiwater-docx`, `tiwater-xlsx`,
|
|
27
|
+
`tiwater-pptx`, and `tiwater-convert` commands from `PATH`. It does not require
|
|
28
|
+
a source checkout or fall back to local projects.
|
|
29
29
|
|
|
30
30
|
The official MCP SDK derives the schemas advertised to clients and validates
|
|
31
31
|
tool arguments and structured results before they cross the protocol boundary.
|
|
@@ -34,4 +34,5 @@ artifact. MCP returns only the artifact path, hash, and byte count.
|
|
|
34
34
|
Template-migration choice artifacts are opaque evidence. Query the same current
|
|
35
35
|
source and baseline through `docx_query_migration_choices` to page unresolved
|
|
36
36
|
sources, request targets compatible with one business action, or inspect cleanup
|
|
37
|
-
targets.
|
|
37
|
+
targets. Compatible targets stay pageable and are shown in current text and
|
|
38
|
+
local document-context order; the tool does not choose the business mapping.
|
package/office/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { spawn } from 'node:child_process';
|
|
6
7
|
import { isDeepStrictEqual } from 'node:util';
|
|
@@ -30,6 +31,10 @@ const pptxCandidates = [
|
|
|
30
31
|
commandCandidate('tiwater-pptx', [], { cwd: invocationCwd }),
|
|
31
32
|
];
|
|
32
33
|
|
|
34
|
+
const convertCandidates = [
|
|
35
|
+
commandCandidate('tiwater-convert', [], { cwd: invocationCwd }),
|
|
36
|
+
];
|
|
37
|
+
|
|
33
38
|
const pathInput = z.string().trim().min(1);
|
|
34
39
|
const migrationAction = z.enum([
|
|
35
40
|
'place-content',
|
|
@@ -74,7 +79,7 @@ const templateMigrationInput = z.object({
|
|
|
74
79
|
baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
|
|
75
80
|
output: pathInput.describe('Path to the migrated output DOCX.'),
|
|
76
81
|
receiptOutput: pathInput.describe('New JSON receipt artifact path. Existing files are never overwritten.'),
|
|
77
|
-
choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source id
|
|
82
|
+
choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source id: place-content writes the source fact; keep-template-content or keep-template-label preserves the matching baseline-owned content or label; select-template-option carries an option identity; exclude-source requires declared exclusion; review-source is limited to genuine local ambiguity.'),
|
|
78
83
|
templateCleanup: z.array(templateCleanupInput).optional().describe('Optional baseline-owned placeholders or example rows to clear.'),
|
|
79
84
|
}).strict();
|
|
80
85
|
|
|
@@ -136,6 +141,42 @@ const artifact = z.object({
|
|
|
136
141
|
bytes: z.number().int().nonnegative(),
|
|
137
142
|
}).strict();
|
|
138
143
|
|
|
144
|
+
const renderFileIdentity = z.object({
|
|
145
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
146
|
+
size_bytes: z.number().int().positive(),
|
|
147
|
+
}).strict();
|
|
148
|
+
const nativeRenderReceipt = z.object({
|
|
149
|
+
status: z.literal('ok'),
|
|
150
|
+
input: z.string(),
|
|
151
|
+
input_sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
152
|
+
output: z.string(),
|
|
153
|
+
output_sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
154
|
+
source_format: z.enum(['doc', 'docx', 'odt', 'rtf', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'odp']),
|
|
155
|
+
target_format: z.literal('pdf'),
|
|
156
|
+
version: z.string(),
|
|
157
|
+
backend: z.enum(['wps', 'et', 'wpp']),
|
|
158
|
+
fallback_reason: z.null(),
|
|
159
|
+
page_count: z.number().int().positive(),
|
|
160
|
+
native_render_provenance: z.object({
|
|
161
|
+
schema: z.literal('tiwater.convert-native-render-provenance/v1'),
|
|
162
|
+
backend: z.enum(['wps', 'et', 'wpp']),
|
|
163
|
+
input: renderFileIdentity,
|
|
164
|
+
output: renderFileIdentity,
|
|
165
|
+
page_count: z.number().int().positive(),
|
|
166
|
+
}).passthrough(),
|
|
167
|
+
}).passthrough();
|
|
168
|
+
const nativeRenderOutput = z.object({
|
|
169
|
+
tool: z.literal('office_render_pdf'),
|
|
170
|
+
runtime: runtimeIdentity,
|
|
171
|
+
pdf: artifact,
|
|
172
|
+
receipt: artifact,
|
|
173
|
+
summary: z.object({
|
|
174
|
+
sourceFormat: z.string(),
|
|
175
|
+
backend: z.enum(['wps', 'et', 'wpp']),
|
|
176
|
+
pageCount: z.number().int().positive(),
|
|
177
|
+
}).strict(),
|
|
178
|
+
}).strict();
|
|
179
|
+
|
|
139
180
|
const migrationCatalogOutput = z.object({
|
|
140
181
|
tool: z.literal('docx_list_migration_choices'),
|
|
141
182
|
runtime: runtimeIdentity,
|
|
@@ -194,7 +235,7 @@ const migrationChoiceQueryInput = z.discriminatedUnion('view', [
|
|
|
194
235
|
...migrationQueryDocuments,
|
|
195
236
|
view: z.literal('targets'),
|
|
196
237
|
sourceChoiceId: z.string().trim().min(1).describe('Opaque current source id returned by the sources view.'),
|
|
197
|
-
action: migrationTargetAction.describe('Business action
|
|
238
|
+
action: migrationTargetAction.describe('Business action: place-content writes the current source fact; keep-template-content keeps baseline-owned fixed content; keep-template-label keeps the baseline label for the same source meaning; select-template-option carries a selected source option into the matching baseline option.'),
|
|
198
239
|
text: z.string().trim().min(1).optional().describe('Optional literal case-insensitive text to find in target visible text or context.'),
|
|
199
240
|
offset: boundedOffset,
|
|
200
241
|
limit: boundedLimit,
|
|
@@ -264,7 +305,7 @@ const tools = [
|
|
|
264
305
|
},
|
|
265
306
|
{
|
|
266
307
|
name: 'docx_query_migration_choices',
|
|
267
|
-
description: 'Query current template-migration alternatives without reading the catalog artifact. Page unresolved sources, request provider-compatible targets for one source and business action, or inspect cleanup targets.
|
|
308
|
+
description: 'Query current template-migration alternatives without reading the catalog artifact. Page unresolved sources, request provider-compatible targets for one source and business action, or inspect cleanup targets. Target results are ordered by literal and local document-context relevance; that order helps discovery but does not make the business choice.',
|
|
268
309
|
inputSchema: migrationChoiceQueryInput,
|
|
269
310
|
outputSchema: migrationChoiceQueryOutput,
|
|
270
311
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
@@ -291,13 +332,6 @@ const tools = [
|
|
|
291
332
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
292
333
|
handler: docxCompare,
|
|
293
334
|
},
|
|
294
|
-
{
|
|
295
|
-
name: 'docx_validate_template_transform',
|
|
296
|
-
description: 'Validate whether a source DOCX template and target DOCX template are structurally compatible.',
|
|
297
|
-
inputSchema: z.object({ sourceTemplate: pathInput, targetTemplate: pathInput }).strict(),
|
|
298
|
-
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
299
|
-
handler: docxValidateTemplateTransform,
|
|
300
|
-
},
|
|
301
335
|
{
|
|
302
336
|
name: 'docx_export_json',
|
|
303
337
|
description: 'Export DOCX body content to a new JSON artifact without returning the full document through MCP.',
|
|
@@ -305,6 +339,17 @@ const tools = [
|
|
|
305
339
|
outputSchema: artifactOutput('docx_export_json'),
|
|
306
340
|
handler: docxExportJson,
|
|
307
341
|
},
|
|
342
|
+
{
|
|
343
|
+
name: 'office_render_pdf',
|
|
344
|
+
description: 'Render a current Office document to PDF with its required native WPS backend and write the complete provider receipt as evidence. The input extension selects Writer, Spreadsheets, or Presentation; fallback rendering is rejected.',
|
|
345
|
+
inputSchema: z.object({
|
|
346
|
+
input: pathInput.describe('Path to the current Office document.'),
|
|
347
|
+
output: pathInput.describe('New PDF output path. Existing files are never overwritten.'),
|
|
348
|
+
receiptOutput: pathInput.describe('New JSON receipt path. Existing files are never overwritten.'),
|
|
349
|
+
}).strict(),
|
|
350
|
+
outputSchema: nativeRenderOutput,
|
|
351
|
+
handler: officeRenderPdf,
|
|
352
|
+
},
|
|
308
353
|
{
|
|
309
354
|
name: 'xlsx_inspect',
|
|
310
355
|
description: 'Inspect an XLSX workbook and write one JSON observation containing workbook structure, exported values, formulas, styles, merged ranges, and conversion evidence.',
|
|
@@ -438,39 +483,147 @@ async function docxQueryMigrationChoices(args) {
|
|
|
438
483
|
branch = migrationTargetBranch(action, source.kind);
|
|
439
484
|
}
|
|
440
485
|
|
|
441
|
-
const targetResult = await
|
|
442
|
-
'find-template-migration-targets',
|
|
486
|
+
const targetResult = await loadMigrationTargets({
|
|
443
487
|
sourcePath,
|
|
444
488
|
baselinePath,
|
|
445
489
|
sourceChoiceId,
|
|
446
490
|
branch,
|
|
447
|
-
args.text
|
|
448
|
-
|
|
449
|
-
String(limit),
|
|
450
|
-
]);
|
|
451
|
-
const targetPage = migrationTargetPage.parse(targetResult.json);
|
|
452
|
-
if (targetPage.sourceChoiceId !== (args.view === 'cleanup' ? null : sourceChoiceId) || targetPage.branch !== branch) {
|
|
453
|
-
throw new Error('Migration target page identity does not match the requested current source and action');
|
|
454
|
-
}
|
|
491
|
+
text: args.text,
|
|
492
|
+
});
|
|
455
493
|
const catalogTargets = new Map(catalog.targets.map(item => [item.id, item]));
|
|
456
|
-
for (const target of
|
|
494
|
+
for (const target of targetResult.targets) {
|
|
457
495
|
const current = catalogTargets.get(target.id);
|
|
458
496
|
if (!current || !isDeepStrictEqual(current, target)) {
|
|
459
497
|
throw new Error(`Migration target ${target.id} is not bound to the current catalog`);
|
|
460
498
|
}
|
|
461
499
|
}
|
|
500
|
+
const catalogOrder = new Map(catalog.targets.map((item, index) => [item.id, index]));
|
|
501
|
+
const orderedTargets = source
|
|
502
|
+
? [...targetResult.targets].sort((left, right) => {
|
|
503
|
+
const relevance = compareRelevance(
|
|
504
|
+
migrationChoiceRelevance(source, right),
|
|
505
|
+
migrationChoiceRelevance(source, left));
|
|
506
|
+
return relevance || catalogOrder.get(left.id) - catalogOrder.get(right.id);
|
|
507
|
+
})
|
|
508
|
+
: [...targetResult.targets].sort((left, right) => catalogOrder.get(left.id) - catalogOrder.get(right.id));
|
|
509
|
+
const items = orderedTargets.slice(offset, offset + limit);
|
|
462
510
|
return migrationQueryResult({
|
|
463
|
-
runtime:
|
|
511
|
+
runtime: targetResult.runtime,
|
|
464
512
|
catalog,
|
|
465
513
|
view: args.view,
|
|
466
514
|
action,
|
|
467
515
|
source,
|
|
468
|
-
items
|
|
469
|
-
offset
|
|
470
|
-
total:
|
|
516
|
+
items,
|
|
517
|
+
offset,
|
|
518
|
+
total: orderedTargets.length,
|
|
471
519
|
});
|
|
472
520
|
}
|
|
473
521
|
|
|
522
|
+
async function loadMigrationTargets({ sourcePath, baselinePath, sourceChoiceId, branch, text }) {
|
|
523
|
+
const targets = [];
|
|
524
|
+
let offset = 0;
|
|
525
|
+
let total = null;
|
|
526
|
+
let runtime = null;
|
|
527
|
+
do {
|
|
528
|
+
const result = await runJsonCandidateChain(docxCandidates, [
|
|
529
|
+
'find-template-migration-targets',
|
|
530
|
+
sourcePath,
|
|
531
|
+
baselinePath,
|
|
532
|
+
sourceChoiceId,
|
|
533
|
+
branch,
|
|
534
|
+
text ?? '-',
|
|
535
|
+
String(offset),
|
|
536
|
+
'100',
|
|
537
|
+
]);
|
|
538
|
+
const page = migrationTargetPage.parse(result.json);
|
|
539
|
+
if (page.sourceChoiceId !== (sourceChoiceId === '-' ? null : sourceChoiceId) || page.branch !== branch) {
|
|
540
|
+
throw new Error('Migration target page identity does not match the requested current source and action');
|
|
541
|
+
}
|
|
542
|
+
if (total !== null && page.total !== total) {
|
|
543
|
+
throw new Error('Migration target page total changed while reading current alternatives');
|
|
544
|
+
}
|
|
545
|
+
if (page.offset !== offset || page.targets.length === 0 && offset < page.total) {
|
|
546
|
+
throw new Error('Migration target pagination did not advance');
|
|
547
|
+
}
|
|
548
|
+
runtime ??= commandRuntime(result);
|
|
549
|
+
total = page.total;
|
|
550
|
+
targets.push(...page.targets);
|
|
551
|
+
offset += page.targets.length;
|
|
552
|
+
} while (offset < total);
|
|
553
|
+
return { runtime, targets };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function migrationChoiceRelevance(source, target) {
|
|
557
|
+
const sourceContext = source.context ?? {};
|
|
558
|
+
const targetContext = target.context ?? {};
|
|
559
|
+
const sourceRows = sourceContext.sameRowTexts ?? [];
|
|
560
|
+
const targetRows = targetContext.sameRowTexts ?? [];
|
|
561
|
+
const sourceHeaders = sourceContext.tableHeaderTexts ?? [];
|
|
562
|
+
const targetHeaders = targetContext.tableHeaderTexts ?? [];
|
|
563
|
+
const mainText = textSimilarity(source.text, target.text);
|
|
564
|
+
const tableIdentity = Math.max(
|
|
565
|
+
textSimilarity(sourceContext.columnHeaderText, targetContext.columnHeaderText),
|
|
566
|
+
maximumPairSimilarity(sourceHeaders, targetHeaders));
|
|
567
|
+
const neighborhood = Math.max(
|
|
568
|
+
textSimilarity(sourceContext.previousText, targetContext.previousText),
|
|
569
|
+
textSimilarity(sourceContext.nextText, targetContext.nextText),
|
|
570
|
+
maximumPairSimilarity(sourceRows, targetRows),
|
|
571
|
+
maximumPairSimilarity([source.text], targetRows),
|
|
572
|
+
maximumPairSimilarity(sourceRows, [target.text]));
|
|
573
|
+
return source.kind === 'table-cell' && target.kind === 'table-cell'
|
|
574
|
+
? [tableIdentity, mainText, neighborhood]
|
|
575
|
+
: [mainText, neighborhood, tableIdentity];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function compareRelevance(left, right) {
|
|
579
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
580
|
+
if (left[index] !== right[index]) return left[index] - right[index];
|
|
581
|
+
}
|
|
582
|
+
return 0;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function maximumPairSimilarity(leftValues, rightValues) {
|
|
586
|
+
let maximum = 0;
|
|
587
|
+
for (const left of leftValues ?? []) {
|
|
588
|
+
for (const right of rightValues ?? []) {
|
|
589
|
+
maximum = Math.max(maximum, textSimilarity(left, right));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return maximum;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function textSimilarity(leftValue, rightValue) {
|
|
596
|
+
const left = normalizeSearchText(leftValue);
|
|
597
|
+
const right = normalizeSearchText(rightValue);
|
|
598
|
+
if (!left || !right) return 0;
|
|
599
|
+
if (left === right) return 1;
|
|
600
|
+
if (Math.min(left.length, right.length) >= 3 && (left.includes(right) || right.includes(left))) {
|
|
601
|
+
return Math.min(left.length, right.length) / Math.max(left.length, right.length);
|
|
602
|
+
}
|
|
603
|
+
const leftPairs = characterPairs(left);
|
|
604
|
+
const rightPairs = characterPairs(right);
|
|
605
|
+
if (leftPairs.size === 0 || rightPairs.size === 0) return 0;
|
|
606
|
+
let shared = 0;
|
|
607
|
+
for (const pair of leftPairs) if (rightPairs.has(pair)) shared += 1;
|
|
608
|
+
return 2 * shared / (leftPairs.size + rightPairs.size);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function normalizeSearchText(value) {
|
|
612
|
+
return typeof value === 'string'
|
|
613
|
+
? value.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
|
614
|
+
: '';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function characterPairs(value) {
|
|
618
|
+
const characters = [...value];
|
|
619
|
+
if (characters.length < 2) return new Set(value ? [value] : []);
|
|
620
|
+
const pairs = new Set();
|
|
621
|
+
for (let index = 0; index + 1 < characters.length; index += 1) {
|
|
622
|
+
pairs.add(characters[index] + characters[index + 1]);
|
|
623
|
+
}
|
|
624
|
+
return pairs;
|
|
625
|
+
}
|
|
626
|
+
|
|
474
627
|
function migrationTargetBranch(action, sourceKind) {
|
|
475
628
|
if (action === 'place-content') return sourceKind === 'media' ? 'copy-media' : 'copy-text';
|
|
476
629
|
if (action === 'keep-template-content') return 'retain-target';
|
|
@@ -550,13 +703,6 @@ async function docxCompare(args) {
|
|
|
550
703
|
return { tool: 'docx_compare', runtime: commandRuntime(result), report: result.json };
|
|
551
704
|
}
|
|
552
705
|
|
|
553
|
-
async function docxValidateTemplateTransform(args) {
|
|
554
|
-
const sourceTemplate = requireString(args.sourceTemplate, 'sourceTemplate');
|
|
555
|
-
const targetTemplate = requireString(args.targetTemplate, 'targetTemplate');
|
|
556
|
-
const result = await runJsonCandidateChain(docxCandidates, ['validate-template-transform', sourceTemplate, targetTemplate, '--json']);
|
|
557
|
-
return { tool: 'docx_validate_template_transform', runtime: commandRuntime(result), report: result.json };
|
|
558
|
-
}
|
|
559
|
-
|
|
560
706
|
async function docxExportJson(args) {
|
|
561
707
|
const input = requireString(args.input, 'input');
|
|
562
708
|
const result = await runJsonCandidateChain(docxCandidates, ['export-json', input]);
|
|
@@ -567,6 +713,64 @@ async function docxExportJson(args) {
|
|
|
567
713
|
};
|
|
568
714
|
}
|
|
569
715
|
|
|
716
|
+
async function officeRenderPdf(args) {
|
|
717
|
+
const input = path.resolve(requireString(args.input, 'input'));
|
|
718
|
+
const output = path.resolve(requireString(args.output, 'output'));
|
|
719
|
+
const receiptOutput = path.resolve(requireString(args.receiptOutput, 'receiptOutput'));
|
|
720
|
+
const sourceFormat = path.extname(input).slice(1).toLowerCase();
|
|
721
|
+
const backend = nativeRenderBackend(sourceFormat);
|
|
722
|
+
if (path.extname(output).toLowerCase() !== '.pdf') {
|
|
723
|
+
throw Object.assign(new Error(`Office render output must be a PDF: ${output}`), { code: -32602 });
|
|
724
|
+
}
|
|
725
|
+
const inputArtifact = await fileArtifact(input);
|
|
726
|
+
await requireNewFile(output, 'output');
|
|
727
|
+
await requireNewFile(receiptOutput, 'receiptOutput');
|
|
728
|
+
await mkdir(path.dirname(output), { recursive: true });
|
|
729
|
+
try {
|
|
730
|
+
const result = await runJsonCandidateChain(
|
|
731
|
+
convertCandidates,
|
|
732
|
+
[`${sourceFormat}-to-pdf`, input, output],
|
|
733
|
+
{ env: { TIWATER_OFFICE_PDF_BACKEND: backend } });
|
|
734
|
+
const receipt = nativeRenderReceipt.parse(result.json);
|
|
735
|
+
const pdf = await fileArtifact(output);
|
|
736
|
+
if (path.resolve(receipt.input) !== input
|
|
737
|
+
|| path.resolve(receipt.output) !== output
|
|
738
|
+
|| receipt.source_format !== sourceFormat
|
|
739
|
+
|| receipt.backend !== backend
|
|
740
|
+
|| receipt.native_render_provenance.backend !== backend
|
|
741
|
+
|| receipt.page_count !== receipt.native_render_provenance.page_count
|
|
742
|
+
|| receipt.input_sha256 !== inputArtifact.sha256
|
|
743
|
+
|| receipt.native_render_provenance.input.sha256 !== inputArtifact.sha256
|
|
744
|
+
|| receipt.native_render_provenance.input.size_bytes !== inputArtifact.bytes
|
|
745
|
+
|| receipt.output_sha256 !== pdf.sha256
|
|
746
|
+
|| receipt.native_render_provenance.output.sha256 !== pdf.sha256
|
|
747
|
+
|| receipt.native_render_provenance.output.size_bytes !== pdf.bytes) {
|
|
748
|
+
throw new Error('Native Office render receipt is not bound to the current input and output');
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
tool: 'office_render_pdf',
|
|
752
|
+
runtime: commandRuntime(result),
|
|
753
|
+
pdf,
|
|
754
|
+
receipt: await writeJsonArtifact(receiptOutput, receipt),
|
|
755
|
+
summary: {
|
|
756
|
+
sourceFormat,
|
|
757
|
+
backend,
|
|
758
|
+
pageCount: receipt.page_count,
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
} catch (error) {
|
|
762
|
+
await rm(output, { force: true });
|
|
763
|
+
throw error;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function nativeRenderBackend(sourceFormat) {
|
|
768
|
+
if (['doc', 'docx', 'odt', 'rtf'].includes(sourceFormat)) return 'wps';
|
|
769
|
+
if (['xls', 'xlsx', 'ods'].includes(sourceFormat)) return 'et';
|
|
770
|
+
if (['ppt', 'pptx', 'odp'].includes(sourceFormat)) return 'wpp';
|
|
771
|
+
throw Object.assign(new Error(`Unsupported Office render input: .${sourceFormat || '(none)'}`), { code: -32602 });
|
|
772
|
+
}
|
|
773
|
+
|
|
570
774
|
async function xlsxInspect(args) {
|
|
571
775
|
const input = requireString(args.input, 'input');
|
|
572
776
|
const result = await runJsonCandidateChain(xlsxCandidates, ['inspect', input, '--json']);
|
|
@@ -629,6 +833,29 @@ async function writeJsonArtifact(output, payload) {
|
|
|
629
833
|
};
|
|
630
834
|
}
|
|
631
835
|
|
|
836
|
+
async function fileArtifact(filePath) {
|
|
837
|
+
const hash = createHash('sha256');
|
|
838
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
839
|
+
hash.update(chunk);
|
|
840
|
+
}
|
|
841
|
+
const file = await stat(filePath);
|
|
842
|
+
return {
|
|
843
|
+
path: path.resolve(filePath),
|
|
844
|
+
sha256: hash.digest('hex'),
|
|
845
|
+
bytes: file.size,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function requireNewFile(filePath, label) {
|
|
850
|
+
try {
|
|
851
|
+
await stat(filePath);
|
|
852
|
+
} catch (error) {
|
|
853
|
+
if (error?.code === 'ENOENT') return;
|
|
854
|
+
throw error;
|
|
855
|
+
}
|
|
856
|
+
throw Object.assign(new Error(`${label} already exists: ${filePath}`), { code: -32602 });
|
|
857
|
+
}
|
|
858
|
+
|
|
632
859
|
function commandRuntime(result) {
|
|
633
860
|
return {
|
|
634
861
|
command: result.command,
|