Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,5 @@ packages/sdk/langs/python/superdoc/generated/
packages/sdk/tools/*.json
# Note: apps/docs/document-api/reference/ stays committed because Mintlify
# deploys directly from git with no pre-build hook support.

.playwright-cli/
Original file line number Diff line number Diff line change
Expand Up @@ -962,5 +962,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
"sourceHash": "42294181d9125c3dfb3525be01eb6c645c3a18d511bd95d678d6661920490721"
"sourceHash": "7c98e8a222685ebb7801111b454a2e5bc6ef18b1ce4a9d7d576a5e48aa69bd5f"
}
4 changes: 2 additions & 2 deletions apps/docs/document-api/reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The tables below are grouped by namespace.
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/get-markdown"><code>getMarkdown</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.getMarkdown(...)</code></span> | Extract the document content as a Markdown string. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/get-html"><code>getHtml</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.getHtml(...)</code></span> | Extract the document content as an HTML string. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/markdown-to-fragment"><code>markdownToFragment</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.markdownToFragment(...)</code></span> | Convert a Markdown string into an SDM/1 structural fragment. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/info"><code>info</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.info(...)</code></span> | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/info"><code>info</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.info(...)</code></span> | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/clear-content"><code>clearContent</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.clearContent(...)</code></span> | Clear all document body content, leaving a single empty paragraph. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/insert"><code>insert</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.insert(...)</code></span> | Insert content into the document. Two input shapes: legacy string-based (value + type) inserts inline content at a text position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target is omitted, content appends at the end of the document. Legacy mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/replace"><code>replace</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.replace(...)</code></span> | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. |
Expand Down Expand Up @@ -269,7 +269,7 @@ The tables below are grouped by namespace.

| Operation | API member path | Description |
| --- | --- | --- |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/styles/paragraph/set-style"><code>styles.paragraph.setStyle</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.styles.paragraph.setStyle(...)</code></span> | Set the paragraph style reference (w:pStyle) on a paragraph-like block. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/styles/paragraph/set-style"><code>styles.paragraph.setStyle</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.styles.paragraph.setStyle(...)</code></span> | Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/styles/paragraph/clear-style"><code>styles.paragraph.clearStyle</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.styles.paragraph.clearStyle(...)</code></span> | Remove the paragraph style reference from a paragraph-like block. |

#### Tables
Expand Down
11 changes: 8 additions & 3 deletions apps/docs/document-api/reference/info.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: info
sidebarTitle: info
description: Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities.
description: Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities.
---

{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}
Expand All @@ -10,7 +10,7 @@ description: Return document summary info including word, character, paragraph,

## Summary

Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts, plus outline and capabilities.
Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities.

- Operation ID: `info`
- API member path: `editor.doc.info(...)`
Expand All @@ -22,7 +22,7 @@ Return document summary info including word, character, paragraph, heading, tabl

## Expected result

Returns a DocumentInfo object with counts (words, characters, paragraphs, headings, tables, images, comments, trackedChanges, sdtFields, lists), document outline, capability flags, and revision.
Returns a DocumentInfo object with counts (words, characters, paragraphs, headings, tables, images, comments, trackedChanges, sdtFields, lists, and optionally pages when pagination is active), document outline, capability flags, and revision.

## Input fields

Expand All @@ -49,6 +49,7 @@ _No fields._
| `counts.headings` | integer | yes | |
| `counts.images` | integer | yes | |
| `counts.lists` | integer | yes | |
| `counts.pages` | integer | no | |
| `counts.paragraphs` | integer | yes | |
| `counts.sdtFields` | integer | yes | |
| `counts.tables` | integer | yes | |
Expand All @@ -73,6 +74,7 @@ _No fields._
"headings": 3,
"images": 2,
"lists": 1,
"pages": 1,
"paragraphs": 12,
"sdtFields": 1,
"tables": 1,
Expand Down Expand Up @@ -157,6 +159,9 @@ _No fields._
"lists": {
"type": "integer"
},
"pages": {
"type": "integer"
},
"paragraphs": {
"type": "integer"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: styles.paragraph.setStyle
sidebarTitle: styles.paragraph.setStyle
description: "Set the paragraph style reference (w:pStyle) on a paragraph-like block."
description: "Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references."
---

{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}
Expand All @@ -10,7 +10,7 @@ description: "Set the paragraph style reference (w:pStyle) on a paragraph-like b

## Summary

Set the paragraph style reference (w:pStyle) on a paragraph-like block.
Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references.

- Operation ID: `styles.paragraph.setStyle`
- API member path: `editor.doc.styles.paragraph.setStyle(...)`
Expand All @@ -22,7 +22,7 @@ Set the paragraph style reference (w:pStyle) on a paragraph-like block.

## Expected result

Returns a ParagraphMutationResult; reports NO_OP if the style already matches.
Returns a ParagraphMutationResult; reports NO_OP if the style already matches. When the style changes, direct run formatting is cleared while character-style references are preserved.

## Input fields

Expand Down
6 changes: 4 additions & 2 deletions packages/document-api/src/contract/operation-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,8 +963,10 @@ export const OPERATION_DEFINITIONS = {

'styles.paragraph.setStyle': {
memberPath: 'styles.paragraph.setStyle',
description: 'Set the paragraph style reference (w:pStyle) on a paragraph-like block.',
expectedResult: 'Returns a ParagraphMutationResult; reports NO_OP if the style already matches.',
description:
'Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references.',
expectedResult:
'Returns a ParagraphMutationResult; reports NO_OP if the style already matches. When the style changes, direct run formatting is cleared while character-style references are preserved.',
requiresDocumentContext: true,
metadata: mutationOperation({
idempotency: 'conditional',
Expand Down
2 changes: 1 addition & 1 deletion packages/document-api/src/paragraphs/paragraphs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export interface ParagraphFormatApi {
clearShading(input: ParagraphsClearShadingInput, options?: MutationOptions): ParagraphMutationResult;
}

/** Public API surface for `styles.paragraph.*` — paragraph style reference operations. */
/** Public API surface for `styles.paragraph.*` — Word-like paragraph style application operations. */
export interface ParagraphStylesApi {
setStyle(input: ParagraphsSetStyleInput, options?: MutationOptions): ParagraphMutationResult;
clearStyle(input: ParagraphsClearStyleInput, options?: MutationOptions): ParagraphMutationResult;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ vi.mock('./plan-wrappers.js', () => ({
}),
}));

