@tiwater/office-mcp 0.12.0 → 0.13.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/index.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  commandCandidate,
12
12
  createToolResult,
13
13
  requireString,
14
+ runCandidateChain,
14
15
  runJsonCandidateChain,
15
16
  withTempJsonFile,
16
17
  } from '../_shared/tool-runtime.mjs';
@@ -35,97 +36,12 @@ const convertCandidates = [
35
36
  ];
36
37
 
37
38
  const pathInput = z.string().trim().min(1);
38
- const migrationQueryTargetAction = z.enum([
39
- 'place-content',
40
- 'keep-template-label',
41
- 'select-template-option',
42
- ]).describe('Business action: place-content moves the current source content into a target content position; keep-template-label preserves the target label and structure while migrating a uniquely identified current field value; select-template-option marks the target option represented by the current source fact.');
43
- const targetActions = new Set(migrationQueryTargetAction.options);
44
-
45
- const choiceReference = z.string().trim().regex(/^[ST][1-9][0-9]*-[0-9a-f]{8}$/);
46
- const alternativeReference = z.string().trim().regex(/^S[1-9][0-9]*-[PLO][1-9][0-9]*-[0-9a-f]{8}$/);
47
-
48
- const terminalMigrationChoiceInput = z.object({
49
- sourceRef: choiceReference,
50
- action: z.enum(['exclude-source', 'review-source']),
51
- }).strict();
52
-
53
- const migrationChoiceInput = z.union([
54
- z.object({ alternativeRef: alternativeReference }).strict(),
55
- terminalMigrationChoiceInput,
56
- ]);
57
-
58
- const templateCleanupInput = z.object({
59
- targetRef: choiceReference,
60
- scope: z.enum(['cell', 'row']),
61
- }).strict();
62
-
63
- const templateMigrationInput = z.object({
64
- source: pathInput.describe('Path to the current source DOCX.'),
65
- baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
66
- output: pathInput.describe('Path to the migrated output DOCX.'),
67
- receiptOutput: pathInput.describe('New JSON receipt artifact path. Existing files are never overwritten.'),
68
- choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source ref. Targeted choices use an alternativeRef returned by the query tool. Terminal exclusions and genuine local review use a sourceRef.'),
69
- templateCleanup: z.array(templateCleanupInput).optional().describe('Optional baseline-owned placeholders or example rows to clear.'),
70
- }).strict();
71
39
 
72
40
  const runtimeIdentity = z.object({
73
41
  command: z.string(),
74
42
  cwd: z.string(),
75
43
  }).strict();
76
44
 
77
- const migrationChoiceOutput = z.object({
78
- id: z.string(),
79
- kind: z.string(),
80
- scope: z.string(),
81
- text: z.string().nullable(),
82
- count: z.number().int(),
83
- requiredCardinality: z.string().nullable(),
84
- context: z.record(z.string(), z.unknown()).nullable(),
85
- allowedActions: z.array(z.string()),
86
- }).strict();
87
-
88
- const migrationPublicChoiceOutput = migrationChoiceOutput.omit({ id: true }).extend({
89
- ref: choiceReference,
90
- }).strict();
91
-
92
- const migrationAlternativeOutput = z.object({
93
- ref: alternativeReference,
94
- action: migrationQueryTargetAction,
95
- target: migrationPublicChoiceOutput,
96
- }).strict();
97
-
98
- const migrationCatalog = z.object({
99
- schema: z.string(),
100
- pass: z.boolean(),
101
- sourceSha256: z.string(),
102
- baselineSha256: z.string(),
103
- sources: z.array(migrationChoiceOutput),
104
- targets: z.array(migrationChoiceOutput),
105
- }).strict().superRefine((catalog, context) => {
106
- for (const key of ['sources', 'targets']) {
107
- const seen = new Set();
108
- for (const [index, choice] of catalog[key].entries()) {
109
- if (seen.has(choice.id)) {
110
- context.addIssue({ code: 'custom', path: [key, index, 'id'], message: `duplicate ${key} choice id: ${choice.id}` });
111
- }
112
- seen.add(choice.id);
113
- }
114
- }
115
- });
116
-
117
- const migrationReceipt = z.object({
118
- schema: z.string(),
119
- toolVersion: z.string(),
120
- status: z.enum(['pass', 'review-required', 'failed']),
121
- pass: z.boolean(),
122
- reviewRequired: z.boolean(),
123
- outputVerified: z.boolean(),
124
- output: z.string().nullable(),
125
- plan: z.string().nullable(),
126
- failures: z.array(z.unknown()),
127
- }).passthrough();
128
-
129
45
  const inputOnly = z.object({ input: pathInput }).strict();
