Skip to content

Commit 32c9991

Browse files
authored
feat(document-api): format.paragraph for w:pPr formatting (#2218)
* feat(document-api): format.paragraph for w:pPr formatting * chore: fix cli test failure * fix(document-api): firstLine vs hanging, paragraph wrappers tests
1 parent a99b5ab commit 32c9991

65 files changed

Lines changed: 11181 additions & 424 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/scripts/export-sdk-contract.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,25 @@ const INTENT_NAMES = {
6666
`format_${entry.key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`)}`,
6767
]),
6868
),
69-
'doc.format.align': 'format_align',
69+
'doc.styles.paragraph.setStyle': 'set_paragraph_style',
70+
'doc.styles.paragraph.clearStyle': 'clear_paragraph_style',
71+
'doc.format.paragraph.resetDirectFormatting': 'reset_paragraph_direct_formatting',
72+
'doc.format.paragraph.setAlignment': 'set_paragraph_alignment',
73+
'doc.format.paragraph.clearAlignment': 'clear_paragraph_alignment',
74+
'doc.format.paragraph.setIndentation': 'set_paragraph_indentation',
75+
'doc.format.paragraph.clearIndentation': 'clear_paragraph_indentation',
76+
'doc.format.paragraph.setSpacing': 'set_paragraph_spacing',
77+
'doc.format.paragraph.clearSpacing': 'clear_paragraph_spacing',
78+
'doc.format.paragraph.setKeepOptions': 'set_paragraph_keep_options',
79+
'doc.format.paragraph.setOutlineLevel': 'set_paragraph_outline_level',
80+
'doc.format.paragraph.setFlowOptions': 'set_paragraph_flow_options',
81+
'doc.format.paragraph.setTabStop': 'set_paragraph_tab_stop',
82+
'doc.format.paragraph.clearTabStop': 'clear_paragraph_tab_stop',
83+
'doc.format.paragraph.clearAllTabStops': 'clear_all_paragraph_tab_stops',
84+
'doc.format.paragraph.setBorder': 'set_paragraph_border',
85+
'doc.format.paragraph.clearBorder': 'clear_paragraph_border',
86+
'doc.format.paragraph.setShading': 'set_paragraph_shading',
87+
'doc.format.paragraph.clearShading': 'clear_paragraph_shading',
7088
'doc.styles.apply': 'styles_apply',
7189
'doc.create.paragraph': 'create_paragraph',
7290
'doc.create.heading': 'create_heading',

apps/cli/src/__tests__/conformance/scenarios.ts

Lines changed: 157 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,56 @@ const FORMAT_INLINE_ALIAS_SUCCESS_SCENARIOS: Record<
232232
return [operationId, formatInlineAliasSuccessScenario(operationId)];
233233
}),
234234
) as Record<FormatInlineAliasCliOperationId, (harness: ConformanceHarness) => Promise<ScenarioInvocation>>;
235+
236+
function paragraphMutationScenario(
237+
operationId: CliOperationId,
238+
label: string,
239+
extraArgs: string[],
240+
prepare: Array<{ operationId: CliOperationId; extraArgs: string[] }> = [],
241+
): (harness: ConformanceHarness) => Promise<ScenarioInvocation> {
242+
return async (harness) => {
243+
const stateDir = await harness.createStateDir(`${label}-success`);
244+
let docPath = await harness.copyFixtureDoc(`${label}-source`);
245+
let block = await harness.firstBlockMatch(docPath, stateDir);
246+
247+
for (let index = 0; index < prepare.length; index += 1) {
248+
const step = prepare[index];
249+
const preparedOut = harness.createOutputPath(`${label}-prepare-${index + 1}`);
250+
const prepared = await harness.runCli(
251+
[
252+
...commandTokens(step.operationId),
253+
docPath,
254+
'--target-json',
255+
JSON.stringify({ kind: 'block', nodeType: 'paragraph', nodeId: block.nodeId }),
256+
...step.extraArgs,
257+
'--out',
258+
preparedOut,
259+
],
260+
stateDir,
261+
);
262+
263+
if (prepared.result.code !== 0 || prepared.envelope.ok !== true) {
264+
throw new Error(`Failed to prepare paragraph scenario ${label} with ${step.operationId}.`);
265+
}
266+
267+
docPath = preparedOut;
268+
block = await harness.firstBlockMatch(docPath, stateDir);
269+
}
270+
271+
return {
272+
stateDir,
273+
args: [
274+
...commandTokens(operationId),
275+
docPath,
276+
'--target-json',
277+
JSON.stringify({ kind: 'block', nodeType: 'paragraph', nodeId: block.nodeId }),
278+
...extraArgs,
279+
'--out',
280+
harness.createOutputPath(`${label}-output`),
281+
],
282+
};
283+
};
284+
}
235285
// ---------------------------------------------------------------------------
236286
// Table scenario helpers (DRY builders for the 40 table operations)
237287
// ---------------------------------------------------------------------------
@@ -1143,25 +1193,113 @@ export const SUCCESS_SCENARIOS = {
11431193
};
11441194
},
11451195
...FORMAT_INLINE_ALIAS_SUCCESS_SCENARIOS,
1146-
'doc.format.align': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1147-
const stateDir = await harness.createStateDir('doc-format-align-success');
1148-
const docPath = await harness.copyFixtureDoc('doc-format-align');
1149-
const target = await harness.firstTextRange(docPath, stateDir);
1150-
return {
1151-
stateDir,
1152-
args: [
1153-
'format',
1154-
'align',
1155-
docPath,
1156-
'--target-json',
1157-
JSON.stringify(target),
1158-
'--alignment-json',
1159-
JSON.stringify('center'),
1160-
'--out',
1161-
harness.createOutputPath('doc-format-align-output'),
1162-
],
1163-
};
1164-
},
1196+
'doc.styles.paragraph.setStyle': paragraphMutationScenario('doc.styles.paragraph.setStyle', 'styles-paragraph-set', [
1197+
'--style-id',
1198+
'Normal',
1199+
]),
1200+
'doc.styles.paragraph.clearStyle': paragraphMutationScenario(
1201+
'doc.styles.paragraph.clearStyle',
1202+
'styles-paragraph-clear',
1203+
[],
1204+
[{ operationId: 'doc.styles.paragraph.setStyle', extraArgs: ['--style-id', '__ConformanceTmpStyle__'] }],
1205+
),
1206+
'doc.format.paragraph.resetDirectFormatting': paragraphMutationScenario(
1207+
'doc.format.paragraph.resetDirectFormatting',
1208+
'format-paragraph-reset',
1209+
[],
1210+
),
1211+
'doc.format.paragraph.setAlignment': paragraphMutationScenario(
1212+
'doc.format.paragraph.setAlignment',
1213+
'format-paragraph-set-alignment',
1214+
['--alignment', 'center'],
1215+
[{ operationId: 'doc.format.paragraph.setAlignment', extraArgs: ['--alignment', 'left'] }],
1216+
),
1217+
'doc.format.paragraph.clearAlignment': paragraphMutationScenario(
1218+
'doc.format.paragraph.clearAlignment',
1219+
'format-paragraph-clear-alignment',
1220+
[],
1221+
),
1222+
'doc.format.paragraph.setIndentation': paragraphMutationScenario(
1223+
'doc.format.paragraph.setIndentation',
1224+
'format-paragraph-set-indentation',
1225+
['--left', '720'],
1226+
),
1227+
'doc.format.paragraph.clearIndentation': paragraphMutationScenario(
1228+
'doc.format.paragraph.clearIndentation',
1229+
'format-paragraph-clear-indentation',
1230+
[],
1231+
[{ operationId: 'doc.format.paragraph.setIndentation', extraArgs: ['--left', '720'] }],
1232+
),
1233+
'doc.format.paragraph.setSpacing': paragraphMutationScenario(
1234+
'doc.format.paragraph.setSpacing',
1235+
'format-paragraph-set-spacing',
1236+
['--before', '120', '--after', '120'],
1237+
),
1238+
'doc.format.paragraph.clearSpacing': paragraphMutationScenario(
1239+
'doc.format.paragraph.clearSpacing',
1240+
'format-paragraph-clear-spacing',
1241+
[],
1242+
[{ operationId: 'doc.format.paragraph.setSpacing', extraArgs: ['--before', '120', '--after', '120'] }],
1243+
),
1244+
'doc.format.paragraph.setKeepOptions': paragraphMutationScenario(
1245+
'doc.format.paragraph.setKeepOptions',
1246+
'format-paragraph-set-keep-options',
1247+
['--keep-next', 'true'],
1248+
),
1249+
'doc.format.paragraph.setOutlineLevel': paragraphMutationScenario(
1250+
'doc.format.paragraph.setOutlineLevel',
1251+
'format-paragraph-set-outline',
1252+
['--outline-level-json', '1'],
1253+
),
1254+
'doc.format.paragraph.setFlowOptions': paragraphMutationScenario(
1255+
'doc.format.paragraph.setFlowOptions',
1256+
'format-paragraph-set-flow',
1257+
['--contextual-spacing', 'true'],
1258+
),
1259+
'doc.format.paragraph.setTabStop': paragraphMutationScenario(
1260+
'doc.format.paragraph.setTabStop',
1261+
'format-paragraph-set-tab-stop',
1262+
['--position', '720', '--alignment', 'left'],
1263+
),
1264+
'doc.format.paragraph.clearTabStop': paragraphMutationScenario(
1265+
'doc.format.paragraph.clearTabStop',
1266+
'format-paragraph-clear-tab-stop',
1267+
['--position', '720'],
1268+
[{ operationId: 'doc.format.paragraph.setTabStop', extraArgs: ['--position', '720', '--alignment', 'left'] }],
1269+
),
1270+
'doc.format.paragraph.clearAllTabStops': paragraphMutationScenario(
1271+
'doc.format.paragraph.clearAllTabStops',
1272+
'format-paragraph-clear-all-tab-stops',
1273+
[],
1274+
[{ operationId: 'doc.format.paragraph.setTabStop', extraArgs: ['--position', '720', '--alignment', 'left'] }],
1275+
),
1276+
'doc.format.paragraph.setBorder': paragraphMutationScenario(
1277+
'doc.format.paragraph.setBorder',
1278+
'format-paragraph-set-border',
1279+
['--side', 'top', '--style', 'single', '--color', '000000'],
1280+
),
1281+
'doc.format.paragraph.clearBorder': paragraphMutationScenario(
1282+
'doc.format.paragraph.clearBorder',
1283+
'format-paragraph-clear-border',
1284+
['--side', 'top'],
1285+
[
1286+
{
1287+
operationId: 'doc.format.paragraph.setBorder',
1288+
extraArgs: ['--side', 'top', '--style', 'single', '--color', '000000'],
1289+
},
1290+
],
1291+
),
1292+
'doc.format.paragraph.setShading': paragraphMutationScenario(
1293+
'doc.format.paragraph.setShading',
1294+
'format-paragraph-set-shading',
1295+
['--fill', 'FFFF00'],
1296+
),
1297+
'doc.format.paragraph.clearShading': paragraphMutationScenario(
1298+
'doc.format.paragraph.clearShading',
1299+
'format-paragraph-clear-shading',
1300+
[],
1301+
[{ operationId: 'doc.format.paragraph.setShading', extraArgs: ['--fill', 'FFFF00'] }],
1302+
),
11651303
'doc.styles.apply': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
11661304
const stateDir = await harness.createStateDir('doc-styles-apply-success');
11671305
const docPath = await harness.copyFixtureDoc('doc-styles-apply');

apps/cli/src/__tests__/lib/error-mapping.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,4 +262,15 @@ describe('mapFailedReceipt: plan-engine code passthrough', () => {
262262
expect(result).toBeInstanceOf(CliError);
263263
expect(result!.code).not.toBe('NO_OP');
264264
});
265+
266+
test('paragraph mutation receipt maps INVALID_TARGET to INVALID_ARGUMENT', () => {
267+
const receipt = {
268+
success: false,
269+
failure: { code: 'INVALID_TARGET', message: 'Paragraph target is invalid.' },
270+
};
271+
272+
const result = mapFailedReceipt('format.paragraph.setAlignment' as any, receipt);
273+
expect(result).toBeInstanceOf(CliError);
274+
expect(result!.code).toBe('INVALID_ARGUMENT');
275+
});
265276
});

apps/cli/src/cli/operation-hints.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,13 @@
99
* OPERATION_DEFINITIONS, the CLI requires only a one-line entry in each table.
1010
*/
1111

12-
import { COMMAND_CATALOG } from '@superdoc/document-api';
12+
import { COMMAND_CATALOG, INLINE_PROPERTY_REGISTRY, type InlineRunPatchKey } from '@superdoc/document-api';
1313
import type { CliExposedOperationId } from './operation-set.js';
1414

15-
type FormatOperationId = Extract<CliExposedOperationId, `format.${string}`>;
16-
type FormatInlineAliasOperationId = Exclude<FormatOperationId, 'format.apply' | 'format.align'>;
15+
type FormatInlineAliasOperationId = `format.${InlineRunPatchKey}`;
1716

18-
const FORMAT_INLINE_ALIAS_OPERATION_IDS = (Object.keys(COMMAND_CATALOG) as CliExposedOperationId[]).filter(
19-
(operationId): operationId is FormatInlineAliasOperationId =>
20-
operationId.startsWith('format.') && operationId !== 'format.apply' && operationId !== 'format.align',
17+
const FORMAT_INLINE_ALIAS_OPERATION_IDS = INLINE_PROPERTY_REGISTRY.map(
18+
(entry) => `format.${entry.key}` as FormatInlineAliasOperationId,
2119
);
2220

2321
function buildFormatInlineAliasRecord<T>(value: T): Record<FormatInlineAliasOperationId, T> {
@@ -27,6 +25,37 @@ function buildFormatInlineAliasRecord<T>(value: T): Record<FormatInlineAliasOper
2725
>;
2826
}
2927

28+
const PARAGRAPH_OPERATION_IDS = [
29+
'styles.paragraph.setStyle',
30+
'styles.paragraph.clearStyle',
31+
'format.paragraph.resetDirectFormatting',
32+
'format.paragraph.setAlignment',
33+
'format.paragraph.clearAlignment',
34+
'format.paragraph.setIndentation',
35+
'format.paragraph.clearIndentation',
36+
'format.paragraph.setSpacing',
37+
'format.paragraph.clearSpacing',
38+
'format.paragraph.setKeepOptions',
39+
'format.paragraph.setOutlineLevel',
40+
'format.paragraph.setFlowOptions',
41+
'format.paragraph.setTabStop',
42+
'format.paragraph.clearTabStop',
43+
'format.paragraph.clearAllTabStops',
44+
'format.paragraph.setBorder',
45+
'format.paragraph.clearBorder',
46+
'format.paragraph.setShading',
47+
'format.paragraph.clearShading',
48+
] as const satisfies readonly CliExposedOperationId[];
49+
50+
type ParagraphOperationId = (typeof PARAGRAPH_OPERATION_IDS)[number];
51+
52+
function buildParagraphRecord<T>(value: T): Record<ParagraphOperationId, T> {
53+
return Object.fromEntries(PARAGRAPH_OPERATION_IDS.map((operationId) => [operationId, value])) as Record<
54+
ParagraphOperationId,
55+
T
56+
>;
57+
}
58+
3059
// ---------------------------------------------------------------------------
3160
// Orchestration kind (derived from COMMAND_CATALOG)
3261
// ---------------------------------------------------------------------------
@@ -52,8 +81,8 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
5281
delete: 'deleted text',
5382
'blocks.delete': 'deleted block',
5483
'format.apply': 'applied style',
55-
'format.align': 'set alignment',
5684
...buildFormatInlineAliasRecord('applied style'),
85+
...buildParagraphRecord('updated paragraph formatting'),
5786
'styles.apply': 'applied stylesheet defaults',
5887
'create.paragraph': 'created paragraph',
5988
'create.heading': 'created heading',
@@ -165,8 +194,8 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
165194
delete: 'mutationReceipt',
166195
'blocks.delete': 'plain',
167196
'format.apply': 'mutationReceipt',
168-
'format.align': 'mutationReceipt',
169197
...buildFormatInlineAliasRecord('mutationReceipt'),
198+
...buildParagraphRecord('plain'),
170199
'styles.apply': 'receipt',
171200
'create.paragraph': 'createResult',
172201
'create.heading': 'createResult',
@@ -262,8 +291,8 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
262291
delete: null,
263292
'blocks.delete': 'result',
264293
'format.apply': null,
265-
'format.align': null,
266294
...buildFormatInlineAliasRecord(null),
295+
...buildParagraphRecord('result'),
267296
'styles.apply': 'receipt',
268297
'create.paragraph': 'result',
269298
'create.heading': 'result',
@@ -353,7 +382,6 @@ export const RESPONSE_VALIDATION_KEY: Partial<Record<CliExposedOperationId, stri
353382
replace: 'receipt',
354383
delete: 'receipt',
355384
'format.apply': 'receipt',
356-
'format.align': 'receipt',
357385
...buildFormatInlineAliasRecord('receipt'),
358386
};
359387

@@ -388,8 +416,8 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
388416
delete: 'textMutation',
389417
'blocks.delete': 'blocks',
390418
'format.apply': 'textMutation',
391-
'format.align': 'textMutation',
392419
...buildFormatInlineAliasRecord('textMutation'),
420+
...buildParagraphRecord('textMutation'),
393421
'styles.apply': 'general',
394422
'create.paragraph': 'create',
395423
'create.heading': 'create',

apps/cli/src/lib/document.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFile, writeFile } from 'node:fs/promises';
22
import { createHash } from 'node:crypto';
3-
import { Editor } from 'superdoc/super-editor';
3+
import type { Editor } from 'superdoc/super-editor';
44
import { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx';
55
import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters';
66
import { markdownToPmDoc } from '@superdoc/super-editor/markdown';
@@ -37,6 +37,38 @@ export interface FileOutputMeta {
3737
byteLength: number;
3838
}
3939

40+
type EditorModule = {
41+
Editor: {
42+
open(source: Buffer, options: Record<string, unknown>): Promise<Editor>;
43+
};
44+
};
45+
46+
const EDITOR_IMPORT_CANDIDATES = ['@superdoc/super-editor', 'superdoc/super-editor'] as const;
47+
let cachedEditorModule: EditorModule | null = null;
48+
49+
async function loadEditorModule(): Promise<EditorModule> {
50+
if (cachedEditorModule) return cachedEditorModule;
51+
52+
const errors: string[] = [];
53+
for (const specifier of EDITOR_IMPORT_CANDIDATES) {
54+
try {
55+
const module = (await import(specifier)) as Partial<EditorModule>;
56+
if (module.Editor && typeof module.Editor.open === 'function') {
57+
cachedEditorModule = module as EditorModule;
58+
return cachedEditorModule;
59+
}
60+
errors.push(`${specifier}: module loaded but Editor.open is unavailable`);
61+
} catch (error) {
62+
errors.push(`${specifier}: ${error instanceof Error ? error.message : String(error)}`);
63+
}
64+
}
65+
66+
throw new CliError('DOCUMENT_OPEN_FAILED', 'Failed to load editor runtime module.', {
67+
candidates: [...EDITOR_IMPORT_CANDIDATES],
68+
errors,
69+
});
70+
}
71+
4072
function toUint8Array(data: unknown): Uint8Array {
4173
if (data instanceof Uint8Array) return data;
4274
if (data instanceof ArrayBuffer) return new Uint8Array(data);
@@ -122,9 +154,10 @@ export async function openDocument(
122154
}
123155

124156
let editor: Editor;
157+
const { Editor: EditorRuntime } = await loadEditorModule();
125158
try {
126159
const isTest = process.env.NODE_ENV === 'test';
127-
editor = await Editor.open(Buffer.from(source), {
160+
editor = await EditorRuntime.open(Buffer.from(source), {
128161
documentId: options.documentId ?? meta.path ?? 'blank.docx',
129162
user: { id: 'cli', name: 'CLI' },
130163
...(isTest ? { telemetry: { enabled: false } } : {}),

0 commit comments

Comments
 (0)