import { paragraphsSetIndentationWrapper } from './paragraphs-wrappers.js';
import { paragraphsSetIndentationWrapper, paragraphsSetStyleWrapper } from './paragraphs-wrappers.js';

type MockNode = {
type: { name: 'paragraph' };
isBlock: true;
type: { name: 'paragraph' | 'text' };
isBlock?: true;
isText?: true;
nodeSize: number;
attrs: Record<string, unknown>;
marks?: MockMark[];
};

function createParagraphNode(attrs: Record<string, unknown>): MockNode {
Expand All @@ -39,19 +41,58 @@ function createParagraphNode(attrs: Record<string, unknown>): MockNode {
};
}

function makeEditor(paragraphProperties: Record<string, unknown>): {
type MockMark = {
type: { name: string; create?: (attrs: Record<string, unknown>) => MockMark };
attrs?: Record<string, unknown>;
};

function createFormattingMark(name: string, attrs?: Record<string, unknown>): MockMark {
return { type: { name }, attrs };
}

function createTextStyleMark(attrs?: Record<string, unknown>): MockMark {
return createFormattingMark('textStyle', attrs);
}

function makeEditor(
paragraphProperties: Record<string, unknown>,
textMarks: MockMark[] = [],
): {
editor: Editor;
setNodeMarkup: ReturnType<typeof vi.fn>;
removeMark: ReturnType<typeof vi.fn>;
addMark: ReturnType<typeof vi.fn>;
dispatch: ReturnType<typeof vi.fn>;
} {
const paragraphNode = createParagraphNode({
paraId: 'p1',
sdBlockId: 'p1',
paragraphProperties,
});
paragraphNode.nodeSize = 6;

const textNode: MockNode = {
type: { name: 'text' },
isText: true,
nodeSize: 4,
attrs: {},
marks: textMarks,
};

const setNodeMarkup = vi.fn().mockReturnThis();
const removeMark = vi.fn().mockReturnThis();
const addMark = vi.fn().mockReturnThis();
const tr = {
setNodeMarkup,
removeMark,
addMark,
doc: {
nodesBetween(callbackStart: number, callbackEnd: number, callback: (node: MockNode, pos: number) => void) {
if (callbackStart < callbackEnd) {
callback(textNode, 1);
}
},
},
};

const doc = {
Expand All @@ -61,6 +102,11 @@ function makeEditor(paragraphProperties: Record<string, unknown>): {
nodeAt(pos: number) {
return pos === 0 ? paragraphNode : null;
},
nodesBetween(from: number, to: number, callback: (node: MockNode, pos: number) => void) {
if (from < to) {
callback(textNode, 1);
}
},
};

const editor = {
Expand All @@ -69,7 +115,7 @@ function makeEditor(paragraphProperties: Record<string, unknown>): {
commands: {},
} as unknown as Editor;

return { editor, setNodeMarkup };
return { editor, setNodeMarkup, removeMark, addMark, dispatch: editor.dispatch as ReturnType<typeof vi.fn> };
}

describe('paragraphsSetIndentationWrapper', () => {
Expand Down Expand Up @@ -101,3 +147,74 @@ describe('paragraphsSetIndentationWrapper', () => {
expect(nextAttrs.paragraphProperties.indent).toEqual({ right: 120, hanging: 360 });
});
});

describe('paragraphsSetStyleWrapper', () => {
it('clears direct run formatting while leaving unrelated marks untouched', () => {
const boldMark = createFormattingMark('bold');
const textStyleMark = createTextStyleMark({ fontFamily: 'Arial', fontSize: '12pt' });
const hyperlinkMark = createFormattingMark('link');
const { editor, setNodeMarkup, removeMark, addMark } = makeEditor({}, [boldMark, textStyleMark, hyperlinkMark]);

paragraphsSetStyleWrapper(editor, {
target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' },
styleId: 'Heading1',
});

expect(removeMark).toHaveBeenCalledTimes(2);
expect(removeMark).toHaveBeenCalledWith(1, 5, boldMark);
expect(removeMark).toHaveBeenCalledWith(1, 5, textStyleMark);
expect(addMark).not.toHaveBeenCalled();
const nextAttrs = setNodeMarkup.mock.calls[0]?.[2] as { paragraphProperties: Record<string, unknown> };
expect(nextAttrs.paragraphProperties).toEqual({ styleId: 'Heading1' });
});

it('preserves character-style reference (styleId) on textStyle marks', () => {
const emphasisStyleMark = createTextStyleMark({ styleId: 'Emphasis' });
const { editor, removeMark, addMark } = makeEditor({}, [emphasisStyleMark]);

paragraphsSetStyleWrapper(editor, {
target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' },
styleId: 'Heading1',
});

// Mark only has styleId (no formatting attrs) — should not be touched at all
expect(removeMark).not.toHaveBeenCalled();
expect(addMark).not.toHaveBeenCalled();
});

it('strips formatting attrs from textStyle but re-adds styleId', () => {
const createdMark = createTextStyleMark({ styleId: 'Emphasis' });
const mixedMark: MockMark = {
type: { name: 'textStyle', create: (attrs) => ({ ...createdMark, attrs }) },
attrs: { styleId: 'Emphasis', fontFamily: 'Arial' },
};
const { editor, removeMark, addMark } = makeEditor({}, [mixedMark]);

paragraphsSetStyleWrapper(editor, {
target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' },
styleId: 'Heading1',
});

expect(removeMark).toHaveBeenCalledTimes(1);
expect(removeMark).toHaveBeenCalledWith(1, 5, mixedMark);
expect(addMark).toHaveBeenCalledTimes(1);
expect(addMark).toHaveBeenCalledWith(1, 5, expect.objectContaining({ attrs: { styleId: 'Emphasis' } }));
});

it('returns NO_OP when the style already matches', () => {
const boldMark = createFormattingMark('bold');
const { editor, removeMark, dispatch } = makeEditor({ styleId: 'Normal' }, [boldMark]);

const result = paragraphsSetStyleWrapper(editor, {
target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' },
styleId: 'Normal',
});

expect(result).toEqual({
success: false,
failure: { code: 'NO_OP', message: 'styles.paragraph.setStyle produced no changes.' },
});
expect(removeMark).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
});
});
Loading
Loading