130
46
  const artifactInput = z.object({
131
47
  input: pathInput,
@@ -137,39 +53,6 @@ const artifact = z.object({
137
53
  bytes: z.number().int().nonnegative(),
138
54
  }).strict();
139
55
 
140
- const xlsxAppliedOperation = z.object({
141
- type: z.string().min(1),
142
- applied: z.boolean(),
143
- detail: z.string(),
144
- sheet: z.string().nullable().optional(),
145
- changedRange: z.string().nullable().optional(),
146
- warnings: z.array(z.string()).nullable().optional(),
147
- }).strict();
148
- const xlsxEditResult = z.object({
149
- input: z.string().min(1),
150
- output: z.string().min(1),
151
- appliedOperations: z.array(xlsxAppliedOperation),
152
- }).strict();
153
- const xlsxApplyReceipt = z.object({
154
- schema: z.literal('tiwater.office.xlsx-apply-receipt/v1'),
155
- pass: z.boolean(),
156
- input: artifact,
157
- operations: artifact,
158
- output: artifact.nullable(),
159
- appliedOperations: z.array(xlsxAppliedOperation),
160
- }).strict();
161
- const xlsxApplyOutput = z.object({
162
- tool: z.literal('xlsx_apply'),
163
- runtime: runtimeIdentity,
164
- receipt: artifact,
165
- output: artifact.nullable(),
166
- summary: z.object({
167
- pass: z.boolean(),
168
- operationCount: z.number().int().nonnegative(),
169
- appliedCount: z.number().int().nonnegative(),
170
- }).strict(),
171
- }).strict();
172
-
173
56
  const pptxTemplateApplyResult = z.object({
174
57
  input: z.string().min(1),
175
58
  template: z.string().min(1),
@@ -210,23 +93,6 @@ const pptxFormatApplyResult = z.object({
210
93
  message: z.string(),
211
94
  }).strict()),
212
95
  }).strict();
213
- const pptxTemplateApplyReceipt = z.object({
214
- schema: z.literal('tiwater.office.pptx-template-apply-receipt/v1'),
215
- pass: z.boolean(),
216
- input: artifact,
217
- template: artifact,
218
- plan: artifact,
219
- output: artifact.nullable(),
220
- providerResult: pptxTemplateApplyResult,
221
- }).strict();
222
- const pptxFormatApplyReceipt = z.object({
223
- schema: z.literal('tiwater.office.pptx-format-apply-receipt/v1'),
224
- pass: z.boolean(),
225
- input: artifact,
226
- operations: artifact,
227
- output: artifact.nullable(),
228
- providerResult: pptxFormatApplyResult,
229
- }).strict();
230
96
  const pptxTemplateApplyOutput = z.object({
231
97
  tool: z.literal('pptx_apply_template'),
232
98
  runtime: runtimeIdentity,
@@ -287,99 +153,121 @@ const nativeRenderOutput = z.object({
287
153
  }).strict(),
288
154
  }).strict();
289
155
 
290
- const migrationCatalogOutput = z.object({
291
- tool: z.literal('docx_list_migration_choices'),
292
- runtime: runtimeIdentity,
293
- artifact,
294
- summary: z.object({
295
- schema: z.string(),
296
- pass: z.boolean(),
297
- sourceSha256: z.string(),
298
- baselineSha256: z.string(),
299
- sourceCount: z.number().int().nonnegative(),
300
- targetCount: z.number().int().nonnegative(),
301
- }).strict(),
156
+ const index = z.number().int().nonnegative();
157
+ const positiveIndex = z.number().int().positive();
158
+ const optionalTextMatch = {
159
+ matchMode: z.string().optional(),
160
+ paragraphStyle: z.string().optional(),
161
+ };
162
+ const richTextSegment = z.object({
163
+ text: z.string(),
164
+ color: z.string().optional(),
165
+ underline: z.boolean().optional(),
166
+ bold: z.boolean().optional(),
167
+ fontName: z.string().optional(),
302
168
  }).strict();
303
-
304
- const migrationQueryPage = z.object({
305
- offset: z.number().int().nonnegative(),
306
- returned: z.number().int().nonnegative(),
307
- total: z.number().int().nonnegative(),
308
- hasMore: z.boolean(),
169
+ const tableCellInput = z.object({
170
+ text: z.string().optional(),
171
+ gridSpan: positiveIndex.optional(),
172
+ vMerge: z.string().optional(),
173
+ bold: z.boolean().optional(),
174
+ header: z.boolean().optional(),
175
+ shading: z.string().optional(),
176
+ alignment: z.string().optional(),
177
+ richText: z.array(richTextSegment).optional(),
309
178
  }).strict();
310
179
 
311
- const migrationTargetPage = z.object({
312
- schema: z.string(),
313
- pass: z.boolean(),
314
- sourceChoiceId: z.string().nullable(),
315
- branch: z.string(),
316
- offset: z.number().int().nonnegative(),
317
- limit: z.number().int().positive(),
318
- total: z.number().int().nonnegative(),
319
- targets: z.array(migrationChoiceOutput),
320
- }).strict();
321
-
322
- const migrationQueryDocuments = {
323
- source: pathInput.describe('Path to the current source DOCX used by docx_list_migration_choices.'),
324
- baseline: pathInput.describe('Path to the same selected current baseline DOCX used by docx_list_migration_choices.'),
325
- };
326
-
327
- const boundedOffset = z.number().int().nonnegative().optional().describe('Zero-based result offset. Defaults to 0.');
328
- const boundedLimit = z.number().int().min(1).max(10).optional().describe('Maximum results to return. Defaults to 10 and cannot exceed 10.');
180
+ function editAction(name, operationType, description, changeSchema) {
181
+ return { name, operationType, description, changeSchema, batch: true };
182
+ }
183
+
184
+ function documentAction(name, operationType, description) {
185
+ return { name, operationType, description, batch: false };
186
+ }
187
+
188
+ const docxEditActions = [
189
+ editAction('docx_set_anchored_text', 'replaceAnchoredText', 'Set text at current DOCX comment anchors.', z.object({ commentId: pathInput, text: z.string() }).strict()),
190
+ editAction('docx_set_paragraph_text', 'replaceParagraphText', 'Set current body paragraph text.', z.object({ paragraphIndex: index, text: z.string() }).strict()),
191
+ editAction('docx_set_paragraph_run_text', 'replaceParagraphRunText', 'Set current body paragraph run text.', z.object({ paragraphIndex: index, runIndex: index, text: z.string() }).strict()),
192
+ editAction('docx_replace_body_text', 'replaceBodyText', 'Replace uniquely matched current body text.', z.object({ findText: pathInput, text: z.string() }).strict()),
193
+ editAction('docx_delete_body_paragraph', 'deleteBodyParagraph', 'Delete uniquely matched current body paragraphs.', z.object({ findText: pathInput, ...optionalTextMatch }).strict()),
194
+ editAction('docx_delete_body_drawing_before_paragraph', 'deleteBodyDrawingBeforeParagraph', 'Delete the drawing immediately before a uniquely matched current paragraph.', z.object({ findText: pathInput, ...optionalTextMatch }).strict()),
195
+ editAction('docx_delete_body_range', 'deleteBodyRange', 'Delete uniquely bounded current body ranges.', z.object({ findText: pathInput, endFindText: z.string().optional(), matchMode: z.string().optional(), endMatchMode: z.string().optional(), paragraphStyle: z.string().optional(), endParagraphStyle: z.string().optional(), deleteToBodyEnd: z.boolean().optional(), removePrecedingPageBreak: z.boolean().optional() }).strict()),
196
+ editAction('docx_start_section', 'startSectionBeforeParagraph', 'Start a section before a uniquely matched current paragraph.', z.object({ findText: pathInput, orientation: z.enum(['portrait', 'landscape']) }).strict()),
197
+ editAction('docx_set_header_paragraph_text', 'replaceHeaderParagraphText', 'Set current header paragraph text.', z.object({ headerIndex: index, paragraphIndex: index, text: z.string() }).strict()),
198
+ editAction('docx_set_header_run_text', 'replaceHeaderParagraphRunText', 'Set current header run text.', z.object({ headerIndex: index, paragraphIndex: index, runIndex: index, text: z.string() }).strict()),
199
+ editAction('docx_replace_header_text', 'replaceHeaderText', 'Replace uniquely matched current header text.', z.object({ findText: pathInput, text: z.string() }).strict()),
200
+ editAction('docx_set_footer_paragraph_text', 'replaceFooterParagraphText', 'Set current footer paragraph text.', z.object({ footerIndex: index, paragraphIndex: index, text: z.string() }).strict()),
201
+ editAction('docx_set_footer_run_text', 'replaceFooterParagraphRunText', 'Set current footer run text.', z.object({ footerIndex: index, paragraphIndex: index, runIndex: index, text: z.string() }).strict()),
202
+ editAction('docx_set_table_cell_text', 'replaceTableCellText', 'Set current body table cell text.', z.object({ tableIndex: index, rowIndex: index, cellIndex: index, text: z.string(), alignment: z.string().optional() }).strict()),
203
+ editAction('docx_set_table_cell_run_text', 'replaceTableCellRunText', 'Set current body table cell run text.', z.object({ tableIndex: index, rowIndex: index, cellIndex: index, paragraphIndex: index, runIndex: index, text: z.string() }).strict()),
204
+ editAction('docx_set_table_cell_choice_state', 'setTableCellChoiceState', 'Set the selected state represented by current table-cell content.', z.object({ tableIndex: index, rowIndex: index, cellIndex: index, text: z.string() }).strict()),
205
+ editAction('docx_set_header_table_cell_text', 'replaceHeaderTableCellText', 'Set current header table cell text.', z.object({ headerIndex: index, tableIndex: index, rowIndex: index, cellIndex: index, text: z.string() }).strict()),
206
+ editAction('docx_set_header_table_cell_run_text', 'replaceHeaderTableCellRunText', 'Set current header table cell run text.', z.object({ headerIndex: index, tableIndex: index, rowIndex: index, cellIndex: index, paragraphIndex: index, runIndex: index, text: z.string() }).strict()),
207
+ editAction('docx_set_footer_table_cell_text', 'replaceFooterTableCellText', 'Set current footer table cell text.', z.object({ footerIndex: index, tableIndex: index, rowIndex: index, cellIndex: index, text: z.string() }).strict()),
208
+ editAction('docx_set_footer_table_cell_run_text', 'replaceFooterTableCellRunText', 'Set current footer table cell run text.', z.object({ footerIndex: index, tableIndex: index, rowIndex: index, cellIndex: index, paragraphIndex: index, runIndex: index, text: z.string() }).strict()),
209
+ editAction('docx_set_table_cell_rich_text', 'replaceTableCellRichText', 'Set current body table cell rich text.', z.object({ tableIndex: index, rowIndex: index, cellIndex: index, richText: z.array(richTextSegment) }).strict()),
210
+ editAction('docx_insert_table_rows', 'insertTableRows', 'Insert rows into a current body table.', z.object({ tableIndex: index, rowIndex: index, templateRowIndex: index.optional(), rows: z.array(z.array(tableCellInput)) }).strict()),
211
+ editAction('docx_delete_table_rows', 'deleteTableRows', 'Delete current body table row ranges.', z.object({ tableIndex: index, startRowIndex: index, endRowIndex: index }).strict()),
212
+ editAction('docx_replace_table_rows', 'replaceTableRows', 'Replace current body table row ranges.', z.object({ tableIndex: index, startRowIndex: index, endRowIndex: index, templateRowIndex: index.optional(), rows: z.array(z.array(tableCellInput)) }).strict()),
213
+ editAction('docx_insert_table_columns', 'insertTableColumns', 'Insert columns into a current body table.', z.object({ tableIndex: index, columnIndex: index, columnCount: positiveIndex.optional(), templateColumnIndex: index.optional() }).strict()),
214
+ editAction('docx_set_table_width', 'setTableWidth', 'Set current body table widths.', z.object({ tableIndex: index, width: pathInput, widthType: z.enum(['pct', 'dxa', 'auto', 'nil']) }).strict()),
215
+ editAction('docx_set_table_cell_alignment', 'setTableCellAlignment', 'Set current body table cell alignment.', z.object({ tableIndex: index, rowIndex: index, cellIndex: index, alignment: pathInput }).strict()),
216
+ editAction('docx_set_table_cell_no_wrap', 'setTableCellNoWrap', 'Set current body table cell no-wrap state.', z.object({ tableIndex: index, rowIndex: index, cellIndex: index, noWrap: z.boolean() }).strict()),
217
+ editAction('docx_set_table_cell_font_size', 'setTableCellFontSize', 'Set current body table cell font size.', z.object({ tableIndex: index, rowIndex: index, cellIndex: index, fontSize: pathInput }).strict()),
218
+ editAction('docx_apply_font_policy', 'applyDocumentFontPolicy', 'Apply an explicit font policy to current document text.', z.object({ fontPolicy: z.object({ schema: pathInput, body: z.record(z.string(), z.string()), table: z.record(z.string(), z.string()) }).strict() }).strict()),
219
+ editAction('docx_set_table_row_height', 'setTableRowHeight', 'Set current body table row height.', z.object({ tableIndex: index, rowIndex: index, height: pathInput, heightRule: z.string().optional() }).strict()),
220
+ editAction('docx_set_table_row_cant_split', 'setTableRowCantSplit', 'Set current body table row split behavior.', z.object({ tableIndex: index, rowIndex: index, cantSplit: z.boolean() }).strict()),
221
+ editAction('docx_set_table_row_keep_next', 'setTableRowKeepNext', 'Set keep-next behavior for current body table rows.', z.object({ tableIndex: index, rowIndex: index, keepNext: z.boolean() }).strict()),
222
+ editAction('docx_set_body_paragraph_keep_next', 'setBodyParagraphKeepNext', 'Set keep-next behavior for current body paragraphs.', z.object({ paragraphIndex: index, keepNext: z.boolean() }).strict()),
223
+ editAction('docx_set_body_paragraph_keep_lines', 'setBodyParagraphKeepLines', 'Set keep-lines behavior for current body paragraphs.', z.object({ paragraphIndex: index, keepLines: z.boolean() }).strict()),
224
+ editAction('docx_set_header_paragraph_font_size', 'setHeaderParagraphFontSize', 'Set current header paragraph font size.', z.object({ headerIndex: index, paragraphIndex: index, fontSize: pathInput }).strict()),
225
+ documentAction('docx_collapse_trailing_empty_section', 'collapseTrailingEmptySection', 'Collapse a current trailing empty section.'),
226
+ documentAction('docx_collapse_trailing_empty_paragraphs', 'collapseTrailingEmptyBodyParagraphs', 'Collapse current trailing empty body paragraphs.'),
227
+ editAction('docx_merge_table_cells', 'mergeTableCells', 'Merge current body table cells.', z.object({ tableIndex: index, rowIndex: index.optional(), startCellIndex: index.optional(), endCellIndex: index.optional(), startRowIndex: index.optional(), endRowIndex: index.optional(), cellIndex: index.optional(), gridColumn: index.optional() }).strict()),
228
+ editAction('docx_unmerge_table_row_cells', 'unmergeTableRowHorizontalCells', 'Unmerge current horizontal table cells.', z.object({ tableIndex: index, rowIndex: index, cellIndex: index }).strict()),
229
+ editAction('docx_unmerge_table_column_cells', 'unmergeTableColumnVerticalCells', 'Unmerge current vertical table cell ranges.', z.object({ tableIndex: index, cellIndex: index, startRowIndex: index, endRowIndex: index }).strict()),
230
+ editAction('docx_delete_comments', 'deleteComments', 'Delete explicit current DOCX comments.', z.object({ commentIds: z.array(pathInput).min(1) }).strict()),
231
+ documentAction('docx_mark_fields_dirty', 'markFieldsDirty', 'Mark current DOCX fields for native refresh.'),
232
+ documentAction('docx_sanitize_fields', 'sanitizeFields', 'Remove update prompts and dirty markers from current DOCX fields.'),
233
+ documentAction('docx_freeze_fields', 'freezeFields', 'Convert current visible DOCX field results to ordinary content.'),
234
+ ];
329
235
 
330
- const migrationChoiceQueryInput = z.union([
331
- z.object({
332
- ...migrationQueryDocuments,
333
- view: z.literal('sources'),
334
- offset: boundedOffset,
335
- limit: boundedLimit,
336
- }).strict(),
337
- z.object({
338
- ...migrationQueryDocuments,
339
- view: z.literal('targets'),
340
- sourceRef: choiceReference.describe('Short source reference returned by the sources view.'),
341
- action: migrationQueryTargetAction.optional().describe('Optional business-action filter. Choose the action from scenario meaning, then use this filter to inspect only compatible targets. Omit it only when comparing the three documented action meanings.'),
342
- text: z.string().trim().min(1).optional().describe('Optional literal case-insensitive text to find in target visible text or context.'),
343
- offset: boundedOffset,
344
- limit: boundedLimit,
345
- }).strict(),
346
- z.object({
347
- ...migrationQueryDocuments,
348
- view: z.literal('cleanup'),
349
- text: z.string().trim().min(1).optional().describe('Optional literal case-insensitive text to find in cleanup target visible text or context.'),
350
- offset: boundedOffset,
351
- limit: boundedLimit,
352
- }).strict(),
353
- ]);
236
+ const scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
237
+ const xlsxEditActions = [
238
+ editAction('xlsx_set_cell_value', 'setCellValue', 'Set current workbook cell values.', z.object({ sheet: pathInput, cell: pathInput, value: scalar, valueType: z.string().optional(), bold: z.boolean().optional(), shrinkToFit: z.boolean().optional(), wrapText: z.boolean().optional() }).strict()),
239
+ editAction('xlsx_set_cell_number_format', 'setCellNumberFormat', 'Set current workbook cell number formats.', z.object({ sheet: pathInput, cell: pathInput, numberFormat: pathInput }).strict()),
240
+ editAction('xlsx_set_rich_text_cell_value', 'setRichTextCellValue', 'Set current workbook rich-text cell values.', z.object({ sheet: pathInput, cell: pathInput, value: z.string(), bold: z.boolean() }).strict()),
241
+ editAction('xlsx_set_range_values', 'setRangeValues', 'Set rectangular values in a current workbook.', z.object({ sheet: pathInput, startCell: pathInput, values: z.array(z.array(scalar)), valueType: z.string().optional() }).strict()),
242
+ editAction('xlsx_insert_rows', 'insertRows', 'Insert rows into a current worksheet.', z.object({ sheet: pathInput, startRow: positiveIndex, count: positiveIndex, preserveHorizontalMergedRanges: z.boolean().optional(), expandAdjacentVerticalMergedRanges: z.boolean().optional() }).strict()),
243
+ editAction('xlsx_copy_row', 'copyRow', 'Copy current worksheet rows.', z.object({ sheet: pathInput, sourceRow: positiveIndex, targetRow: positiveIndex, translateFormulas: z.boolean().optional() }).strict()),
244
+ editAction('xlsx_expand_section_rows', 'expandSectionRows', 'Expand current worksheet row sections from visible anchors.', z.object({ sheet: pathInput, anchorText: pathInput, exampleRows: positiveIndex, targetRows: positiveIndex, preserveStyle: z.boolean().optional(), preserveFormulas: z.boolean().optional(), preserveMergedRanges: z.boolean().optional() }).strict()),
245
+ editAction('xlsx_set_print_area', 'setPrintArea', 'Set current worksheet print areas.', z.object({ sheet: pathInput, range: pathInput }).strict()),
246
+ editAction('xlsx_set_page_setup', 'setPageSetup', 'Set current worksheet page properties.', z.object({ sheet: pathInput, fitToPagesWide: positiveIndex.optional(), fitToPagesTall: positiveIndex.optional(), orientation: z.enum(['portrait', 'landscape']).optional(), paperSize: z.enum(['letter', 'legal', 'a3', 'a4']).optional(), repeatRowsStart: positiveIndex.optional(), repeatRowsEnd: positiveIndex.optional(), repeatColsStart: positiveIndex.optional(), repeatColsEnd: positiveIndex.optional() }).strict()),
247
+ editAction('xlsx_set_row_page_breaks', 'setRowPageBreaks', 'Set current worksheet row page breaks.', z.object({ sheet: pathInput, breakBeforeRows: z.array(positiveIndex) }).strict()),
248
+ editAction('xlsx_set_column_width', 'setColumnWidth', 'Set current worksheet column widths.', z.object({ sheet: pathInput, column: pathInput, width: z.number().positive().max(255) }).strict()),
249
+ ];
354
250
 
355
- const migrationChoiceQueryOutput = z.object({
356
- tool: z.literal('docx_query_migration_choices'),
357
- runtime: runtimeIdentity,
358
- sourceSha256: z.string(),
359
- baselineSha256: z.string(),
360
- view: z.enum(['sources', 'targets', 'cleanup']),
361
- action: migrationQueryTargetAction.nullable(),
362
- source: migrationPublicChoiceOutput.nullable(),
363
- items: z.array(z.union([migrationPublicChoiceOutput, migrationAlternativeOutput])),
364
- page: migrationQueryPage,
365
- }).strict();
251
+ function editToolDefinitions(actions) {
252
+ return actions.map(action => ({
253
+ name: action.name,
254
+ description: action.batch ? `${action.description} One call batches only this action kind.` : action.description,
255
+ inputSchema: z.object({
256
+ input: pathInput,
257
+ output: pathInput.describe('New document output path. Existing files are never overwritten.'),
258
+ receiptOutput: pathInput.describe('New JSON receipt path. Existing files are never overwritten.'),
259
+ ...(action.batch ? { changes: z.array(action.changeSchema).min(1) } : {}),
260
+ }).strict(),
261
+ outputSchema: fixedEditOutput(action.name),
262
+ annotations: action.batch ? undefined : { idempotentHint: true },
263
+ handler: args => fixedEdit(action, args),
264
+ }));
265
+ }
366
266
 
367
- function migrationReceiptOutput(tool) {
267
+ function fixedEditOutput(tool) {
368
268
  return z.object({
369
- tool: z.literal(tool),
370
- runtime: runtimeIdentity,
371
- artifact,
372
- summary: z.object({
373
- schema: z.string(),
374
- toolVersion: z.string(),
375
- status: z.enum(['pass', 'review-required', 'failed']),
376
- pass: z.boolean(),
377
- reviewRequired: z.boolean(),
378
- outputVerified: z.boolean(),
379
- output: z.string().nullable(),
380
- plan: z.string().nullable(),
381
- failureCount: z.number().int().nonnegative(),
382
- }).strict(),
269
+ tool: z.literal(tool), runtime: runtimeIdentity, receipt: artifact, output: artifact.nullable(),
270
+ summary: z.object({ pass: z.boolean(), operationCount: z.number().int().nonnegative(), appliedCount: z.number().int().nonnegative() }).strict(),
383
271
  }).strict();
384
272
  }
385
273
 
@@ -396,37 +284,12 @@ const tools = [
396
284
  handler: docxInspect,
397
285
  },
398
286
  {
399
- name: 'docx_list_migration_choices',
400
- description: 'Write every current source item that still needs a business choice and the selectable current baseline targets to an opaque run-local evidence artifact. Use docx_query_migration_choices with the same source and baseline to inspect bounded alternatives; do not parse the artifact.',
401
- inputSchema: z.object({
402
- source: pathInput.describe('Path to the current source DOCX.'),
403
- baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
404
- output: pathInput.describe('New JSON artifact path. Existing files are never overwritten.'),
405
- }).strict(),
406
- outputSchema: migrationCatalogOutput,
407
- handler: docxListMigrationChoices,
408
- },
409
- {
410
- name: 'docx_query_migration_choices',
411
- description: 'Query current template-migration alternatives without reading the catalog artifact. place-content moves current content into a target content position; keep-template-label preserves the target label and structure while migrating a uniquely identified current field value; select-template-option marks a target option represented by the current source fact. Choose the action from scenario meaning and filter by it before paging compatible targets. Target results bind the action and target into one alternativeRef; result order helps discovery but never makes the business choice.',
412
- inputSchema: migrationChoiceQueryInput,
413
- outputSchema: migrationChoiceQueryOutput,
287
+ name: 'docx_inspect_tables',
288
+ description: 'Inspect current DOCX tables, cells, merges, paragraphs, runs, and formatting.',
289
+ inputSchema: artifactInput,
290
+ outputSchema: artifactOutput('docx_inspect_tables'),
414
291
  annotations: { readOnlyHint: true, idempotentHint: true },
415
- handler: docxQueryMigrationChoices,
416
- },
417
- {
418
- name: 'docx_migrate_template',
419
- description: 'Migrate a current DOCX into the selected baseline from one complete batch of business choices. For targeted choices, submit one alternativeRef returned by docx_query_migration_choices. Exclusion and local review use a sourceRef plus their terminal action. The tool derives all document values, coordinates, plans, and edits.',
420
- inputSchema: templateMigrationInput,
421
- outputSchema: migrationReceiptOutput('docx_migrate_template'),
422
- handler: docxMigrateTemplate,
423
- },
424
- {
425
- name: 'docx_verify_migration',
426
- description: 'Independently re-resolve the same business choices and verify a migrated DOCX against the current source and baseline. This does not trust the migration receipt.',
427
- inputSchema: templateMigrationInput,
428
- outputSchema: migrationReceiptOutput('docx_verify_migration'),
429
- handler: docxVerifyMigration,
292
+ handler: docxInspectTables,
430
293
  },
431
294
  {
432
295
  name: 'docx_compare',
@@ -442,6 +305,39 @@ const tools = [
442
305
  outputSchema: artifactOutput('docx_export_json'),
443
306
  handler: docxExportJson,
444
307
  },
308
+ {
309
+ name: 'docx_validate',
310
+ description: 'Validate a current DOCX package against the published OpenXML contract.',
311
+ inputSchema: inputOnly,
312
+ annotations: { readOnlyHint: true, idempotentHint: true },
313
+ handler: docxValidate,
314
+ },
315
+ {
316
+ name: 'docx_validate_font_policy',
317
+ description: 'Validate current DOCX text against an explicit font policy.',
318
+ inputSchema: z.object({ input: pathInput, policy: z.object({ schema: pathInput, body: z.record(z.string(), z.string()), table: z.record(z.string(), z.string()) }).strict() }).strict(),
319
+ annotations: { readOnlyHint: true, idempotentHint: true },
320
+ handler: docxValidateFontPolicy,
321
+ },
322
+ {
323
+ name: 'docx_strip_direct_formatting',
324
+ description: 'Remove direct paragraph and run formatting while preserving styles.',
325
+ inputSchema: z.object({ input: pathInput, output: pathInput }).strict(),
326
+ handler: docxStripDirectFormatting,
327
+ },
328
+ {
329
+ name: 'docx_replace_style_ids',
330
+ description: 'Replace current DOCX style IDs from an explicit style map.',
331
+ inputSchema: z.object({ input: pathInput, output: pathInput, styleMap: z.record(z.string(), z.string()) }).strict(),
332
+ handler: docxReplaceStyleIds,
333
+ },
334
+ {
335
+ name: 'docx_fill_template',
336
+ description: 'Fill current DOCX placeholders from an explicit data object.',
337
+ inputSchema: z.object({ template: pathInput, output: pathInput, data: z.record(z.string(), z.unknown()) }).strict(),
338
+ handler: docxFillTemplate,
339
+ },
340
+ ...editToolDefinitions(docxEditActions),
445
341
  {
446
342
  name: 'office_render_pdf',
447
343
  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.',
@@ -453,9 +349,15 @@ const tools = [
453
349
  outputSchema: nativeRenderOutput,
454
350
  handler: officeRenderPdf,
455
351
  },
352
+ {
353
+ name: 'xlsx_convert_legacy',
354
+ description: 'Convert a current legacy XLS workbook to XLSX using the published native ET backend.',
355
+ inputSchema: z.object({ input: pathInput, output: pathInput, receiptOutput: pathInput }).strict(),
356
+ handler: xlsxConvertLegacy,
357
+ },
456
358
  {
457
359
  name: 'xlsx_inspect',
458
- description: 'Inspect an XLSX workbook and write one JSON observation containing workbook structure, exported values, formulas, styles, merged ranges, and conversion evidence.',
360
+ description: 'Inspect a current XLSX workbook or legacy XLS workbook and write one JSON observation containing workbook structure, exported values, formulas, styles, merged ranges, and any published legacy-format conversion evidence.',
459
361
  inputSchema: artifactInput,
460
362
  outputSchema: artifactOutput('xlsx_inspect'),
461
363
  handler: xlsxInspect,
@@ -472,17 +374,12 @@ const tools = [
472
374
  handler: xlsxExportJson,
473
375
  },
474
376
  {
475
- name: 'xlsx_apply',
476
- description: 'Apply one deterministic XLSX operations artifact to a current workbook. This tool executes published workbook edits; it does not derive values, coordinates, or business decisions.',
477
- inputSchema: z.object({
478
- input: pathInput.describe('Path to the current XLSX workbook.'),
479
- operations: pathInput.describe('Path to the deterministic tiwater.xlsx-edit/v1 operations artifact.'),
480
- output: pathInput.describe('New XLSX output path. Existing files are never overwritten.'),
481
- receiptOutput: pathInput.describe('New JSON receipt path. Existing files are never overwritten.'),
482
- }).strict(),
483
- outputSchema: xlsxApplyOutput,
484
- handler: xlsxApply,
377
+ name: 'xlsx_fill_template',
378
+ description: 'Fill current XLSX placeholders from an explicit data object.',
379
+ inputSchema: z.object({ template: pathInput, output: pathInput, data: z.record(z.string(), z.unknown()) }).strict(),
380
+ handler: xlsxFillTemplate,
485
381
  },
382
+ ...editToolDefinitions(xlsxEditActions),
486
383
  {
487
384
  name: 'xlsx_validate',
488
385
  description: 'Validate an XLSX workbook package and return Open XML validation evidence.',
@@ -504,13 +401,20 @@ const tools = [
504
401
  outputSchema: artifactOutput('pptx_export_json'),
505
402
  handler: pptxExportJson,
506
403
  },
404
+ {
405
+ name: 'pptx_fill_template',
406
+ description: 'Fill current PPTX placeholders from an explicit data object.',
407
+ inputSchema: z.object({ template: pathInput, output: pathInput, data: z.record(z.string(), z.unknown()) }).strict(),
408
+ handler: pptxFillTemplate,
409
+ },
507
410
  {
508
411
  name: 'pptx_apply_template',
509
412
  description: 'Apply one deterministic PPTX template-application plan to a current presentation. This tool executes the published plan; it does not select a template or derive business content, slide mappings, geometry, or formatting decisions.',
510
413
  inputSchema: z.object({
511
414
  input: pathInput.describe('Path to the current source PPTX.'),
512
415
  template: pathInput.describe('Path to the selected current template PPTX.'),
513
- plan: pathInput.describe('Path to the deterministic template-application plan artifact.'),
416
+ targetMasterPath: pathInput,
417
+ slides: z.array(z.object({ slideNumber: positiveIndex, targetLayoutPath: pathInput, contentBounds: z.object({ x: index, y: index, cx: positiveIndex, cy: positiveIndex }).strict().optional(), contentShapeIds: z.array(positiveIndex).min(1).optional(), sourceLayoutShapeIdsToPreserve: z.array(positiveIndex).optional() }).strict()).min(1),
514
418
  output: pathInput.describe('New PPTX output path. Existing files are never overwritten.'),
515
419
  receiptOutput: pathInput.describe('New JSON receipt path. Existing files are never overwritten.'),
516
420
  }).strict(),
@@ -522,20 +426,27 @@ const tools = [
522
426
  description: 'Apply one deterministic PPTX formatting plan to a current presentation. This tool executes published formatting operations; it does not derive values, coordinates, or business decisions.',
523
427
  inputSchema: z.object({
524
428
  input: pathInput.describe('Path to the current PPTX.'),
525
- operations: pathInput.describe('Path to the deterministic formatting operations artifact.'),
429
+ changes: z.array(z.object({ slideNumber: positiveIndex, shapeId: positiveIndex, runIndex: index, fontFamily: z.string().optional(), fontSize: z.number().positive().optional(), color: z.string().optional(), bold: z.boolean().optional(), paragraphAlignment: z.string().optional() }).strict()).min(1),
526
430
  output: pathInput.describe('New PPTX output path. Existing files are never overwritten.'),
527
431
  receiptOutput: pathInput.describe('New JSON receipt path. Existing files are never overwritten.'),
528
432
  }).strict(),
529
433
  outputSchema: pptxFormatApplyOutput,
530
434
  handler: pptxApplyFormat,
531
435
  },
436
+ {
437
+ name: 'pptx_validate',
438
+ description: 'Validate a current PPTX package against the published OpenXML contract.',
439
+ inputSchema: inputOnly,
440
+ annotations: { readOnlyHint: true, idempotentHint: true },
441
+ handler: pptxValidate,
442
+ },
532
443
  ];
533
444
 
534
445
  function buildServer() {
535
446
  const server = new McpServer(
536
447
  { name: 'tiwater-office', version: packageMetadata.version },
537
448
  {
538
- instructions: 'Use the Office tools for technical document observation. For template migration, list the current choices, select only allowed business actions, migrate once, and independently verify the result. Never invent document values, identities, coordinates, plans, or edit operations.',
449
+ instructions: 'Use these tools only for generic Office observation, conversion, editing, validation, and native rendering. Derive business meaning from the active scenario knowledge and current documents; the provider owns no scenario workflow.',
539
450
  },
540
451
  );
541
452
  for (const tool of tools) {
@@ -563,452 +474,98 @@ async function docxInspect(args) {
563
474
  };
564
475
  }
565
476
 
566
- async function docxListMigrationChoices(args) {
567
- const source = requireString(args.source, 'source');
568
- const baseline = requireString(args.baseline, 'baseline');
569
- const result = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', source, baseline]);
570
- const catalog = migrationCatalog.parse(result.json);
571
- return {
572
- tool: 'docx_list_migration_choices',
573
- runtime: commandRuntime(result),
574
- artifact: await writeJsonArtifact(requireString(args.output, 'output'), catalog),
575
- summary: {
576
- schema: catalog.schema,
577
- pass: catalog.pass,
578
- sourceSha256: catalog.sourceSha256,
579
- baselineSha256: catalog.baselineSha256,
580
- sourceCount: catalog.sources.length,
581
- targetCount: catalog.targets.length,
582
- },
583
- };
584
- }
585
-
586
- async function docxQueryMigrationChoices(args) {
587
- const sourcePath = requireString(args.source, 'source');
588
- const baselinePath = requireString(args.baseline, 'baseline');
589
- const offset = args.offset ?? 0;
590
- const limit = args.limit ?? 10;
591
- const catalogResult = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', sourcePath, baselinePath]);
592
- const catalog = migrationCatalog.parse(catalogResult.json);
593
-
594
- if (args.view === 'sources') {
595
- return migrationQueryResult({
596
- runtime: commandRuntime(catalogResult),
597
- catalog,
598
- view: 'sources',
599
- action: null,
600
- source: null,
601
- items: catalog.sources.slice(offset, offset + limit),
602
- offset,
603
- total: catalog.sources.length,
604
- });
605
- }
606
-
607
- let source = null;
608
- let action = null;
609
- if (args.view === 'cleanup') {
610
- const targetResult = await loadMigrationTargets({
611
- sourcePath,
612
- baselinePath,
613
- sourceChoiceId: '-',
614
- branch: 'baseline-clear',
615
- text: args.text,
616
- });
617
- const orderedTargets = validateAndOrderMigrationTargets(catalog, null, targetResult.targets);
618
- return migrationQueryResult({
619
- runtime: targetResult.runtime,
620
- catalog,
621
- view: 'cleanup',
622
- action: null,
623
- source: null,
624
- items: orderedTargets.slice(offset, offset + limit),
625
- offset,
626
- total: orderedTargets.length,
627
- });
628
- }
629
-
630
- const requestedSourceId = choiceIdFromRef(catalog, catalog.sources, args.sourceRef, 'S', 'source');
631
- source = catalog.sources.find(item => item.id === requestedSourceId) ?? null;
632
- if (!source) {
633
- throw Object.assign(new Error(`Unknown migration source: ${args.sourceRef}`), { code: -32602 });
634
- }
635
- action = args.action ?? null;
636
- const actions = action
637
- ? [action]
638
- : [...targetActions].filter(candidate => source.allowedActions.includes(candidate));
639
- const pages = await Promise.all(actions.map(async candidate => {
640
- if (!source.allowedActions.includes(candidate)) {
641
- throw Object.assign(new Error(`Action ${candidate} is not allowed for ${source.id}`), { code: -32602 });
642
- }
643
- const result = await loadMigrationTargets({
644
- sourcePath,
645
- baselinePath,
646
- sourceChoiceId: source.id,
647
- branch: migrationTargetBranch(candidate, source.kind),
648
- text: args.text,
649
- });
650
- return {
651
- action: candidate,
652
- runtime: result.runtime,
653
- targets: validateAndOrderMigrationTargets(catalog, source, result.targets),
654
- };
655
- }));
656
- const catalogOrder = new Map(catalog.targets.map((item, index) => [item.id, index]));
657
- const actionOrder = new Map([...targetActions].map((item, index) => [item, index]));
658
- const alternatives = pages.flatMap(page => page.targets.map(target => ({ action: page.action, target })))
659
- .sort((left, right) => {
660
- const relevance = compareRelevance(
661
- migrationChoiceRelevance(source, right.target),
662
- migrationChoiceRelevance(source, left.target));
663
- return relevance
664
- || catalogOrder.get(left.target.id) - catalogOrder.get(right.target.id)
665
- || actionOrder.get(left.action) - actionOrder.get(right.action);
666
- })
667
- .map(item => ({
668
- ref: migrationAlternativeRef(catalog, source, item.action, item.target),
669
- action: item.action,
670
- target: publicMigrationChoice(catalog, item.target, 'T'),
671
- }));
672
- return migrationQueryResult({
673
- runtime: pages[0]?.runtime ?? commandRuntime(catalogResult),
674
- catalog,
675
- view: 'targets',
676
- action,
677
- source,
678
- items: alternatives.slice(offset, offset + limit),
679
- offset,
680
- total: alternatives.length,
681
- alternatives: true,
682
- });
683
- }
684
-
685
- function validateAndOrderMigrationTargets(catalog, source, targets) {
686
- const catalogTargets = new Map(catalog.targets.map(item => [item.id, item]));
687
- for (const target of targets) {
688
- const current = catalogTargets.get(target.id);
689
- if (!current || !isDeepStrictEqual(current, target)) {
690
- throw new Error(`Migration target ${target.id} is not bound to the current catalog`);
691
- }
692
- }
693
- const catalogOrder = new Map(catalog.targets.map((item, index) => [item.id, index]));
694
- return source
695
- ? [...targets].sort((left, right) => {
696
- const relevance = compareRelevance(
697
- migrationChoiceRelevance(source, right),
698
- migrationChoiceRelevance(source, left));
699
- return relevance || catalogOrder.get(left.id) - catalogOrder.get(right.id);
700
- })
701
- : [...targets].sort((left, right) => catalogOrder.get(left.id) - catalogOrder.get(right.id));
702
- }
703
-
704
- async function loadMigrationTargets({ sourcePath, baselinePath, sourceChoiceId, branch, text }) {
705
- const targets = [];
706
- let offset = 0;
707
- let total = null;
708
- let runtime = null;
709
- do {
710
- const result = await runJsonCandidateChain(docxCandidates, [
711
- 'find-template-migration-targets',
712
- sourcePath,
713
- baselinePath,
714
- sourceChoiceId,
715
- branch,
716
- text ?? '-',
717
- String(offset),
718
- '100',
719
- ]);
720
- const page = migrationTargetPage.parse(result.json);
721
- if (page.sourceChoiceId !== (sourceChoiceId === '-' ? null : sourceChoiceId) || page.branch !== branch) {
722
- throw new Error('Migration target page identity does not match the requested current source and action');
723
- }
724
- if (total !== null && page.total !== total) {
725
- throw new Error('Migration target page total changed while reading current alternatives');
726
- }
727
- if (page.offset !== offset || page.targets.length === 0 && offset < page.total) {
728
- throw new Error('Migration target pagination did not advance');
729
- }
730
- runtime ??= commandRuntime(result);
731
- total = page.total;
732
- targets.push(...page.targets);
733
- offset += page.targets.length;
734
- } while (offset < total);
735
- return { runtime, targets };
736
- }
737
-
738
- function migrationChoiceRelevance(source, target) {
739
- const sourceContext = source.context ?? {};
740
- const targetContext = target.context ?? {};
741
- const sourceRows = sourceContext.sameRowTexts ?? [];
742
- const targetRows = targetContext.sameRowTexts ?? [];
743
- const sourceHeaders = sourceContext.tableHeaderTexts ?? [];
744
- const targetHeaders = targetContext.tableHeaderTexts ?? [];
745
- const mainText = textSimilarity(source.text, target.text);
746
- const tableIdentity = Math.max(
747
- textSimilarity(sourceContext.columnHeaderText, targetContext.columnHeaderText),
748
- maximumPairSimilarity(sourceHeaders, targetHeaders));
749
- const neighborhood = Math.max(
750
- textSimilarity(sourceContext.previousText, targetContext.previousText),
751
- textSimilarity(sourceContext.nextText, targetContext.nextText),
752
- maximumPairSimilarity(sourceRows, targetRows),
753
- maximumPairSimilarity([source.text], targetRows),
754
- maximumPairSimilarity(sourceRows, [target.text]));
755
- return source.kind === 'table-cell' && target.kind === 'table-cell'
756
- ? [tableIdentity, mainText, neighborhood]
757
- : [mainText, neighborhood, tableIdentity];
758
- }
759
-
760
- function compareRelevance(left, right) {
761
- for (let index = 0; index < left.length; index += 1) {
762
- if (left[index] !== right[index]) return left[index] - right[index];
763
- }
764
- return 0;
765
- }
766
-
767
- function maximumPairSimilarity(leftValues, rightValues) {
768
- let maximum = 0;
769
- for (const left of leftValues ?? []) {
770
- for (const right of rightValues ?? []) {
771
- maximum = Math.max(maximum, textSimilarity(left, right));
772
- }
773
- }
774
- return maximum;
775
- }
776
-
777
- function textSimilarity(leftValue, rightValue) {
778
- const left = normalizeSearchText(leftValue);
779
- const right = normalizeSearchText(rightValue);
780
- if (!left || !right) return 0;
781
- if (left === right) return 1;
782
- if (Math.min(left.length, right.length) >= 3 && (left.includes(right) || right.includes(left))) {
783
- return Math.min(left.length, right.length) / Math.max(left.length, right.length);
784
- }
785
- const leftPairs = characterPairs(left);
786
- const rightPairs = characterPairs(right);
787
- if (leftPairs.size === 0 || rightPairs.size === 0) return 0;
788
- let shared = 0;
789
- for (const pair of leftPairs) if (rightPairs.has(pair)) shared += 1;
790
- return 2 * shared / (leftPairs.size + rightPairs.size);
791
- }
792
-
793
- function normalizeSearchText(value) {
794
- return typeof value === 'string'
795
- ? value.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
796
- : '';
477
+ async function docxInspectTables(args) {
478
+ const result = await runJsonCandidateChain(docxCandidates, ['inspect-tables', requireString(args.input, 'input'), '--json']);
479
+ return { tool: 'docx_inspect_tables', runtime: commandRuntime(result), artifact: await writeJsonArtifact(requireString(args.output, 'output'), result.json) };
797
480
  }
798
481
 
799
- function characterPairs(value) {
800
- const characters = [...value];
801
- if (characters.length < 2) return new Set(value ? [value] : []);
802
- const pairs = new Set();
803
- for (let index = 0; index + 1 < characters.length; index += 1) {
804
- pairs.add(characters[index] + characters[index + 1]);
805
- }
806
- return pairs;
807
- }
808
-
809
- function migrationTargetBranch(action, sourceKind) {
810
- if (action === 'place-content') return sourceKind === 'media' ? 'copy-media' : 'copy-text';
811
- if (action === 'keep-template-content') return 'retain-target';
812
- if (action === 'keep-template-label') return 'retain-target-label';
813
- if (action === 'select-template-option') return 'choice-selection';
814
- throw new Error(`Unsupported migration target action: ${action}`);
815
- }
816
-
817
- function catalogReferenceToken(catalog) {
818
- return createHash('sha256')
819
- .update(`${catalog.sourceSha256}\n${catalog.baselineSha256}`)
820
- .digest('hex')
821
- .slice(0, 8);
822
- }
823
-
824
- function choiceRef(catalog, items, id, prefix) {
825
- const index = items.findIndex(item => item.id === id);
826
- if (index < 0) throw new Error(`Migration choice is not bound to the current catalog: ${id}`);
827
- return `${prefix}${index + 1}-${catalogReferenceToken(catalog)}`;
828
- }
829
-
830
- function choiceIdFromRef(catalog, items, ref, prefix, label) {
831
- if (!ref.startsWith(prefix)) {
832
- throw invalidMigrationInput(`invalid migration ${label} ref: ${ref}`);
833
- }
834
- const [ordinal, token] = ref.slice(1).split('-');
835
- if (token !== catalogReferenceToken(catalog)) {
836
- throw invalidMigrationInput(`stale migration ${label} ref: ${ref}`);
837
- }
838
- const item = items[Number(ordinal) - 1];
839
- if (!item) throw invalidMigrationInput(`unknown migration ${label} ref: ${ref}`);
840
- return item.id;
482
+ async function docxValidate(args) {
483
+ const result = await runJsonCandidateChain(docxCandidates, ['validate-openxml', requireString(args.input, 'input')], { allowedExitCodes: [0, 1] });
484
+ return { tool: 'docx_validate', runtime: commandRuntime(result), result: result.json };
841
485
  }
842
486
 
843
- const migrationActionCodes = new Map([
844
- ['place-content', 'P'],
845
- ['keep-template-label', 'L'],
846
- ['select-template-option', 'O'],
847
- ]);
848
- const migrationActionsByCode = new Map([...migrationActionCodes].map(([action, code]) => [code, action]));
849
-
850
- function migrationAlternativeRef(catalog, source, action, target) {
851
- const sourceOrdinal = catalog.sources.findIndex(item => item.id === source.id) + 1;
852
- const targetOrdinal = catalog.targets.findIndex(item => item.id === target.id) + 1;
853
- const code = migrationActionCodes.get(action);
854
- if (sourceOrdinal < 1 || targetOrdinal < 1 || !code) {
855
- throw new Error('Migration alternative is not bound to the current catalog');
856
- }
857
- return `S${sourceOrdinal}-${code}${targetOrdinal}-${migrationAlternativeToken(catalog, source, action, target)}`;
858
- }
859
-
860
- function migrationAlternativeToken(catalog, source, action, target) {
861
- return createHash('sha256')
862
- .update(`${catalog.sourceSha256}\n${catalog.baselineSha256}\n${source.id}\n${action}\n${target.id}`)
863
- .digest('hex')
864
- .slice(0, 8);
865
- }
866
-
867
- function migrationChoiceFromAlternativeRef(catalog, ref) {
868
- const match = /^S([1-9][0-9]*)-([PLO])([1-9][0-9]*)-([0-9a-f]{8})$/.exec(ref);
869
- if (!match) throw invalidMigrationInput(`invalid migration alternative ref: ${ref}`);
870
- const source = catalog.sources[Number(match[1]) - 1];
871
- const target = catalog.targets[Number(match[3]) - 1];
872
- const action = migrationActionsByCode.get(match[2]);
873
- if (!source || !target || !action) {
874
- throw invalidMigrationInput(`unknown migration alternative ref: ${ref}`);
875
- }
876
- if (match[4] !== migrationAlternativeToken(catalog, source, action, target)) {
877
- throw invalidMigrationInput(`stale or invalid migration alternative ref: ${ref}`);
878
- }
879
- return { sourceChoiceId: source.id, action, targetChoiceId: target.id };
487
+ async function docxValidateFontPolicy(args) {
488
+ return withTempJsonFile(args.policy, async policyPath => {
489
+ const result = await runJsonCandidateChain(docxCandidates, ['validate-font-policy', requireString(args.input, 'input'), policyPath], { allowedExitCodes: [0, 1] });
490
+ return { tool: 'docx_validate_font_policy', runtime: commandRuntime(result), result: result.json };
491
+ });
880
492
  }
881
493
 
882
- function publicMigrationChoice(catalog, choice, prefix) {
883
- const { id, ...visible } = choice;
884
- const items = prefix === 'S' ? catalog.sources : catalog.targets;
885
- return {
886
- ...visible,
887
- allowedActions: visible.allowedActions.filter(action => action !== 'keep-template-content'),
888
- ref: choiceRef(catalog, items, id, prefix),
889
- };
494
+ async function docxStripDirectFormatting(args) {
495
+ return copyTransform('docx_strip_direct_formatting', docxCandidates, ['strip-direct-formatting'], args);
890
496
  }
891
497
 
892
- function migrationQueryResult({ runtime, catalog, view, action, source, items, offset, total, alternatives = false }) {
893
- return {
894
- tool: 'docx_query_migration_choices',
895
- runtime,
896
- sourceSha256: catalog.sourceSha256,
897
- baselineSha256: catalog.baselineSha256,
898
- view,
899
- action,
900
- source: source ? publicMigrationChoice(catalog, source, 'S') : null,
901
- items: alternatives
902
- ? items
903
- : items.map(item => publicMigrationChoice(catalog, item, view === 'sources' ? 'S' : 'T')),
904
- page: {
905
- offset,
906
- returned: items.length,
907
- total,
908
- hasMore: offset + items.length < total,
909
- },
910
- };
498
+ async function docxReplaceStyleIds(args) {
499
+ return withTempJsonFile(args.styleMap, styleMapPath => copyTransform('docx_replace_style_ids', docxCandidates, ['replace-style-ids'], args, [styleMapPath]));
911
500
  }
912
501
 
913
- async function docxMigrateTemplate(args) {
914
- return runTemplateMigrationCommand('docx_migrate_template', 'migrate-template', args);
502
+ async function docxFillTemplate(args) {
503
+ return withTempJsonFile(args.data, dataPath => templateFill('docx_fill_template', docxCandidates, args, dataPath));
915
504
  }
916
505
 
917
- async function docxVerifyMigration(args) {
918
- return runTemplateMigrationCommand('docx_verify_migration', 'verify-template-migration', args);
506
+ async function xlsxFillTemplate(args) {
507
+ return withTempJsonFile(args.data, dataPath => templateFill('xlsx_fill_template', xlsxCandidates, args, dataPath));
919
508
  }
920
509
 
921
- function invalidMigrationInput(message) {
922
- return Object.assign(new Error(message), { code: -32602 });
510
+ async function pptxFillTemplate(args) {
511
+ return withTempJsonFile(args.data, dataPath => templateFill('pptx_fill_template', pptxCandidates, args, dataPath));
923
512
  }
924
513
 
925
- function completeMigrationChoices(catalog, choices) {
926
- const sources = new Map(catalog.sources.map(source => [source.id, source]));
927
- const seen = new Set();
928
- const completed = choices.map(rawChoice => {
929
- const choice = rawChoice.alternativeRef
930
- ? migrationChoiceFromAlternativeRef(catalog, rawChoice.alternativeRef)
931
- : {
932
- sourceChoiceId: choiceIdFromRef(catalog, catalog.sources, rawChoice.sourceRef, 'S', 'source'),
933
- action: rawChoice.action,
934
- };
935
- const { sourceChoiceId } = choice;
936
- const source = sources.get(sourceChoiceId);
937
- if (!source) throw invalidMigrationInput(`unknown migration source id: ${sourceChoiceId}`);
938
- if (seen.has(sourceChoiceId)) throw invalidMigrationInput(`duplicate migration source id: ${sourceChoiceId}`);
939
- seen.add(sourceChoiceId);
940
- if (!source.allowedActions.includes(choice.action)) {
941
- throw invalidMigrationInput(`migration action ${choice.action} is not allowed for source id: ${sourceChoiceId}`);
942
- }
943
- return source.requiredCardinality === 'all'
944
- ? { ...choice, cardinality: 'all' }
945
- : choice;
946
- });
947
- const missing = [...sources.keys()].filter(sourceChoiceId => !seen.has(sourceChoiceId));
948
- if (missing.length > 0) {
949
- throw invalidMigrationInput(`migration choices must cover every source id; missing ${missing.length}`);
950
- }
951
- return completed;
514
+ async function templateFill(tool, candidates, args, dataPath) {
515
+ const template = path.resolve(requireString(args.template, 'template'));
516
+ const output = path.resolve(requireString(args.output, 'output'));
517
+ await requireNewFile(output, 'output');
518
+ const result = await runJsonCandidateChain(candidates, ['fill-template', template, dataPath, output]);
519
+ return { tool, runtime: commandRuntime(result), output: await fileArtifact(output), result: result.json };
952
520
  }
953
521
 
954
- function completeTemplateCleanup(catalog, cleanup, choices) {
955
- const targets = new Map(catalog.targets.map(target => [target.id, target]));
956
- const claimedTargets = new Set(choices.flatMap(choice => choice.targetChoiceId ? [choice.targetChoiceId] : []));
957
- const seen = new Set();
958
- return cleanup.map(rawCleanup => {
959
- const targetChoiceId = choiceIdFromRef(catalog, catalog.targets, rawCleanup.targetRef, 'T', 'target');
960
- if (!seen.add(targetChoiceId)) throw invalidMigrationInput(`duplicate migration cleanup target id: ${targetChoiceId}`);
961
- if (!targets.get(targetChoiceId)?.allowedActions.includes('template-cleanup')) {
962
- throw invalidMigrationInput(`migration cleanup is not allowed for target id: ${targetChoiceId}`);
963
- }
964
- return { targetChoiceId, scope: rawCleanup.scope };
965
- }).filter(cleanupChoice => !claimedTargets.has(cleanupChoice.targetChoiceId));
522
+ async function copyTransform(tool, candidates, command, args, suffix = []) {
523
+ const input = path.resolve(requireString(args.input, 'input'));
524
+ const output = path.resolve(requireString(args.output, 'output'));
525
+ await requireNewFile(output, 'output');
526
+ const result = await runCandidateChain(candidates, [...command, input, output, ...suffix]);
527
+ return { tool, runtime: commandRuntime(result), output: await fileArtifact(output) };
966
528
  }
967
529
 
968
- async function runTemplateMigrationCommand(tool, command, args) {
969
- const source = requireString(args.source, 'source');
970
- const baseline = requireString(args.baseline, 'baseline');
971
- const output = requireString(args.output, 'output');
972
- if (!Array.isArray(args.choices)) {
973
- throw Object.assign(new Error('choices must be an array'), { code: -32602 });
974
- }
975
- const catalogResult = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', source, baseline]);
976
- const catalog = migrationCatalog.parse(catalogResult.json);
977
- const choices = completeMigrationChoices(catalog, args.choices);
978
- const templateCleanup = Array.isArray(args.templateCleanup)
979
- ? completeTemplateCleanup(catalog, args.templateCleanup, choices)
980
- : [];
981
- const payload = {
982
- schema: 'tiwater.docx.template-migration-business-choices/v1',
983
- choices,
984
- ...(templateCleanup.length > 0 ? { templateCleanup } : {}),
985
- };
986
- return withTempJsonFile(payload, async choicesPath => {
987
- const result = await runJsonCandidateChain(
988
- docxCandidates,
989
- [command, source, baseline, choicesPath, output],
990
- { allowedExitCodes: [0, 1] });
991
- if (result.json === null) {
992
- const detail = result.stderr.trim() || result.stdout.trim() || 'no diagnostic output';
993
- throw new Error(`${result.command} ${command} returned no JSON receipt (exit ${result.code}): ${detail}`);
530
+ async function fixedEdit(action, args) {
531
+ const input = path.resolve(requireString(args.input, 'input'));
532
+ const output = path.resolve(requireString(args.output, 'output'));
533
+ const receiptOutput = path.resolve(requireString(args.receiptOutput, 'receiptOutput'));
534
+ await requireNewFile(output, 'output');
535
+ await requireNewFile(receiptOutput, 'receiptOutput');
536
+ const inputArtifact = await fileArtifact(input);
537
+ const operations = action.batch
538
+ ? args.changes.map(change => ({ ...change, type: action.operationType }))
539
+ : [{ type: action.operationType }];
540
+ const candidates = action.name.startsWith('docx_') ? docxCandidates : xlsxCandidates;
541
+ return withTempJsonFile({ operations }, async operationsPath => {
542
+ try {
543
+ const result = await runJsonCandidateChain(candidates, ['edit', input, operationsPath, output], { allowedExitCodes: [0, 1] });
544
+ const appliedOperations = Array.isArray(result.json?.appliedOperations) ? result.json.appliedOperations : [];
545
+ const pass = appliedOperations.length === operations.length && appliedOperations.every(operation => operation.applied === true);
546
+ const outputArtifact = pass ? await fileArtifact(output) : null;
547
+ if (!pass) await rm(output, { force: true });
548
+ const receipt = {
549
+ schema: 'tiwater.office.fixed-edit-receipt/v1',
550
+ tool: action.name,
551
+ operationType: action.operationType,
552
+ pass,
553
+ input: inputArtifact,
554
+ output: outputArtifact,
555
+ operationCount: operations.length,
556
+ appliedOperations,
557
+ };
558
+ return {
559
+ tool: action.name,
560
+ runtime: commandRuntime(result),
561
+ receipt: await writeJsonArtifact(receiptOutput, receipt),
562
+ output: outputArtifact,
563
+ summary: { pass, operationCount: operations.length, appliedCount: appliedOperations.filter(operation => operation.applied).length },
564
+ };
565
+ } catch (error) {
566
+ await rm(output, { force: true });
567
+ throw error;
994
568
  }
995
- const receipt = migrationReceipt.parse(result.json);
996
- return {
997
- tool,
998
- runtime: commandRuntime(result),
999
- artifact: await writeJsonArtifact(requireString(args.receiptOutput, 'receiptOutput'), receipt),
1000
- summary: {
1001
- schema: receipt.schema,
1002
- toolVersion: receipt.toolVersion,
1003
- status: receipt.status,
1004
- pass: receipt.pass,
1005
- reviewRequired: receipt.reviewRequired,
1006
- outputVerified: receipt.outputVerified,
1007
- output: receipt.output,
1008
- plan: receipt.plan,
1009
- failureCount: receipt.failures.length,
1010
- },
1011
- };
1012
569
  });
1013
570
  }
1014
571
 
@@ -1080,6 +637,30 @@ async function officeRenderPdf(args) {
1080
637
  }
1081
638
  }
1082
639
 
640
+ async function xlsxConvertLegacy(args) {
641
+ const input = path.resolve(requireString(args.input, 'input'));
642
+ const output = path.resolve(requireString(args.output, 'output'));
643
+ const receiptOutput = path.resolve(requireString(args.receiptOutput, 'receiptOutput'));
644
+ if (path.extname(input).toLowerCase() !== '.xls' || path.extname(output).toLowerCase() !== '.xlsx') {
645
+ throw Object.assign(new Error('Legacy conversion requires .xls input and .xlsx output'), { code: -32602 });
646
+ }
647
+ await requireNewFile(output, 'output');
648
+ await requireNewFile(receiptOutput, 'receiptOutput');
649
+ const inputArtifact = await fileArtifact(input);
650
+ try {
651
+ const result = await runJsonCandidateChain(convertCandidates, ['xls-to-xlsx', input, output]);
652
+ const outputArtifact = await fileArtifact(output);
653
+ const receipt = {
654
+ schema: 'tiwater.office.xlsx-legacy-conversion-receipt/v1',
655
+ backend: 'et', input: inputArtifact, output: outputArtifact, provider: result.json,
656
+ };
657
+ return { tool: 'xlsx_convert_legacy', runtime: commandRuntime(result), output: outputArtifact, receipt: await writeJsonArtifact(receiptOutput, receipt) };
658
+ } catch (error) {
659
+ await rm(output, { force: true });
660
+ throw error;
661
+ }
662
+ }
663
+
1083
664
  function nativeRenderBackend(sourceFormat) {
1084
665
  if (['doc', 'docx', 'odt', 'rtf'].includes(sourceFormat)) return 'wps';
1085
666
  if (['xls', 'xlsx', 'ods'].includes(sourceFormat)) return 'et';
@@ -1111,56 +692,6 @@ async function xlsxExportJson(args) {
1111
692
  };
1112
693
  }
1113
694
 
1114
- async function xlsxApply(args) {
1115
- const input = path.resolve(requireString(args.input, 'input'));
1116
- const operations = path.resolve(requireString(args.operations, 'operations'));
1117
- const output = path.resolve(requireString(args.output, 'output'));
1118
- const receiptOutput = path.resolve(requireString(args.receiptOutput, 'receiptOutput'));
1119
- if (path.extname(input).toLowerCase() !== '.xlsx' || path.extname(output).toLowerCase() !== '.xlsx') {
1120
- throw Object.assign(new Error('XLSX apply input and output must use the .xlsx extension'), { code: -32602 });
1121
- }
1122
- await requireNewFile(output, 'output');
1123
- await requireNewFile(receiptOutput, 'receiptOutput');
1124
- const inputArtifact = await fileArtifact(input);
1125
- const operationsArtifact = await fileArtifact(operations);
1126
- await mkdir(path.dirname(output), { recursive: true });
1127
- try {
1128
- const result = await runJsonCandidateChain(
1129
- xlsxCandidates,
1130
- ['edit', input, operations, output],
1131
- { allowedExitCodes: [0, 1] });
1132
- const edit = xlsxEditResult.parse(result.json);
1133
- if (path.resolve(edit.input) !== input || path.resolve(edit.output) !== output) {
1134
- throw new Error('XLSX edit receipt is not bound to the current input and output');
1135
- }
1136
- const pass = edit.appliedOperations.every(operation => operation.applied);
1137
- const outputArtifact = pass ? await fileArtifact(output) : null;
1138
- if (!pass) await rm(output, { force: true });
1139
- const receipt = xlsxApplyReceipt.parse({
1140
- schema: 'tiwater.office.xlsx-apply-receipt/v1',
1141
- pass,
1142
- input: inputArtifact,
1143
- operations: operationsArtifact,
1144
- output: outputArtifact,
1145
- appliedOperations: edit.appliedOperations,
1146
- });
1147
- return {
1148
- tool: 'xlsx_apply',
1149
- runtime: commandRuntime(result),
1150
- receipt: await writeJsonArtifact(receiptOutput, receipt),
1151
- output: outputArtifact,
1152
- summary: {
1153
- pass,
1154
- operationCount: edit.appliedOperations.length,
1155
- appliedCount: edit.appliedOperations.filter(operation => operation.applied).length,
1156
- },
1157
- };
1158
- } catch (error) {
1159
- await rm(output, { force: true });
1160
- throw error;
1161
- }
1162
- }
1163
-
1164
695
  async function xlsxValidate(args) {
1165
696
  const input = requireString(args.input, 'input');
1166
697
  const result = await runJsonCandidateChain(xlsxCandidates, ['validate', input], { allowedExitCodes: [0, 1] });
@@ -1188,17 +719,16 @@ async function pptxExportJson(args) {
1188
719
  }
1189
720
 
1190
721
  async function pptxApplyTemplate(args) {
1191
- return pptxApply('pptx_apply_template', args, true);
722
+ return withTempJsonFile({ targetMasterPath: args.targetMasterPath, slides: args.slides }, planPath => pptxApply('pptx_apply_template', args, true, planPath));
1192
723
  }
1193
724
 
1194
725
  async function pptxApplyFormat(args) {
1195
- return pptxApply('pptx_apply_format', args, false);
726
+ return withTempJsonFile({ operations: args.changes }, planPath => pptxApply('pptx_apply_format', args, false, planPath));
1196
727
  }
1197
728
 
1198
- async function pptxApply(tool, args, templateMode) {
729
+ async function pptxApply(tool, args, templateMode, plan) {
1199
730
  const input = path.resolve(requireString(args.input, 'input'));
1200
731
  const template = templateMode ? path.resolve(requireString(args.template, 'template')) : null;
1201
- const plan = path.resolve(requireString(templateMode ? args.plan : args.operations, templateMode ? 'plan' : 'operations'));
1202
732
  const output = path.resolve(requireString(args.output, 'output'));
1203
733
  const receiptOutput = path.resolve(requireString(args.receiptOutput, 'receiptOutput'));
1204
734
  for (const [label, candidate] of [['input', input], ['output', output], ...(template ? [['template', template]] : [])]) {
@@ -1230,18 +760,17 @@ async function pptxApply(tool, args, templateMode) {
1230
760
  const pass = providerResult.issues.length === 0;
1231
761
  const outputArtifact = pass ? await fileArtifact(output) : null;
1232
762
  if (!pass) await rm(output, { force: true });
1233
- const receiptSchema = templateMode ? pptxTemplateApplyReceipt : pptxFormatApplyReceipt;
1234
- const receipt = receiptSchema.parse({
763
+ const receipt = {
1235
764
  schema: templateMode
1236
765
  ? 'tiwater.office.pptx-template-apply-receipt/v1'
1237
766
  : 'tiwater.office.pptx-format-apply-receipt/v1',
1238
767
  pass,
1239
768
  input: inputArtifact,
1240
769
  ...(templateMode ? { template: templateArtifact } : {}),
1241
- ...(templateMode ? { plan: planArtifact } : { operations: planArtifact }),
770
+ requestSha256: planArtifact.sha256,
1242
771
  output: outputArtifact,
1243
772
  providerResult,
1244
- });
773
+ };
1245
774
  return {
1246
775
  tool,
1247
776
  runtime: commandRuntime(result),
@@ -1257,6 +786,11 @@ async function pptxApply(tool, args, templateMode) {
1257
786
  }
1258
787
  }
1259
788
 
789
+ async function pptxValidate(args) {
790
+ const result = await runJsonCandidateChain(pptxCandidates, ['validate', requireString(args.input, 'input')], { allowedExitCodes: [0, 1] });
791
+ return { tool: 'pptx_validate', runtime: commandRuntime(result), result: result.json };
792
+ }
793
+
1260
794
  async function requireArtifactUnchanged(expected, label) {
1261
795
  const current = await fileArtifact(expected.path);
1262
796
  if (!isDeepStrictEqual(current, expected)) throw new Error(`${label} changed during provider execution`);