diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 62cb23faa1..46333d3d2a 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -194,6 +194,20 @@ const INTENT_NAMES = { 'doc.history.get': 'get_history', 'doc.history.undo': 'undo', 'doc.history.redo': 'redo', + 'doc.create.image': 'create_image', + 'doc.images.list': 'list_images', + 'doc.images.get': 'get_image', + 'doc.images.delete': 'delete_image', + 'doc.images.move': 'move_image', + 'doc.images.convertToInline': 'convert_image_to_inline', + 'doc.images.convertToFloating': 'convert_image_to_floating', + 'doc.images.setSize': 'set_image_size', + 'doc.images.setWrapType': 'set_image_wrap_type', + 'doc.images.setWrapSide': 'set_image_wrap_side', + 'doc.images.setWrapDistances': 'set_image_wrap_distances', + 'doc.images.setPosition': 'set_image_position', + 'doc.images.setAnchorOptions': 'set_image_anchor_options', + 'doc.images.setZOrder': 'set_image_z_order', } as const satisfies Record; // --------------------------------------------------------------------------- diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 6fecef8c12..884ebfe2bb 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -559,6 +559,112 @@ async function createDocWithMarkedTocEntry( return { docPath: markedDoc, entryAddress }; } +const CONFORMANCE_IMAGE_DATA_URI = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII='; + +type ImagePlacement = 'inline' | 'floating'; +type ImageFixture = { + docPath: string; + imageId: string; +}; + +function pickImageId( + items: Record[], + context: string, + placement?: ImagePlacement, +): { imageId: string; item: Record } { + const match = + placement === undefined + ? items[0] + : (items.find((item) => { + const address = item.address; + if (!address || typeof address !== 'object') return false; + return (address as Record).placement === placement; + }) ?? items[0]); + + if (!match) { + throw new Error(`[${context}] No images available.`); + } + + const imageId = match.sdImageId; + if (typeof imageId !== 'string' || imageId.length === 0) { + throw new Error(`[${context}] Unable to resolve image id from list output.`); + } + + return { imageId, item: match }; +} + +async function resolveImageFixture( + harness: ConformanceHarness, + stateDir: string, + docPath: string, + context: string, + placement?: ImagePlacement, +): Promise { + const listed = await harness.runCli([...commandTokens('doc.images.list'), docPath, '--limit', '20'], stateDir); + if (listed.result.code !== 0 || listed.envelope.ok !== true) { + throw new Error(`[${context}] Failed to list images.`); + } + + const items = extractDiscoveryItems(listed.envelope.data); + const { imageId } = pickImageId(items, context, placement); + return { docPath, imageId }; +} + +async function createInlineImageFixture( + harness: ConformanceHarness, + stateDir: string, + label: string, +): Promise { + const sourceDoc = await harness.copyFixtureDoc(`${label}-source`); + const outputDoc = harness.createOutputPath(`${label}-with-image`); + const created = await harness.runCli( + [ + ...commandTokens('doc.create.image'), + sourceDoc, + '--src', + CONFORMANCE_IMAGE_DATA_URI, + '--alt', + 'Conformance image', + '--at-json', + JSON.stringify({ kind: 'documentEnd' }), + '--out', + outputDoc, + ], + stateDir, + ); + if (created.result.code !== 0 || created.envelope.ok !== true) { + throw new Error(`[${label}] Failed to create image fixture.`); + } + + return resolveImageFixture(harness, stateDir, outputDoc, `${label}:inline`, 'inline'); +} + +async function createFloatingImageFixture( + harness: ConformanceHarness, + stateDir: string, + label: string, +): Promise { + const inlineFixture = await createInlineImageFixture(harness, stateDir, `${label}-seed-inline`); + const floatingDoc = harness.createOutputPath(`${label}-floating`); + const converted = await harness.runCli( + [ + ...commandTokens('doc.images.convertToFloating'), + inlineFixture.docPath, + '--image-id', + inlineFixture.imageId, + '--out', + floatingDoc, + ], + stateDir, + ); + if (converted.result.code !== 0 || converted.envelope.ok !== true) { + throw new Error(`[${label}] Failed to convert fixture image to floating.`); + } + + return resolveImageFixture(harness, stateDir, floatingDoc, `${label}:floating`, 'floating'); +} + export const SUCCESS_SCENARIOS = { 'doc.open': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-open-success'); @@ -1642,6 +1748,227 @@ export const SUCCESS_SCENARIOS = { ], }; }, + + // --------------------------------------------------------------------------- + // Image operations + // --------------------------------------------------------------------------- + + 'doc.create.image': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-create-image-success'); + const docPath = await harness.copyFixtureDoc('doc-create-image'); + return { + stateDir, + args: [ + ...commandTokens('doc.create.image'), + docPath, + '--src', + CONFORMANCE_IMAGE_DATA_URI, + '--alt', + 'Conformance image', + '--at-json', + JSON.stringify({ kind: 'documentEnd' }), + '--out', + harness.createOutputPath('doc-create-image-output'), + ], + }; + }, + 'doc.images.list': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-list-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-list'); + return { + stateDir, + args: [...commandTokens('doc.images.list'), fixture.docPath, '--limit', '20'], + }; + }, + 'doc.images.get': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-get-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-get'); + return { + stateDir, + args: [...commandTokens('doc.images.get'), fixture.docPath, '--image-id', fixture.imageId], + }; + }, + 'doc.images.delete': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-delete-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-delete'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.delete'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--out', + harness.createOutputPath('doc-images-delete-output'), + ], + }; + }, + 'doc.images.move': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-move-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-move'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.move'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--to-json', + JSON.stringify({ kind: 'documentStart' }), + '--out', + harness.createOutputPath('doc-images-move-output'), + ], + }; + }, + 'doc.images.convertToInline': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-convert-to-inline-success'); + const fixture = await createFloatingImageFixture(harness, stateDir, 'doc-images-convert-to-inline'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.convertToInline'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--out', + harness.createOutputPath('doc-images-convert-to-inline-output'), + ], + }; + }, + 'doc.images.convertToFloating': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-convert-to-floating-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-convert-to-floating'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.convertToFloating'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--out', + harness.createOutputPath('doc-images-convert-to-floating-output'), + ], + }; + }, + 'doc.images.setSize': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-size-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-set-size'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setSize'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--size-json', + JSON.stringify({ width: 240, height: 120 }), + '--out', + harness.createOutputPath('doc-images-set-size-output'), + ], + }; + }, + 'doc.images.setWrapType': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-wrap-type-success'); + const fixture = await createFloatingImageFixture(harness, stateDir, 'doc-images-set-wrap-type'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setWrapType'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--type', + 'Tight', + '--out', + harness.createOutputPath('doc-images-set-wrap-type-output'), + ], + }; + }, + 'doc.images.setWrapSide': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-wrap-side-success'); + const fixture = await createFloatingImageFixture(harness, stateDir, 'doc-images-set-wrap-side'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setWrapSide'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--side', + 'left', + '--out', + harness.createOutputPath('doc-images-set-wrap-side-output'), + ], + }; + }, + 'doc.images.setWrapDistances': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-wrap-distances-success'); + const fixture = await createFloatingImageFixture(harness, stateDir, 'doc-images-set-wrap-distances'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setWrapDistances'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--distances-json', + JSON.stringify({ distTop: 100, distBottom: 100 }), + '--out', + harness.createOutputPath('doc-images-set-wrap-distances-output'), + ], + }; + }, + 'doc.images.setPosition': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-position-success'); + const fixture = await createFloatingImageFixture(harness, stateDir, 'doc-images-set-position'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setPosition'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--position-json', + JSON.stringify({ hRelativeFrom: 'column', alignH: 'center' }), + '--out', + harness.createOutputPath('doc-images-set-position-output'), + ], + }; + }, + 'doc.images.setAnchorOptions': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-anchor-options-success'); + const fixture = await createFloatingImageFixture(harness, stateDir, 'doc-images-set-anchor-options'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setAnchorOptions'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--options-json', + JSON.stringify({ behindDoc: true, allowOverlap: false }), + '--out', + harness.createOutputPath('doc-images-set-anchor-options-output'), + ], + }; + }, + 'doc.images.setZOrder': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-z-order-success'); + const fixture = await createFloatingImageFixture(harness, stateDir, 'doc-images-set-z-order'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setZOrder'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--z-order-json', + JSON.stringify({ relativeHeight: 500 }), + '--out', + harness.createOutputPath('doc-images-set-z-order-output'), + ], + }; + }, 'doc.toc.list': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-toc-list-success'); const docPath = await harness.copyTocFixtureDoc('doc-toc-list', stateDir); diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index eae120f593..74bd34b381 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -176,6 +176,22 @@ export const SUCCESS_VERB: Record = { 'history.get': 'retrieved history state', 'history.undo': 'undid last change', 'history.redo': 'redid last change', + + // Images + 'create.image': 'created image', + 'images.list': 'listed images', + 'images.get': 'resolved image', + 'images.delete': 'deleted image', + 'images.move': 'moved image', + 'images.convertToInline': 'converted to inline', + 'images.convertToFloating': 'converted to floating', + 'images.setSize': 'set image size', + 'images.setWrapType': 'set wrap type', + 'images.setWrapSide': 'set wrap side', + 'images.setWrapDistances': 'set wrap distances', + 'images.setPosition': 'set position', + 'images.setAnchorOptions': 'set anchor options', + 'images.setZOrder': 'set z-order', }; // --------------------------------------------------------------------------- @@ -311,6 +327,22 @@ export const OUTPUT_FORMAT: Record = { 'history.get': 'plain', 'history.undo': 'plain', 'history.redo': 'plain', + + // Images + 'create.image': 'createResult', + 'images.list': 'plain', + 'images.get': 'plain', + 'images.delete': 'plain', + 'images.move': 'plain', + 'images.convertToInline': 'plain', + 'images.convertToFloating': 'plain', + 'images.setSize': 'plain', + 'images.setWrapType': 'plain', + 'images.setWrapSide': 'plain', + 'images.setWrapDistances': 'plain', + 'images.setPosition': 'plain', + 'images.setAnchorOptions': 'plain', + 'images.setZOrder': 'plain', }; // --------------------------------------------------------------------------- @@ -430,6 +462,22 @@ export const RESPONSE_ENVELOPE_KEY: Record 'history.get': 'result', 'history.undo': 'result', 'history.redo': 'result', + + // Images + 'create.image': 'result', + 'images.list': 'result', + 'images.get': 'result', + 'images.delete': 'result', + 'images.move': 'result', + 'images.convertToInline': 'result', + 'images.convertToFloating': 'result', + 'images.setSize': 'result', + 'images.setWrapType': 'result', + 'images.setWrapSide': 'result', + 'images.setWrapDistances': 'result', + 'images.setPosition': 'result', + 'images.setAnchorOptions': 'result', + 'images.setZOrder': 'result', }; // --------------------------------------------------------------------------- @@ -464,6 +512,7 @@ export type OperationFamily = | 'comments' | 'lists' | 'tables' + | 'images' | 'toc' | 'textMutation' | 'create' @@ -577,4 +626,20 @@ export const OPERATION_FAMILY: Record = 'history.get': 'query', 'history.undo': 'general', 'history.redo': 'general', + + // Images + 'create.image': 'images', + 'images.list': 'images', + 'images.get': 'images', + 'images.delete': 'images', + 'images.move': 'images', + 'images.convertToInline': 'images', + 'images.convertToFloating': 'images', + 'images.setSize': 'images', + 'images.setWrapType': 'images', + 'images.setWrapSide': 'images', + 'images.setWrapDistances': 'images', + 'images.setPosition': 'images', + 'images.setAnchorOptions': 'images', + 'images.setZOrder': 'images', }; diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index 20e0cf74f5..a0840710c7 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -97,6 +97,26 @@ function mapListsError(operationId: CliExposedOperationId, error: unknown, code: return new CliError('COMMAND_FAILED', message, { operationId, details }); } +function mapImagesError(operationId: CliExposedOperationId, error: unknown, code: string | undefined): CliError { + const message = extractErrorMessage(error); + const details = extractErrorDetails(error); + + if (code === 'TARGET_NOT_FOUND') { + return new CliError('TARGET_NOT_FOUND', message, { operationId, details }); + } + + if (code === 'INVALID_TARGET') { + return new CliError('INVALID_ARGUMENT', message, { operationId, details }); + } + + if (code === 'CAPABILITY_UNAVAILABLE' || code === 'COMMAND_UNAVAILABLE') { + return new CliError('COMMAND_FAILED', message, { operationId, details }); + } + + if (error instanceof CliError) return error; + return new CliError('COMMAND_FAILED', message, { operationId, details }); +} + function mapTablesError(operationId: CliExposedOperationId, error: unknown, code: string | undefined): CliError { const message = extractErrorMessage(error); const details = extractErrorDetails(error); @@ -305,6 +325,7 @@ const FAMILY_MAPPERS: Record< comments: mapCommentsError, lists: mapListsError, tables: mapTablesError, + images: mapImagesError, toc: mapTocError, textMutation: mapTextMutationError, create: mapCreateError, @@ -460,6 +481,17 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure }); } + // Images family + if (family === 'images') { + if (failureCode === 'TARGET_NOT_FOUND') { + return new CliError('TARGET_NOT_FOUND', failureMessage, { operationId, failure }); + } + if (failureCode === 'INVALID_TARGET') { + return new CliError('INVALID_ARGUMENT', failureMessage, { operationId, failure }); + } + return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure }); + } + // Tables family if (family === 'tables') { if (failureCode === 'TARGET_NOT_FOUND') { diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 4c9ba72cc0..e8a22a7335 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -18,9 +18,10 @@ Use the tables below to see what operations are available and where each one is | Capabilities | 1 | 0 | 1 | [Reference](/document-api/reference/capabilities/index) | | Comments | 5 | 0 | 5 | [Reference](/document-api/reference/comments/index) | | Core | 10 | 0 | 10 | [Reference](/document-api/reference/core/index) | -| Create | 5 | 0 | 5 | [Reference](/document-api/reference/create/index) | +| Create | 6 | 0 | 6 | [Reference](/document-api/reference/create/index) | | Format | 44 | 1 | 45 | [Reference](/document-api/reference/format/index) | | History | 3 | 0 | 3 | [Reference](/document-api/reference/history/index) | +| Images | 13 | 0 | 13 | [Reference](/document-api/reference/images/index) | | Lists | 17 | 0 | 17 | [Reference](/document-api/reference/lists/index) | | Mutations | 2 | 0 | 2 | [Reference](/document-api/reference/mutations/index) | | Paragraph Formatting | 17 | 0 | 17 | [Reference](/document-api/reference/format/paragraph/index) | @@ -56,6 +57,7 @@ Use the tables below to see what operations are available and where each one is | editor.doc.create.sectionBreak(...) | [`create.sectionBreak`](/document-api/reference/create/section-break) | | editor.doc.create.table(...) | [`create.table`](/document-api/reference/create/table) | | editor.doc.create.tableOfContents(...) | [`create.tableOfContents`](/document-api/reference/create/table-of-contents) | +| editor.doc.create.image(...) | [`create.image`](/document-api/reference/create/image) | | editor.doc.format.apply(...) | [`format.apply`](/document-api/reference/format/apply) | | editor.doc.format.bold(...) | [`format.bold`](/document-api/reference/format/bold) | | editor.doc.format.italic(...) | [`format.italic`](/document-api/reference/format/italic) | @@ -104,6 +106,19 @@ Use the tables below to see what operations are available and where each one is | editor.doc.history.get(...) | [`history.get`](/document-api/reference/history/get) | | editor.doc.history.undo(...) | [`history.undo`](/document-api/reference/history/undo) | | editor.doc.history.redo(...) | [`history.redo`](/document-api/reference/history/redo) | +| editor.doc.images.list(...) | [`images.list`](/document-api/reference/images/list) | +| editor.doc.images.get(...) | [`images.get`](/document-api/reference/images/get) | +| editor.doc.images.delete(...) | [`images.delete`](/document-api/reference/images/delete) | +| editor.doc.images.move(...) | [`images.move`](/document-api/reference/images/move) | +| editor.doc.images.convertToInline(...) | [`images.convertToInline`](/document-api/reference/images/convert-to-inline) | +| editor.doc.images.convertToFloating(...) | [`images.convertToFloating`](/document-api/reference/images/convert-to-floating) | +| editor.doc.images.setSize(...) | [`images.setSize`](/document-api/reference/images/set-size) | +| editor.doc.images.setWrapType(...) | [`images.setWrapType`](/document-api/reference/images/set-wrap-type) | +| editor.doc.images.setWrapSide(...) | [`images.setWrapSide`](/document-api/reference/images/set-wrap-side) | +| editor.doc.images.setWrapDistances(...) | [`images.setWrapDistances`](/document-api/reference/images/set-wrap-distances) | +| editor.doc.images.setPosition(...) | [`images.setPosition`](/document-api/reference/images/set-position) | +| editor.doc.images.setAnchorOptions(...) | [`images.setAnchorOptions`](/document-api/reference/images/set-anchor-options) | +| editor.doc.images.setZOrder(...) | [`images.setZOrder`](/document-api/reference/images/set-z-order) | | editor.doc.lists.list(...) | [`lists.list`](/document-api/reference/lists/list) | | editor.doc.lists.get(...) | [`lists.get`](/document-api/reference/lists/get) | | editor.doc.lists.insert(...) | [`lists.insert`](/document-api/reference/lists/insert) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 975bec65ea..419419296d 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -13,6 +13,7 @@ "apps/docs/document-api/reference/comments/patch.mdx", "apps/docs/document-api/reference/core/index.mdx", "apps/docs/document-api/reference/create/heading.mdx", + "apps/docs/document-api/reference/create/image.mdx", "apps/docs/document-api/reference/create/index.mdx", "apps/docs/document-api/reference/create/paragraph.mdx", "apps/docs/document-api/reference/create/section-break.mdx", @@ -92,6 +93,20 @@ "apps/docs/document-api/reference/history/index.mdx", "apps/docs/document-api/reference/history/redo.mdx", "apps/docs/document-api/reference/history/undo.mdx", + "apps/docs/document-api/reference/images/convert-to-floating.mdx", + "apps/docs/document-api/reference/images/convert-to-inline.mdx", + "apps/docs/document-api/reference/images/delete.mdx", + "apps/docs/document-api/reference/images/get.mdx", + "apps/docs/document-api/reference/images/index.mdx", + "apps/docs/document-api/reference/images/list.mdx", + "apps/docs/document-api/reference/images/move.mdx", + "apps/docs/document-api/reference/images/set-anchor-options.mdx", + "apps/docs/document-api/reference/images/set-position.mdx", + "apps/docs/document-api/reference/images/set-size.mdx", + "apps/docs/document-api/reference/images/set-wrap-distances.mdx", + "apps/docs/document-api/reference/images/set-wrap-side.mdx", + "apps/docs/document-api/reference/images/set-wrap-type.mdx", + "apps/docs/document-api/reference/images/set-z-order.mdx", "apps/docs/document-api/reference/index.mdx", "apps/docs/document-api/reference/info.mdx", "apps/docs/document-api/reference/insert.mdx", @@ -244,7 +259,8 @@ "create.heading", "create.sectionBreak", "create.table", - "create.tableOfContents" + "create.tableOfContents", + "create.image" ], "pagePath": "apps/docs/document-api/reference/create/index.mdx", "title": "Create" @@ -493,8 +509,29 @@ ], "pagePath": "apps/docs/document-api/reference/toc/index.mdx", "title": "Table of Contents" + }, + { + "aliasMemberPaths": [], + "key": "images", + "operationIds": [ + "images.list", + "images.get", + "images.delete", + "images.move", + "images.convertToInline", + "images.convertToFloating", + "images.setSize", + "images.setWrapType", + "images.setWrapSide", + "images.setWrapDistances", + "images.setPosition", + "images.setAnchorOptions", + "images.setZOrder" + ], + "pagePath": "apps/docs/document-api/reference/images/index.mdx", + "title": "Images" } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "e06db0688bf06e7963ac3e777833076f02a71b4fa840cf301ca76a51efedc48f" + "sourceHash": "4a1730dcf3d8aecc2706b7507381afedffd8fd4bbf5e930d9afe3c6f79403c3a" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 18f347e6cc..9693e95301 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -312,6 +312,11 @@ _No fields._ | `operations.create.heading.dryRun` | boolean | yes | | | `operations.create.heading.reasons` | enum[] | no | | | `operations.create.heading.tracked` | boolean | yes | | +| `operations.create.image` | object | yes | | +| `operations.create.image.available` | boolean | yes | | +| `operations.create.image.dryRun` | boolean | yes | | +| `operations.create.image.reasons` | enum[] | no | | +| `operations.create.image.tracked` | boolean | yes | | | `operations.create.paragraph` | object | yes | | | `operations.create.paragraph.available` | boolean | yes | | | `operations.create.paragraph.dryRun` | boolean | yes | | @@ -687,6 +692,71 @@ _No fields._ | `operations.history.undo.dryRun` | boolean | yes | | | `operations.history.undo.reasons` | enum[] | no | | | `operations.history.undo.tracked` | boolean | yes | | +| `operations.images.convertToFloating` | object | yes | | +| `operations.images.convertToFloating.available` | boolean | yes | | +| `operations.images.convertToFloating.dryRun` | boolean | yes | | +| `operations.images.convertToFloating.reasons` | enum[] | no | | +| `operations.images.convertToFloating.tracked` | boolean | yes | | +| `operations.images.convertToInline` | object | yes | | +| `operations.images.convertToInline.available` | boolean | yes | | +| `operations.images.convertToInline.dryRun` | boolean | yes | | +| `operations.images.convertToInline.reasons` | enum[] | no | | +| `operations.images.convertToInline.tracked` | boolean | yes | | +| `operations.images.delete` | object | yes | | +| `operations.images.delete.available` | boolean | yes | | +| `operations.images.delete.dryRun` | boolean | yes | | +| `operations.images.delete.reasons` | enum[] | no | | +| `operations.images.delete.tracked` | boolean | yes | | +| `operations.images.get` | object | yes | | +| `operations.images.get.available` | boolean | yes | | +| `operations.images.get.dryRun` | boolean | yes | | +| `operations.images.get.reasons` | enum[] | no | | +| `operations.images.get.tracked` | boolean | yes | | +| `operations.images.list` | object | yes | | +| `operations.images.list.available` | boolean | yes | | +| `operations.images.list.dryRun` | boolean | yes | | +| `operations.images.list.reasons` | enum[] | no | | +| `operations.images.list.tracked` | boolean | yes | | +| `operations.images.move` | object | yes | | +| `operations.images.move.available` | boolean | yes | | +| `operations.images.move.dryRun` | boolean | yes | | +| `operations.images.move.reasons` | enum[] | no | | +| `operations.images.move.tracked` | boolean | yes | | +| `operations.images.setAnchorOptions` | object | yes | | +| `operations.images.setAnchorOptions.available` | boolean | yes | | +| `operations.images.setAnchorOptions.dryRun` | boolean | yes | | +| `operations.images.setAnchorOptions.reasons` | enum[] | no | | +| `operations.images.setAnchorOptions.tracked` | boolean | yes | | +| `operations.images.setPosition` | object | yes | | +| `operations.images.setPosition.available` | boolean | yes | | +| `operations.images.setPosition.dryRun` | boolean | yes | | +| `operations.images.setPosition.reasons` | enum[] | no | | +| `operations.images.setPosition.tracked` | boolean | yes | | +| `operations.images.setSize` | object | yes | | +| `operations.images.setSize.available` | boolean | yes | | +| `operations.images.setSize.dryRun` | boolean | yes | | +| `operations.images.setSize.reasons` | enum[] | no | | +| `operations.images.setSize.tracked` | boolean | yes | | +| `operations.images.setWrapDistances` | object | yes | | +| `operations.images.setWrapDistances.available` | boolean | yes | | +| `operations.images.setWrapDistances.dryRun` | boolean | yes | | +| `operations.images.setWrapDistances.reasons` | enum[] | no | | +| `operations.images.setWrapDistances.tracked` | boolean | yes | | +| `operations.images.setWrapSide` | object | yes | | +| `operations.images.setWrapSide.available` | boolean | yes | | +| `operations.images.setWrapSide.dryRun` | boolean | yes | | +| `operations.images.setWrapSide.reasons` | enum[] | no | | +| `operations.images.setWrapSide.tracked` | boolean | yes | | +| `operations.images.setWrapType` | object | yes | | +| `operations.images.setWrapType.available` | boolean | yes | | +| `operations.images.setWrapType.dryRun` | boolean | yes | | +| `operations.images.setWrapType.reasons` | enum[] | no | | +| `operations.images.setWrapType.tracked` | boolean | yes | | +| `operations.images.setZOrder` | object | yes | | +| `operations.images.setZOrder.available` | boolean | yes | | +| `operations.images.setZOrder.dryRun` | boolean | yes | | +| `operations.images.setZOrder.reasons` | enum[] | no | | +| `operations.images.setZOrder.tracked` | boolean | yes | | | `operations.info` | object | yes | | | `operations.info.available` | boolean | yes | | | `operations.info.dryRun` | boolean | yes | | @@ -1552,6 +1622,14 @@ _No fields._ ], "tracked": true }, + "create.image": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "create.paragraph": { "available": true, "dryRun": true, @@ -2152,6 +2230,110 @@ _No fields._ ], "tracked": true }, + "images.convertToFloating": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.convertToInline": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.delete": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.get": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.list": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.move": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setAnchorOptions": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setPosition": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setSize": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setWrapDistances": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setWrapSide": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setWrapType": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setZOrder": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "info": { "available": true, "dryRun": true, @@ -4897,6 +5079,41 @@ _No fields._ ], "type": "object" }, + "create.image": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "create.paragraph": { "additionalProperties": false, "properties": { @@ -7522,6 +7739,461 @@ _No fields._ ], "type": "object" }, + "images.convertToFloating": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.convertToInline": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.delete": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.list": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.move": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.setAnchorOptions": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.setPosition": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.setSize": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.setWrapDistances": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.setWrapSide": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.setWrapType": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "images.setZOrder": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "info": { "additionalProperties": false, "properties": { @@ -11170,7 +11842,21 @@ _No fields._ "toc.editEntry", "history.get", "history.undo", - "history.redo" + "history.redo", + "create.image", + "images.list", + "images.get", + "images.delete", + "images.move", + "images.convertToInline", + "images.convertToFloating", + "images.setSize", + "images.setWrapType", + "images.setWrapSide", + "images.setWrapDistances", + "images.setPosition", + "images.setAnchorOptions", + "images.setZOrder" ], "type": "object" }, diff --git a/apps/docs/document-api/reference/create/image.mdx b/apps/docs/document-api/reference/create/image.mdx new file mode 100644 index 0000000000..7a8ef32a4d --- /dev/null +++ b/apps/docs/document-api/reference/create/image.mdx @@ -0,0 +1,270 @@ +--- +title: create.image +sidebarTitle: create.image +description: Insert a new image at the target position. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Insert a new image at the target position. + +- Operation ID: `create.image` +- API member path: `editor.doc.create.image(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a CreateImageResult with the new image address. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `alt` | string | no | | +| `at` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") \\| object(kind="inParagraph") | no | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="inParagraph") | +| `size` | object | no | | +| `size.height` | number | no | | +| `size.width` | number | no | | +| `src` | string | yes | | +| `title` | string | no | | + +### Example request + +```json +{ + "alt": "example", + "src": "example", + "title": "example" +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `INVALID_INPUT` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "alt": { + "type": "string" + }, + "at": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentStart" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentEnd" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "before" + }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "after" + }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inParagraph" + }, + "offset": { + "type": "integer" + }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + } + ] + }, + "size": { + "additionalProperties": false, + "properties": { + "height": { + "type": "number" + }, + "width": { + "type": "number" + } + }, + "type": "object" + }, + "src": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "INVALID_INPUT" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/create/index.mdx b/apps/docs/document-api/reference/create/index.mdx index ec8432c8bb..e6a2a33d7a 100644 --- a/apps/docs/document-api/reference/create/index.mdx +++ b/apps/docs/document-api/reference/create/index.mdx @@ -19,4 +19,5 @@ Structured creation helpers. | create.sectionBreak | `create.sectionBreak` | Yes | `non-idempotent` | No | Yes | | create.table | `create.table` | Yes | `non-idempotent` | Yes | Yes | | create.tableOfContents | `create.tableOfContents` | Yes | `non-idempotent` | No | Yes | +| create.image | `create.image` | Yes | `non-idempotent` | No | Yes | diff --git a/apps/docs/document-api/reference/images/convert-to-floating.mdx b/apps/docs/document-api/reference/images/convert-to-floating.mdx new file mode 100644 index 0000000000..40acf5c575 --- /dev/null +++ b/apps/docs/document-api/reference/images/convert-to-floating.mdx @@ -0,0 +1,161 @@ +--- +title: images.convertToFloating +sidebarTitle: images.convertToFloating +description: Convert an inline image to floating placement. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Convert an inline image to floating placement. + +- Operation ID: `images.convertToFloating` +- API member path: `editor.doc.images.convertToFloating(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult; reports NO_OP if already floating. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "imageId": "example" +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/convert-to-inline.mdx b/apps/docs/document-api/reference/images/convert-to-inline.mdx new file mode 100644 index 0000000000..682c420ca2 --- /dev/null +++ b/apps/docs/document-api/reference/images/convert-to-inline.mdx @@ -0,0 +1,161 @@ +--- +title: images.convertToInline +sidebarTitle: images.convertToInline +description: Convert a floating image to inline placement. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Convert a floating image to inline placement. + +- Operation ID: `images.convertToInline` +- API member path: `editor.doc.images.convertToInline(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult; reports NO_OP if already inline. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "imageId": "example" +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/delete.mdx b/apps/docs/document-api/reference/images/delete.mdx new file mode 100644 index 0000000000..2c44aad77b --- /dev/null +++ b/apps/docs/document-api/reference/images/delete.mdx @@ -0,0 +1,161 @@ +--- +title: images.delete +sidebarTitle: images.delete +description: Delete an image from the document. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Delete an image from the document. + +- Operation ID: `images.delete` +- API member path: `editor.doc.images.delete(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult indicating success or failure. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "imageId": "example" +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/get.mdx b/apps/docs/document-api/reference/images/get.mdx new file mode 100644 index 0000000000..ddbfccfbb5 --- /dev/null +++ b/apps/docs/document-api/reference/images/get.mdx @@ -0,0 +1,85 @@ +--- +title: images.get +sidebarTitle: images.get +description: Get details for a specific image by its stable ID. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Get details for a specific image by its stable ID. + +- Operation ID: `images.get` +- API member path: `editor.doc.images.get(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImageSummary with full image properties. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "imageId": "example" +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId" + ], + "type": "object" +} +``` + + + +```json +{ + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/index.mdx b/apps/docs/document-api/reference/images/index.mdx new file mode 100644 index 0000000000..3cc8366309 --- /dev/null +++ b/apps/docs/document-api/reference/images/index.mdx @@ -0,0 +1,30 @@ +--- +title: Images operations +sidebarTitle: Images +description: Images operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Image lifecycle, placement, and wrap configuration. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| images.list | `images.list` | No | `idempotent` | No | No | +| images.get | `images.get` | No | `idempotent` | No | No | +| images.delete | `images.delete` | Yes | `conditional` | No | Yes | +| images.move | `images.move` | Yes | `non-idempotent` | No | Yes | +| images.convertToInline | `images.convertToInline` | Yes | `conditional` | No | Yes | +| images.convertToFloating | `images.convertToFloating` | Yes | `conditional` | No | Yes | +| images.setSize | `images.setSize` | Yes | `conditional` | No | Yes | +| images.setWrapType | `images.setWrapType` | Yes | `conditional` | No | Yes | +| images.setWrapSide | `images.setWrapSide` | Yes | `conditional` | No | Yes | +| images.setWrapDistances | `images.setWrapDistances` | Yes | `conditional` | No | Yes | +| images.setPosition | `images.setPosition` | Yes | `conditional` | No | Yes | +| images.setAnchorOptions | `images.setAnchorOptions` | Yes | `conditional` | No | Yes | +| images.setZOrder | `images.setZOrder` | Yes | `conditional` | No | Yes | + diff --git a/apps/docs/document-api/reference/images/list.mdx b/apps/docs/document-api/reference/images/list.mdx new file mode 100644 index 0000000000..416d0ea073 --- /dev/null +++ b/apps/docs/document-api/reference/images/list.mdx @@ -0,0 +1,110 @@ +--- +title: images.list +sidebarTitle: images.list +description: List all images in the document. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +List all images in the document. + +- Operation ID: `images.list` +- API member path: `editor.doc.images.list(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesListResult with total count and image summaries. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `limit` | integer | no | | +| `offset` | integer | no | | + +### Example request + +```json +{ + "limit": 50, + "offset": 0 +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `items` | object[] | yes | | +| `total` | integer | yes | | + +### Example response + +```json +{ + "items": [ + {} + ], + "total": 1 +} +``` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "items": { + "items": { + "type": "object" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "total", + "items" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/move.mdx b/apps/docs/document-api/reference/images/move.mdx new file mode 100644 index 0000000000..aa0739fb69 --- /dev/null +++ b/apps/docs/document-api/reference/images/move.mdx @@ -0,0 +1,245 @@ +--- +title: images.move +sidebarTitle: images.move +description: Move an image to a new location in the document. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Move an image to a new location in the document. + +- Operation ID: `images.move` +- API member path: `editor.doc.images.move(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult indicating success or failure. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `to` | object(kind="documentStart") \\| object(kind="documentEnd") \\| object(kind="before") \\| object(kind="after") \\| object(kind="inParagraph") | yes | One of: object(kind="documentStart"), object(kind="documentEnd"), object(kind="before"), object(kind="after"), object(kind="inParagraph") | + +### Example request + +```json +{ + "imageId": "example", + "to": { + "kind": "documentStart" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `INVALID_TARGET` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "to": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentStart" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "documentEnd" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "before" + }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "after" + }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inParagraph" + }, + "offset": { + "type": "integer" + }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + } + ] + } + }, + "required": [ + "imageId", + "to" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/set-anchor-options.mdx b/apps/docs/document-api/reference/images/set-anchor-options.mdx new file mode 100644 index 0000000000..88b2c4579a --- /dev/null +++ b/apps/docs/document-api/reference/images/set-anchor-options.mdx @@ -0,0 +1,193 @@ +--- +title: images.setAnchorOptions +sidebarTitle: images.setAnchorOptions +description: Set anchor behavior options for a floating image. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set anchor behavior options for a floating image. + +- Operation ID: `images.setAnchorOptions` +- API member path: `editor.doc.images.setAnchorOptions(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `options` | object | yes | | +| `options.allowOverlap` | boolean | no | | +| `options.behindDoc` | boolean | no | | +| `options.layoutInCell` | boolean | no | | +| `options.lockAnchor` | boolean | no | | +| `options.simplePos` | boolean | no | | + +### Example request + +```json +{ + "imageId": "example", + "options": { + "allowOverlap": true, + "behindDoc": true + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "options": { + "additionalProperties": false, + "properties": { + "allowOverlap": { + "type": "boolean" + }, + "behindDoc": { + "type": "boolean" + }, + "layoutInCell": { + "type": "boolean" + }, + "lockAnchor": { + "type": "boolean" + }, + "simplePos": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "required": [ + "imageId", + "options" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/set-position.mdx b/apps/docs/document-api/reference/images/set-position.mdx new file mode 100644 index 0000000000..2b160075cf --- /dev/null +++ b/apps/docs/document-api/reference/images/set-position.mdx @@ -0,0 +1,204 @@ +--- +title: images.setPosition +sidebarTitle: images.setPosition +description: Set the anchor position for a floating image. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the anchor position for a floating image. + +- Operation ID: `images.setPosition` +- API member path: `editor.doc.images.setPosition(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `position` | object | yes | | +| `position.alignH` | string | no | | +| `position.alignV` | string | no | | +| `position.hRelativeFrom` | string | no | | +| `position.marginOffset` | object | no | | +| `position.marginOffset.horizontal` | number | no | | +| `position.marginOffset.top` | number | no | | +| `position.vRelativeFrom` | string | no | | + +### Example request + +```json +{ + "imageId": "example", + "position": { + "hRelativeFrom": "example", + "vRelativeFrom": "example" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "position": { + "additionalProperties": false, + "properties": { + "alignH": { + "type": "string" + }, + "alignV": { + "type": "string" + }, + "hRelativeFrom": { + "type": "string" + }, + "marginOffset": { + "additionalProperties": false, + "properties": { + "horizontal": { + "type": "number" + }, + "top": { + "type": "number" + } + }, + "type": "object" + }, + "vRelativeFrom": { + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "imageId", + "position" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/set-size.mdx b/apps/docs/document-api/reference/images/set-size.mdx new file mode 100644 index 0000000000..babfa810f4 --- /dev/null +++ b/apps/docs/document-api/reference/images/set-size.mdx @@ -0,0 +1,198 @@ +--- +title: images.setSize +sidebarTitle: images.setSize +description: Set explicit width/height for an image. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set explicit width/height for an image. + +- Operation ID: `images.setSize` +- API member path: `editor.doc.images.setSize(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult; reports NO_OP if the size already matches. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `size` | object | yes | | +| `size.height` | number | yes | | +| `size.unit` | enum | no | `"px"`, `"pt"`, `"twip"` | +| `size.width` | number | yes | | + +### Example request + +```json +{ + "imageId": "example", + "size": { + "height": 12.5, + "unit": "px", + "width": 12.5 + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "size": { + "additionalProperties": false, + "properties": { + "height": { + "exclusiveMinimum": 0, + "type": "number" + }, + "unit": { + "enum": [ + "px", + "pt", + "twip" + ], + "type": "string" + }, + "width": { + "exclusiveMinimum": 0, + "type": "number" + } + }, + "required": [ + "width", + "height" + ], + "type": "object" + } + }, + "required": [ + "imageId", + "size" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/set-wrap-distances.mdx b/apps/docs/document-api/reference/images/set-wrap-distances.mdx new file mode 100644 index 0000000000..8c6c48af9b --- /dev/null +++ b/apps/docs/document-api/reference/images/set-wrap-distances.mdx @@ -0,0 +1,189 @@ +--- +title: images.setWrapDistances +sidebarTitle: images.setWrapDistances +description: Set the text-wrap distance margins for a floating image. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the text-wrap distance margins for a floating image. + +- Operation ID: `images.setWrapDistances` +- API member path: `editor.doc.images.setWrapDistances(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult; reports NO_OP if already set. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `distances` | object | yes | | +| `distances.distBottom` | number | no | | +| `distances.distLeft` | number | no | | +| `distances.distRight` | number | no | | +| `distances.distTop` | number | no | | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "distances": { + "distBottom": 12.5, + "distTop": 12.5 + }, + "imageId": "example" +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "distances": { + "additionalProperties": false, + "properties": { + "distBottom": { + "type": "number" + }, + "distLeft": { + "type": "number" + }, + "distRight": { + "type": "number" + }, + "distTop": { + "type": "number" + } + }, + "type": "object" + }, + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId", + "distances" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/set-wrap-side.mdx b/apps/docs/document-api/reference/images/set-wrap-side.mdx new file mode 100644 index 0000000000..2e2ec081da --- /dev/null +++ b/apps/docs/document-api/reference/images/set-wrap-side.mdx @@ -0,0 +1,173 @@ +--- +title: images.setWrapSide +sidebarTitle: images.setWrapSide +description: Set which side(s) text wraps around a floating image. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set which side(s) text wraps around a floating image. + +- Operation ID: `images.setWrapSide` +- API member path: `editor.doc.images.setWrapSide(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult; reports NO_OP if already set. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `side` | enum | yes | `"bothSides"`, `"left"`, `"right"`, `"largest"` | + +### Example request + +```json +{ + "imageId": "example", + "side": "bothSides" +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "side": { + "enum": [ + "bothSides", + "left", + "right", + "largest" + ], + "type": "string" + } + }, + "required": [ + "imageId", + "side" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/set-wrap-type.mdx b/apps/docs/document-api/reference/images/set-wrap-type.mdx new file mode 100644 index 0000000000..ce54acbff4 --- /dev/null +++ b/apps/docs/document-api/reference/images/set-wrap-type.mdx @@ -0,0 +1,175 @@ +--- +title: images.setWrapType +sidebarTitle: images.setWrapType +description: Set the text wrapping type for a floating image. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the text wrapping type for a floating image. + +- Operation ID: `images.setWrapType` +- API member path: `editor.doc.images.setWrapType(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult; reports NO_OP if already set. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `type` | enum | yes | `"None"`, `"Square"`, `"Through"`, `"Tight"`, `"TopAndBottom"`, `"Inline"` | + +### Example request + +```json +{ + "imageId": "example", + "type": "None" +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "type": { + "enum": [ + "None", + "Square", + "Through", + "Tight", + "TopAndBottom", + "Inline" + ], + "type": "string" + } + }, + "required": [ + "imageId", + "type" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/images/set-z-order.mdx b/apps/docs/document-api/reference/images/set-z-order.mdx new file mode 100644 index 0000000000..48f70431d8 --- /dev/null +++ b/apps/docs/document-api/reference/images/set-z-order.mdx @@ -0,0 +1,181 @@ +--- +title: images.setZOrder +sidebarTitle: images.setZOrder +description: Set the z-order (relativeHeight) for a floating image. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Set the z-order (relativeHeight) for a floating image. + +- Operation ID: `images.setZOrder` +- API member path: `editor.doc.images.setZOrder(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `zOrder` | object | yes | | +| `zOrder.relativeHeight` | integer | yes | | + +### Example request + +```json +{ + "imageId": "example", + "zOrder": { + "relativeHeight": 1 + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | no | | +| `image` | object | no | | +| `success` | boolean | no | | + +### Example response + +```json +{ + "image": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "zOrder": { + "additionalProperties": false, + "properties": { + "relativeHeight": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "relativeHeight" + ], + "type": "object" + } + }, + "required": [ + "imageId", + "zOrder" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "type": "object" + }, + "image": { + "type": "object" + }, + "success": { + "type": "boolean" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "image": { + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "image" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index d30b11c1ed..2965a67cfd 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -23,7 +23,7 @@ Document API is currently alpha and subject to breaking changes. | Core | 10 | 0 | 10 | [Open](/document-api/reference/core/index) | | Blocks | 1 | 0 | 1 | [Open](/document-api/reference/blocks/index) | | Capabilities | 1 | 0 | 1 | [Open](/document-api/reference/capabilities/index) | -| Create | 5 | 0 | 5 | [Open](/document-api/reference/create/index) | +| Create | 6 | 0 | 6 | [Open](/document-api/reference/create/index) | | Sections | 18 | 0 | 18 | [Open](/document-api/reference/sections/index) | | Format | 44 | 1 | 45 | [Open](/document-api/reference/format/index) | | Styles | 1 | 0 | 1 | [Open](/document-api/reference/styles/index) | @@ -37,6 +37,7 @@ Document API is currently alpha and subject to breaking changes. | Tables | 42 | 0 | 42 | [Open](/document-api/reference/tables/index) | | History | 3 | 0 | 3 | [Open](/document-api/reference/history/index) | | Table of Contents | 10 | 0 | 10 | [Open](/document-api/reference/toc/index) | +| Images | 13 | 0 | 13 | [Open](/document-api/reference/images/index) | ## Available operations @@ -78,6 +79,7 @@ The tables below are grouped by namespace. | create.sectionBreak | editor.doc.create.sectionBreak(...) | Create a section break at the target location with optional initial section properties. | | create.table | editor.doc.create.table(...) | Create a new table at the target position. | | create.tableOfContents | editor.doc.create.tableOfContents(...) | Insert a new table of contents at the target position. | +| create.image | editor.doc.create.image(...) | Insert a new image at the target position. | #### Sections @@ -309,3 +311,21 @@ The tables below are grouped by namespace. | toc.listEntries | editor.doc.toc.listEntries(...) | List all TC (table of contents entry) fields in the document body. | | toc.getEntry | editor.doc.toc.getEntry(...) | Retrieve details of a specific TC (table of contents entry) field. | | toc.editEntry | editor.doc.toc.editEntry(...) | Update the properties of a TC (table of contents entry) field. | + +#### Images + +| Operation | API member path | Description | +| --- | --- | --- | +| images.list | editor.doc.images.list(...) | List all images in the document. | +| images.get | editor.doc.images.get(...) | Get details for a specific image by its stable ID. | +| images.delete | editor.doc.images.delete(...) | Delete an image from the document. | +| images.move | editor.doc.images.move(...) | Move an image to a new location in the document. | +| images.convertToInline | editor.doc.images.convertToInline(...) | Convert a floating image to inline placement. | +| images.convertToFloating | editor.doc.images.convertToFloating(...) | Convert an inline image to floating placement. | +| images.setSize | editor.doc.images.setSize(...) | Set explicit width/height for an image. | +| images.setWrapType | editor.doc.images.setWrapType(...) | Set the text wrapping type for a floating image. | +| images.setWrapSide | editor.doc.images.setWrapSide(...) | Set which side(s) text wraps around a floating image. | +| images.setWrapDistances | editor.doc.images.setWrapDistances(...) | Set the text-wrap distance margins for a floating image. | +| images.setPosition | editor.doc.images.setPosition(...) | Set the anchor position for a floating image. | +| images.setAnchorOptions | editor.doc.images.setAnchorOptions(...) | Set anchor behavior options for a floating image. | +| images.setZOrder | editor.doc.images.setZOrder(...) | Set the z-order (relativeHeight) for a floating image. | diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 55c94d4d43..6737703e8b 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -474,6 +474,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.create.sectionBreak` | `create section-break` | Create a section break at the target location with optional initial section properties. | | `doc.create.table` | `create table` | Create a new table at the target position. | | `doc.create.tableOfContents` | `create table-of-contents` | Insert a new table of contents at the target position. | +| `doc.create.image` | `create image` | Insert a new image at the target position. | #### Sections @@ -637,6 +638,24 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.describe` | `describe` | List all available CLI operations and contract metadata. | | `doc.describeCommand` | `describe command` | Show detailed metadata for a single CLI operation. | +#### Images + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.images.list` | `images list` | List all images in the document. | +| `doc.images.get` | `images get` | Get details for a specific image by its stable ID. | +| `doc.images.delete` | `images delete` | Delete an image from the document. | +| `doc.images.move` | `images move` | Move an image to a new location in the document. | +| `doc.images.convertToInline` | `images convert-to-inline` | Convert a floating image to inline placement. | +| `doc.images.convertToFloating` | `images convert-to-floating` | Convert an inline image to floating placement. | +| `doc.images.setSize` | `images set-size` | Set explicit width/height for an image. | +| `doc.images.setWrapType` | `images set-wrap-type` | Set the text wrapping type for a floating image. | +| `doc.images.setWrapSide` | `images set-wrap-side` | Set which side(s) text wraps around a floating image. | +| `doc.images.setWrapDistances` | `images set-wrap-distances` | Set the text-wrap distance margins for a floating image. | +| `doc.images.setPosition` | `images set-position` | Set the anchor position for a floating image. | +| `doc.images.setAnchorOptions` | `images set-anchor-options` | Set anchor behavior options for a floating image. | +| `doc.images.setZOrder` | `images set-z-order` | Set the z-order (relativeHeight) for a floating image. | + @@ -764,6 +783,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.create.section_break` | `create section-break` | Create a section break at the target location with optional initial section properties. | | `doc.create.table` | `create table` | Create a new table at the target position. | | `doc.create.table_of_contents` | `create table-of-contents` | Insert a new table of contents at the target position. | +| `doc.create.image` | `create image` | Insert a new image at the target position. | #### Sections @@ -927,6 +947,24 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.describe` | `describe` | List all available CLI operations and contract metadata. | | `doc.describe_command` | `describe command` | Show detailed metadata for a single CLI operation. | +#### Images + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.images.list` | `images list` | List all images in the document. | +| `doc.images.get` | `images get` | Get details for a specific image by its stable ID. | +| `doc.images.delete` | `images delete` | Delete an image from the document. | +| `doc.images.move` | `images move` | Move an image to a new location in the document. | +| `doc.images.convert_to_inline` | `images convert-to-inline` | Convert a floating image to inline placement. | +| `doc.images.convert_to_floating` | `images convert-to-floating` | Convert an inline image to floating placement. | +| `doc.images.set_size` | `images set-size` | Set explicit width/height for an image. | +| `doc.images.set_wrap_type` | `images set-wrap-type` | Set the text wrapping type for a floating image. | +| `doc.images.set_wrap_side` | `images set-wrap-side` | Set which side(s) text wraps around a floating image. | +| `doc.images.set_wrap_distances` | `images set-wrap-distances` | Set the text-wrap distance margins for a floating image. | +| `doc.images.set_position` | `images set-position` | Set the anchor position for a floating image. | +| `doc.images.set_anchor_options` | `images set-anchor-options` | Set anchor behavior options for a floating image. | +| `doc.images.set_z_order` | `images set-z-order` | Set the z-order (relativeHeight) for a floating image. | + {/* SDK_OPERATIONS_END */} diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 29cf571098..7c211e7492 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -6,6 +6,7 @@ import { OPERATION_REFERENCE_DOC_PATH_MAP, REFERENCE_OPERATION_GROUPS } from './ import { buildInternalContractSchemas } from './schemas.js'; import { PUBLIC_MUTATION_STEP_OP_IDS, STEP_OP_CATALOG } from './step-op-catalog.js'; import { OPERATION_IDS, PRE_APPLY_THROW_CODES, isValidOperationIdFormat } from './types.js'; +import { Z_ORDER_RELATIVE_HEIGHT_MAX, Z_ORDER_RELATIVE_HEIGHT_MIN } from '../images/z-order.js'; describe('document-api contract catalog', () => { it('keeps operation ids explicit and format-valid', () => { @@ -125,6 +126,28 @@ describe('document-api contract catalog', () => { expect(capabilitiesOutput.properties?.global?.required).toContain('history'); }); + it('declares images.setZOrder.relativeHeight as unsigned 32-bit integer', () => { + const schemas = buildInternalContractSchemas(); + const inputSchema = schemas.operations['images.setZOrder'].input as { + properties?: { + zOrder?: { + properties?: { + relativeHeight?: { + type?: string; + minimum?: number; + maximum?: number; + }; + }; + }; + }; + }; + + const relativeHeightSchema = inputSchema.properties?.zOrder?.properties?.relativeHeight; + expect(relativeHeightSchema?.type).toBe('integer'); + expect(relativeHeightSchema?.minimum).toBe(Z_ORDER_RELATIVE_HEIGHT_MIN); + expect(relativeHeightSchema?.maximum).toBe(Z_ORDER_RELATIVE_HEIGHT_MAX); + }); + it('derives OPERATION_IDS from OPERATION_DEFINITIONS keys', () => { const definitionKeys = Object.keys(OPERATION_DEFINITIONS).sort(); const operationIds = [...OPERATION_IDS].sort(); @@ -150,6 +173,7 @@ describe('document-api contract catalog', () => { 'tables', 'history', 'toc', + 'images', ]; for (const id of OPERATION_IDS) { expect(validGroups, `${id} has invalid referenceGroup`).toContain(OPERATION_DEFINITIONS[id].referenceGroup); diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index f786cf38fa..ee97c1764b 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -49,7 +49,8 @@ export type ReferenceGroupKey = | 'mutations' | 'tables' | 'history' - | 'toc'; + | 'toc' + | 'images'; // --------------------------------------------------------------------------- // Entry shape @@ -150,6 +151,9 @@ const T_PLAN_ENGINE = [ const T_NOT_FOUND_COMMAND = ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'] as const; const T_NOT_FOUND_COMMAND_TRACKED = [...T_NOT_FOUND_COMMAND] as const; +// Image operations can throw AMBIGUOUS_TARGET when multiple images share an sdImageId. +const T_IMAGE_COMMAND = ['TARGET_NOT_FOUND', 'AMBIGUOUS_TARGET', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'] as const; + const T_QUERY_MATCH = ['MATCH_NOT_FOUND', 'AMBIGUOUS_MATCH', 'INVALID_INPUT', 'INTERNAL_ERROR'] as const; const T_SECTION_CREATE = [ 'TARGET_NOT_FOUND', @@ -2340,6 +2344,230 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'history/redo.mdx', referenceGroup: 'history', }, + + // ------------------------------------------------------------------------- + // Create: image + // ------------------------------------------------------------------------- + + 'create.image': { + memberPath: 'create.image', + description: 'Insert a new image at the target position.', + expectedResult: 'Returns a CreateImageResult with the new image address.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'INVALID_INPUT'], + throws: [...T_NOT_FOUND_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'create/image.mdx', + referenceGroup: 'create', + }, + + // ------------------------------------------------------------------------- + // Images: lifecycle + placement + // ------------------------------------------------------------------------- + + 'images.list': { + memberPath: 'images.list', + description: 'List all images in the document.', + expectedResult: 'Returns an ImagesListResult with total count and image summaries.', + requiresDocumentContext: true, + metadata: readOperation({ idempotency: 'idempotent', deterministicTargetResolution: true }), + referenceDocPath: 'images/list.mdx', + referenceGroup: 'images', + }, + + 'images.get': { + memberPath: 'images.get', + description: 'Get details for a specific image by its stable ID.', + expectedResult: 'Returns an ImageSummary with full image properties.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: ['TARGET_NOT_FOUND', 'AMBIGUOUS_TARGET'], + deterministicTargetResolution: true, + }), + referenceDocPath: 'images/get.mdx', + referenceGroup: 'images', + }, + + 'images.delete': { + memberPath: 'images.delete', + description: 'Delete an image from the document.', + expectedResult: 'Returns an ImagesMutationResult indicating success or failure.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/delete.mdx', + referenceGroup: 'images', + }, + + 'images.move': { + memberPath: 'images.move', + description: 'Move an image to a new location in the document.', + expectedResult: 'Returns an ImagesMutationResult indicating success or failure.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/move.mdx', + referenceGroup: 'images', + }, + + 'images.convertToInline': { + memberPath: 'images.convertToInline', + description: 'Convert a floating image to inline placement.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if already inline.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/convert-to-inline.mdx', + referenceGroup: 'images', + }, + + 'images.convertToFloating': { + memberPath: 'images.convertToFloating', + description: 'Convert an inline image to floating placement.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if already floating.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/convert-to-floating.mdx', + referenceGroup: 'images', + }, + + 'images.setSize': { + memberPath: 'images.setSize', + description: 'Set explicit width/height for an image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if the size already matches.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/set-size.mdx', + referenceGroup: 'images', + }, + + 'images.setWrapType': { + memberPath: 'images.setWrapType', + description: 'Set the text wrapping type for a floating image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if already set.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/set-wrap-type.mdx', + referenceGroup: 'images', + }, + + 'images.setWrapSide': { + memberPath: 'images.setWrapSide', + description: 'Set which side(s) text wraps around a floating image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if already set.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/set-wrap-side.mdx', + referenceGroup: 'images', + }, + + 'images.setWrapDistances': { + memberPath: 'images.setWrapDistances', + description: 'Set the text-wrap distance margins for a floating image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if already set.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/set-wrap-distances.mdx', + referenceGroup: 'images', + }, + + 'images.setPosition': { + memberPath: 'images.setPosition', + description: 'Set the anchor position for a floating image.', + expectedResult: 'Returns an ImagesMutationResult.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/set-position.mdx', + referenceGroup: 'images', + }, + + 'images.setAnchorOptions': { + memberPath: 'images.setAnchorOptions', + description: 'Set anchor behavior options for a floating image.', + expectedResult: 'Returns an ImagesMutationResult.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/set-anchor-options.mdx', + referenceGroup: 'images', + }, + + 'images.setZOrder': { + memberPath: 'images.setZOrder', + description: 'Set the z-order (relativeHeight) for a floating image.', + expectedResult: 'Returns an ImagesMutationResult.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/set-z-order.mdx', + referenceGroup: 'images', + }, } as const satisfies Record; // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index b75497dfba..5d0ebb1694 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -121,6 +121,26 @@ import type { SectionsSetVerticalAlignInput, } from '../sections/sections.types.js'; import type { QueryMatchInput, QueryMatchOutput } from '../types/query-match.types.js'; +import type { + CreateImageInput, + CreateImageResult, + ImagesListInput, + ImagesListResult, + ImagesGetInput, + ImageSummary, + ImagesDeleteInput, + ImagesMutationResult, + MoveImageInput, + ConvertToInlineInput, + ConvertToFloatingInput, + SetSizeInput, + SetWrapTypeInput, + SetWrapSideInput, + SetWrapDistancesInput, + SetPositionInput, + SetAnchorOptionsInput, + SetZOrderInput, +} from '../images/images.types.js'; import type { MutationsApplyInput, MutationsPreviewInput, @@ -572,6 +592,24 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { 'toc.listEntries': { input: TocListEntriesQuery | undefined; options: never; output: TocListEntriesResult }; 'toc.getEntry': { input: TocGetEntryInput; options: never; output: TocEntryInfo }; 'toc.editEntry': { input: TocEditEntryInput; options: MutationOptions; output: TocEntryMutationResult }; + + // --- create.image --- + 'create.image': { input: CreateImageInput; options: MutationOptions; output: CreateImageResult }; + + // --- images.* --- + 'images.list': { input: ImagesListInput | undefined; options: never; output: ImagesListResult }; + 'images.get': { input: ImagesGetInput; options: never; output: ImageSummary }; + 'images.delete': { input: ImagesDeleteInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.move': { input: MoveImageInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.convertToInline': { input: ConvertToInlineInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.convertToFloating': { input: ConvertToFloatingInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setSize': { input: SetSizeInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setWrapType': { input: SetWrapTypeInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setWrapSide': { input: SetWrapSideInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setWrapDistances': { input: SetWrapDistancesInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setPosition': { input: SetPositionInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setAnchorOptions': { input: SetAnchorOptionsInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setZOrder': { input: SetZOrderInput; options: MutationOptions; output: ImagesMutationResult }; } // --- Bidirectional completeness checks --- diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index 0c0286e80b..80cba678ca 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -106,6 +106,11 @@ const GROUP_METADATA: Record; @@ -3592,6 +3593,279 @@ const operationSchemas: Record = { success: tocEntryMutationSuccessSchema, failure: tocEntryMutationFailureSchema, }, + + // --- images --- + + // Shared image location schema — discriminated union on `kind`. + // Used by create.image (at) and images.move (to). + + 'create.image': { + input: objectSchema( + { + src: { type: 'string' }, + alt: { type: 'string' }, + title: { type: 'string' }, + size: objectSchema({ width: { type: 'number' }, height: { type: 'number' } }), + at: { + oneOf: [ + objectSchema({ kind: { const: 'documentStart' } }, ['kind']), + objectSchema({ kind: { const: 'documentEnd' } }, ['kind']), + objectSchema({ kind: { const: 'before' }, target: blockNodeAddressSchema }, ['kind', 'target']), + objectSchema({ kind: { const: 'after' }, target: blockNodeAddressSchema }, ['kind', 'target']), + objectSchema( + { kind: { const: 'inParagraph' }, target: blockNodeAddressSchema, offset: { type: 'integer' } }, + ['kind', 'target'], + ), + ], + }, + }, + ['src'], + ), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { enum: ['INVALID_TARGET', 'INVALID_INPUT'] }, message: { type: 'string' } }, [ + 'code', + 'message', + ]), + }, + ['success', 'failure'], + ), + }, + 'images.list': { + input: objectSchema({ offset: { type: 'integer' }, limit: { type: 'integer' } }), + output: objectSchema({ total: { type: 'integer' }, items: arraySchema({ type: 'object' }) }, ['total', 'items']), + }, + 'images.get': { + input: objectSchema({ imageId: { type: 'string' } }, ['imageId']), + output: { type: 'object' as const }, + }, + 'images.delete': { + input: objectSchema({ imageId: { type: 'string' } }, ['imageId']), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.move': { + input: objectSchema( + { + imageId: { type: 'string' }, + to: { + oneOf: [ + objectSchema({ kind: { const: 'documentStart' } }, ['kind']), + objectSchema({ kind: { const: 'documentEnd' } }, ['kind']), + objectSchema({ kind: { const: 'before' }, target: blockNodeAddressSchema }, ['kind', 'target']), + objectSchema({ kind: { const: 'after' }, target: blockNodeAddressSchema }, ['kind', 'target']), + objectSchema( + { kind: { const: 'inParagraph' }, target: blockNodeAddressSchema, offset: { type: 'integer' } }, + ['kind', 'target'], + ), + ], + }, + }, + ['imageId', 'to'], + ), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.convertToInline': { + input: objectSchema({ imageId: { type: 'string' } }, ['imageId']), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.convertToFloating': { + input: objectSchema({ imageId: { type: 'string' } }, ['imageId']), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.setSize': { + input: objectSchema( + { + imageId: { type: 'string' }, + size: objectSchema( + { + width: { type: 'number', exclusiveMinimum: 0 }, + height: { type: 'number', exclusiveMinimum: 0 }, + unit: { type: 'string', enum: ['px', 'pt', 'twip'] }, + }, + ['width', 'height'], + ), + }, + ['imageId', 'size'], + ), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.setWrapType': { + input: objectSchema( + { + imageId: { type: 'string' }, + type: { type: 'string', enum: ['None', 'Square', 'Through', 'Tight', 'TopAndBottom', 'Inline'] }, + }, + ['imageId', 'type'], + ), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.setWrapSide': { + input: objectSchema( + { imageId: { type: 'string' }, side: { type: 'string', enum: ['bothSides', 'left', 'right', 'largest'] } }, + ['imageId', 'side'], + ), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.setWrapDistances': { + input: objectSchema( + { + imageId: { type: 'string' }, + distances: objectSchema({ + distTop: { type: 'number' }, + distBottom: { type: 'number' }, + distLeft: { type: 'number' }, + distRight: { type: 'number' }, + }), + }, + ['imageId', 'distances'], + ), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.setPosition': { + input: objectSchema( + { + imageId: { type: 'string' }, + position: objectSchema({ + hRelativeFrom: { type: 'string' }, + vRelativeFrom: { type: 'string' }, + alignH: { type: 'string' }, + alignV: { type: 'string' }, + marginOffset: objectSchema({ + horizontal: { type: 'number' }, + top: { type: 'number' }, + }), + }), + }, + ['imageId', 'position'], + ), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.setAnchorOptions': { + input: objectSchema( + { + imageId: { type: 'string' }, + options: objectSchema({ + behindDoc: { type: 'boolean' }, + allowOverlap: { type: 'boolean' }, + layoutInCell: { type: 'boolean' }, + lockAnchor: { type: 'boolean' }, + simplePos: { type: 'boolean' }, + }), + }, + ['imageId', 'options'], + ), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, + 'images.setZOrder': { + input: objectSchema( + { + imageId: { type: 'string' }, + zOrder: objectSchema( + { + relativeHeight: { + type: 'integer', + minimum: Z_ORDER_RELATIVE_HEIGHT_MIN, + maximum: Z_ORDER_RELATIVE_HEIGHT_MAX, + }, + }, + ['relativeHeight'], + ), + }, + ['imageId', 'zOrder'], + ), + output: objectSchema({ success: { type: 'boolean' }, image: { type: 'object' }, failure: { type: 'object' } }), + success: objectSchema({ success: { const: true }, image: { type: 'object' } }, ['success', 'image']), + failure: objectSchema( + { + success: { const: false }, + failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + }, + ['success', 'failure'], + ), + }, }; /** diff --git a/packages/document-api/src/create/create.ts b/packages/document-api/src/create/create.ts index d5823437e8..8b938ced83 100644 --- a/packages/document-api/src/create/create.ts +++ b/packages/document-api/src/create/create.ts @@ -16,6 +16,7 @@ import type { SectionBreakType, } from '../sections/sections.types.js'; import type { CreateTableOfContentsInput, CreateTableOfContentsResult, TocCreateLocation } from '../toc/toc.types.js'; +import type { CreateImageInput, CreateImageResult } from '../images/images.types.js'; import { DocumentApiValidationError } from '../errors.js'; export interface CreateApi { @@ -24,6 +25,7 @@ export interface CreateApi { table(input: CreateTableInput, options?: MutationOptions): CreateTableResult; sectionBreak(input: CreateSectionBreakInput, options?: MutationOptions): CreateSectionBreakResult; tableOfContents(input: CreateTableOfContentsInput, options?: MutationOptions): CreateTableOfContentsResult; + image(input: CreateImageInput, options?: MutationOptions): CreateImageResult; } export type CreateAdapter = CreateApi; diff --git a/packages/document-api/src/images/images.test.ts b/packages/document-api/src/images/images.test.ts new file mode 100644 index 0000000000..6867cf61b5 --- /dev/null +++ b/packages/document-api/src/images/images.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DocumentApiValidationError } from '../errors.js'; +import { executeImagesSetZOrder, type ImagesAdapter } from './images.js'; +import { Z_ORDER_RELATIVE_HEIGHT_MAX, Z_ORDER_RELATIVE_HEIGHT_MIN } from './z-order.js'; + +function makeSetZOrderAdapter() { + const setZOrder = vi.fn(() => ({ + success: true as const, + image: { + kind: 'inline' as const, + nodeType: 'image' as const, + nodeId: 'img-1', + placement: 'floating' as const, + }, + })); + + const adapter = { setZOrder } as unknown as ImagesAdapter; + return { adapter, setZOrder }; +} + +describe('executeImagesSetZOrder', () => { + it('accepts minimum valid relativeHeight (0)', () => { + const { adapter, setZOrder } = makeSetZOrderAdapter(); + + executeImagesSetZOrder(adapter, { + imageId: 'img-1', + zOrder: { relativeHeight: Z_ORDER_RELATIVE_HEIGHT_MIN }, + }); + + expect(setZOrder).toHaveBeenCalledWith( + { + imageId: 'img-1', + zOrder: { relativeHeight: Z_ORDER_RELATIVE_HEIGHT_MIN }, + }, + undefined, + ); + }); + + it('accepts maximum valid relativeHeight (4294967295)', () => { + const { adapter, setZOrder } = makeSetZOrderAdapter(); + + executeImagesSetZOrder(adapter, { + imageId: 'img-1', + zOrder: { relativeHeight: Z_ORDER_RELATIVE_HEIGHT_MAX }, + }); + + expect(setZOrder).toHaveBeenCalledWith( + { + imageId: 'img-1', + zOrder: { relativeHeight: Z_ORDER_RELATIVE_HEIGHT_MAX }, + }, + undefined, + ); + }); + + it.each([ + { label: 'fractional number', value: 1.5 }, + { label: 'negative integer', value: -1 }, + { label: 'overflow integer', value: Z_ORDER_RELATIVE_HEIGHT_MAX + 1 }, + { label: 'NaN', value: Number.NaN }, + { label: 'Infinity', value: Number.POSITIVE_INFINITY }, + ])('rejects invalid relativeHeight: $label', ({ value }) => { + const { adapter, setZOrder } = makeSetZOrderAdapter(); + + let thrown: unknown; + try { + executeImagesSetZOrder(adapter, { + imageId: 'img-1', + zOrder: { relativeHeight: value }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(DocumentApiValidationError); + expect((thrown as Error).message).toContain('unsigned 32-bit integer'); + expect(setZOrder).not.toHaveBeenCalled(); + }); + + it('rejects missing zOrder object', () => { + const { adapter, setZOrder } = makeSetZOrderAdapter(); + + expect(() => + executeImagesSetZOrder(adapter, { + imageId: 'img-1', + zOrder: undefined as unknown as { relativeHeight: number }, + }), + ).toThrow('requires a "zOrder" object'); + + expect(setZOrder).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/document-api/src/images/images.ts b/packages/document-api/src/images/images.ts new file mode 100644 index 0000000000..bb8460a01a --- /dev/null +++ b/packages/document-api/src/images/images.ts @@ -0,0 +1,272 @@ +import type { MutationOptions } from '../types/index.js'; +import { DocumentApiValidationError } from '../errors.js'; +import type { + CreateImageInput, + CreateImageResult, + ImagesListInput, + ImagesListResult, + ImagesGetInput, + ImageSummary, + ImagesDeleteInput, + ImagesMutationResult, + MoveImageInput, + ConvertToInlineInput, + ConvertToFloatingInput, + SetSizeInput, + SetWrapTypeInput, + SetWrapSideInput, + SetWrapDistancesInput, + SetPositionInput, + SetAnchorOptionsInput, + SetZOrderInput, +} from './images.types.js'; +import { isUnsignedInt32, Z_ORDER_RELATIVE_HEIGHT_MAX, Z_ORDER_RELATIVE_HEIGHT_MIN } from './z-order.js'; + +// --------------------------------------------------------------------------- +// Valid value sets +// --------------------------------------------------------------------------- + +const VALID_WRAP_TYPES = new Set(['Inline', 'None', 'Square', 'Tight', 'Through', 'TopAndBottom']); +const VALID_WRAP_SIDES = new Set(['bothSides', 'left', 'right', 'largest']); +const VALID_IMAGE_SIZE_UNITS = new Set(['px', 'pt', 'twip']); + +// --------------------------------------------------------------------------- +// Adapter interface +// --------------------------------------------------------------------------- + +export interface ImagesAdapter { + list(input: ImagesListInput): ImagesListResult; + get(input: ImagesGetInput): ImageSummary; + delete(input: ImagesDeleteInput, options?: MutationOptions): ImagesMutationResult; + move(input: MoveImageInput, options?: MutationOptions): ImagesMutationResult; + convertToInline(input: ConvertToInlineInput, options?: MutationOptions): ImagesMutationResult; + convertToFloating(input: ConvertToFloatingInput, options?: MutationOptions): ImagesMutationResult; + setSize(input: SetSizeInput, options?: MutationOptions): ImagesMutationResult; + setWrapType(input: SetWrapTypeInput, options?: MutationOptions): ImagesMutationResult; + setWrapSide(input: SetWrapSideInput, options?: MutationOptions): ImagesMutationResult; + setWrapDistances(input: SetWrapDistancesInput, options?: MutationOptions): ImagesMutationResult; + setPosition(input: SetPositionInput, options?: MutationOptions): ImagesMutationResult; + setAnchorOptions(input: SetAnchorOptionsInput, options?: MutationOptions): ImagesMutationResult; + setZOrder(input: SetZOrderInput, options?: MutationOptions): ImagesMutationResult; +} + +export type ImagesApi = ImagesAdapter; + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +function requireString(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', `${field} must be a non-empty string.`, { field }); + } +} + +function requireImageId(input: { imageId?: unknown }): void { + requireString(input?.imageId, 'imageId'); +} + +function requireFinitePositiveNumber(value: unknown, field: string): asserts value is number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new DocumentApiValidationError('INVALID_INPUT', `${field} must be a finite positive number.`, { + field, + value, + }); + } +} + +function requireUnsignedInt32(value: unknown, field: string): asserts value is number { + if (!isUnsignedInt32(value)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${field} must be an unsigned 32-bit integer (${Z_ORDER_RELATIVE_HEIGHT_MIN}..${Z_ORDER_RELATIVE_HEIGHT_MAX}).`, + { + field, + value, + minimum: Z_ORDER_RELATIVE_HEIGHT_MIN, + maximum: Z_ORDER_RELATIVE_HEIGHT_MAX, + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Execute functions +// --------------------------------------------------------------------------- + +export function executeImagesList(adapter: ImagesAdapter, input: ImagesListInput): ImagesListResult { + return adapter.list(input ?? {}); +} + +export function executeImagesGet(adapter: ImagesAdapter, input: ImagesGetInput): ImageSummary { + requireImageId(input); + return adapter.get(input); +} + +export function executeImagesDelete( + adapter: ImagesAdapter, + input: ImagesDeleteInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + return adapter.delete(input, options); +} + +export function executeImagesMove( + adapter: ImagesAdapter, + input: MoveImageInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (!input.to) { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.move requires a "to" location.', { field: 'to' }); + } + return adapter.move(input, options); +} + +export function executeImagesConvertToInline( + adapter: ImagesAdapter, + input: ConvertToInlineInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + return adapter.convertToInline(input, options); +} + +export function executeImagesConvertToFloating( + adapter: ImagesAdapter, + input: ConvertToFloatingInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + return adapter.convertToFloating(input, options); +} + +export function executeImagesSetSize( + adapter: ImagesAdapter, + input: SetSizeInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (!input.size || typeof input.size !== 'object') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setSize requires a "size" object.', { + field: 'size', + }); + } + + requireFinitePositiveNumber(input.size.width, 'size.width'); + requireFinitePositiveNumber(input.size.height, 'size.height'); + + if (input.size.unit !== undefined && !VALID_IMAGE_SIZE_UNITS.has(input.size.unit)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'size.unit must be one of: px, pt, twip.', { + field: 'size.unit', + allowed: [...VALID_IMAGE_SIZE_UNITS], + }); + } + + return adapter.setSize(input, options); +} + +export function executeImagesSetWrapType( + adapter: ImagesAdapter, + input: SetWrapTypeInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (!VALID_WRAP_TYPES.has(input.type)) { + throw new DocumentApiValidationError('INVALID_INPUT', `Invalid wrap type: "${input.type}".`, { + field: 'type', + allowed: [...VALID_WRAP_TYPES], + }); + } + return adapter.setWrapType(input, options); +} + +export function executeImagesSetWrapSide( + adapter: ImagesAdapter, + input: SetWrapSideInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (!VALID_WRAP_SIDES.has(input.side)) { + throw new DocumentApiValidationError('INVALID_INPUT', `Invalid wrap side: "${input.side}".`, { + field: 'side', + allowed: [...VALID_WRAP_SIDES], + }); + } + return adapter.setWrapSide(input, options); +} + +export function executeImagesSetWrapDistances( + adapter: ImagesAdapter, + input: SetWrapDistancesInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (!input.distances || typeof input.distances !== 'object') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setWrapDistances requires a "distances" object.', { + field: 'distances', + }); + } + return adapter.setWrapDistances(input, options); +} + +export function executeImagesSetPosition( + adapter: ImagesAdapter, + input: SetPositionInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (!input.position || typeof input.position !== 'object') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setPosition requires a "position" object.', { + field: 'position', + }); + } + return adapter.setPosition(input, options); +} + +export function executeImagesSetAnchorOptions( + adapter: ImagesAdapter, + input: SetAnchorOptionsInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (!input.options || typeof input.options !== 'object') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setAnchorOptions requires an "options" object.', { + field: 'options', + }); + } + return adapter.setAnchorOptions(input, options); +} + +export function executeImagesSetZOrder( + adapter: ImagesAdapter, + input: SetZOrderInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (!input.zOrder || typeof input.zOrder !== 'object') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setZOrder requires a "zOrder" object.', { + field: 'zOrder', + }); + } + requireUnsignedInt32(input.zOrder.relativeHeight, 'zOrder.relativeHeight'); + return adapter.setZOrder(input, options); +} + +// --------------------------------------------------------------------------- +// Create image execute (lives here alongside images domain) +// --------------------------------------------------------------------------- + +export interface CreateImageAdapter { + image(input: CreateImageInput, options?: MutationOptions): CreateImageResult; +} + +export function executeCreateImage( + adapter: CreateImageAdapter, + input: CreateImageInput, + options?: MutationOptions, +): CreateImageResult { + requireString(input?.src, 'src'); + return adapter.image(input, options); +} diff --git a/packages/document-api/src/images/images.types.ts b/packages/document-api/src/images/images.types.ts new file mode 100644 index 0000000000..ff61cf00d7 --- /dev/null +++ b/packages/document-api/src/images/images.types.ts @@ -0,0 +1,193 @@ +import type { BlockNodeAddress } from '../types/index.js'; +import type { + ImageProperties, + ImageWrapType, + ImageWrapSide, + ImageMarginOffset, + ImageSize, +} from '../types/media.types.js'; + +// --------------------------------------------------------------------------- +// Address +// --------------------------------------------------------------------------- + +/** Stable address for an image node in the document. */ +export interface ImageAddress { + /** Always 'inline' — ProseMirror node kind (all images are PM inline nodes). */ + kind: 'inline'; + nodeType: 'image'; + nodeId: string; + /** OOXML placement semantics: 'inline' = wp:inline, 'floating' = wp:anchor. */ + placement: 'inline' | 'floating'; +} + +// --------------------------------------------------------------------------- +// Location (for create / move) +// --------------------------------------------------------------------------- + +export type ImageCreateLocation = + | { kind: 'documentStart' } + | { kind: 'documentEnd' } + | { kind: 'before'; target: BlockNodeAddress } + | { kind: 'after'; target: BlockNodeAddress } + | { kind: 'inParagraph'; target: BlockNodeAddress; offset?: number }; + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +export interface ImageSummary { + sdImageId: string; + address: ImageAddress; + properties: ImageProperties; +} + +// --------------------------------------------------------------------------- +// Wrap distances +// --------------------------------------------------------------------------- + +export interface ImageWrapDistances { + distTop?: number; + distBottom?: number; + distLeft?: number; + distRight?: number; +} + +// --------------------------------------------------------------------------- +// Position input +// --------------------------------------------------------------------------- + +export interface ImagePositionInput { + hRelativeFrom?: string; + vRelativeFrom?: string; + alignH?: string; + alignV?: string; + marginOffset?: ImageMarginOffset; +} + +// --------------------------------------------------------------------------- +// Anchor options input +// --------------------------------------------------------------------------- + +export interface ImageAnchorOptionsInput { + behindDoc?: boolean; + allowOverlap?: boolean; + layoutInCell?: boolean; + lockAnchor?: boolean; + simplePos?: boolean; +} + +// --------------------------------------------------------------------------- +// Z-order input +// --------------------------------------------------------------------------- + +export interface ImageZOrderInput { + /** Raw OOXML relativeHeight unsigned 32-bit integer (0..4294967295). */ + relativeHeight: number; +} + +// --------------------------------------------------------------------------- +// Operation inputs +// --------------------------------------------------------------------------- + +export interface CreateImageInput { + src: string; + alt?: string; + title?: string; + size?: ImageSize; + at?: ImageCreateLocation; +} + +export interface ImagesListInput { + offset?: number; + limit?: number; +} + +export interface ImagesGetInput { + imageId: string; +} + +export interface ImagesDeleteInput { + imageId: string; +} + +export interface MoveImageInput { + imageId: string; + to: ImageCreateLocation; +} + +export interface ConvertToInlineInput { + imageId: string; +} + +export interface ConvertToFloatingInput { + imageId: string; +} + +export interface SetWrapTypeInput { + imageId: string; + type: ImageWrapType; +} + +export interface SetSizeInput { + imageId: string; + size: ImageSize; +} + +export interface SetWrapSideInput { + imageId: string; + side: ImageWrapSide; +} + +export interface SetWrapDistancesInput { + imageId: string; + distances: ImageWrapDistances; +} + +export interface SetPositionInput { + imageId: string; + position: ImagePositionInput; +} + +export interface SetAnchorOptionsInput { + imageId: string; + options: ImageAnchorOptionsInput; +} + +export interface SetZOrderInput { + imageId: string; + zOrder: ImageZOrderInput; +} + +// --------------------------------------------------------------------------- +// Operation outputs +// --------------------------------------------------------------------------- + +export interface CreateImageSuccessResult { + success: true; + image: ImageAddress; +} + +export interface CreateImageFailureResult { + success: false; + failure: { code: string; message: string }; +} + +export type CreateImageResult = CreateImageSuccessResult | CreateImageFailureResult; + +export interface ImagesListResult { + total: number; + items: ImageSummary[]; +} + +export interface ImagesMutationSuccessResult { + success: true; + image: ImageAddress; +} + +export interface ImagesMutationFailureResult { + success: false; + failure: { code: string; message: string }; +} + +export type ImagesMutationResult = ImagesMutationSuccessResult | ImagesMutationFailureResult; diff --git a/packages/document-api/src/images/z-order.ts b/packages/document-api/src/images/z-order.ts new file mode 100644 index 0000000000..72baeba444 --- /dev/null +++ b/packages/document-api/src/images/z-order.ts @@ -0,0 +1,18 @@ +/** + * OOXML unsignedInt bounds for wp:anchor@relativeHeight. + * ECMA-376 defines unsignedInt as 0..4294967295. + */ +export const Z_ORDER_RELATIVE_HEIGHT_MIN = 0; +export const Z_ORDER_RELATIVE_HEIGHT_MAX = 4_294_967_295; + +/** + * Returns true when the value is an unsigned 32-bit integer. + */ +export function isUnsignedInt32(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value >= Z_ORDER_RELATIVE_HEIGHT_MIN && + value <= Z_ORDER_RELATIVE_HEIGHT_MAX + ); +} diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index a1ab30e63d..133d306d6a 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -304,6 +304,43 @@ import { executeSectionsSetTitlePage, executeSectionsSetVerticalAlign, } from './sections/sections.js'; +import type { ImagesAdapter, ImagesApi, CreateImageAdapter } from './images/images.js'; +import { + executeImagesList, + executeImagesGet, + executeImagesDelete, + executeImagesMove, + executeImagesConvertToInline, + executeImagesConvertToFloating, + executeImagesSetSize, + executeImagesSetWrapType, + executeImagesSetWrapSide, + executeImagesSetWrapDistances, + executeImagesSetPosition, + executeImagesSetAnchorOptions, + executeImagesSetZOrder, + executeCreateImage, +} from './images/images.js'; +import type { + CreateImageInput, + CreateImageResult, + ImagesListInput, + ImagesListResult, + ImagesGetInput, + ImageSummary, + ImagesDeleteInput, + ImagesMutationResult, + MoveImageInput, + ConvertToInlineInput, + ConvertToFloatingInput, + SetSizeInput, + SetWrapTypeInput, + SetWrapSideInput, + SetWrapDistancesInput, + SetPositionInput, + SetAnchorOptionsInput, + SetZOrderInput, +} from './images/images.types.js'; import type { TocApi, TocAdapter } from './toc/toc.js'; import { executeTocList, @@ -425,6 +462,35 @@ export type { ReviewDecideInput, } from './track-changes/track-changes.js'; export type { BlocksAdapter } from './blocks/blocks.js'; +export type { ImagesAdapter, ImagesApi, CreateImageAdapter } from './images/images.js'; +export type { + ImageAddress, + ImageCreateLocation, + ImageSummary, + ImageWrapDistances, + ImagePositionInput, + ImageAnchorOptionsInput, + ImageZOrderInput, + CreateImageInput, + CreateImageResult, + ImagesListInput, + ImagesListResult, + ImagesGetInput, + ImagesDeleteInput, + ImagesMutationResult, + ImagesMutationSuccessResult, + ImagesMutationFailureResult, + MoveImageInput, + ConvertToInlineInput, + ConvertToFloatingInput, + SetSizeInput, + SetWrapTypeInput, + SetWrapSideInput, + SetWrapDistancesInput, + SetPositionInput, + SetAnchorOptionsInput, + SetZOrderInput, +} from './images/images.types.js'; export type { TocApi, TocAdapter } from './toc/toc.js'; export type { TocAddress, @@ -792,6 +858,10 @@ export interface DocumentApi { * Table of contents operations. */ toc: TocApi; + /** + * Image lifecycle and placement operations. + */ + images: ImagesApi; /** * Selector-based query with cardinality contracts for mutation targeting. */ @@ -846,6 +916,7 @@ export interface DocumentApiAdapters { paragraphs: ParagraphsAdapter; tables: TablesAdapter; toc: TocAdapter; + images: ImagesAdapter & CreateImageAdapter; query: QueryAdapter; mutations: MutationsAdapter; history: HistoryAdapter; @@ -1041,8 +1112,52 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { tableOfContents(input: CreateTableOfContentsInput, options?: MutationOptions): CreateTableOfContentsResult { return executeCreateTableOfContents(adapters.create, input, options); }, + image(input: CreateImageInput, options?: MutationOptions): CreateImageResult { + return executeCreateImage(adapters.images, input, options); + }, }, capabilities, + images: { + list(input?: ImagesListInput): ImagesListResult { + return executeImagesList(adapters.images, input ?? {}); + }, + get(input: ImagesGetInput): ImageSummary { + return executeImagesGet(adapters.images, input); + }, + delete(input: ImagesDeleteInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesDelete(adapters.images, input, options); + }, + move(input: MoveImageInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesMove(adapters.images, input, options); + }, + convertToInline(input: ConvertToInlineInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesConvertToInline(adapters.images, input, options); + }, + convertToFloating(input: ConvertToFloatingInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesConvertToFloating(adapters.images, input, options); + }, + setSize(input: SetSizeInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetSize(adapters.images, input, options); + }, + setWrapType(input: SetWrapTypeInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetWrapType(adapters.images, input, options); + }, + setWrapSide(input: SetWrapSideInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetWrapSide(adapters.images, input, options); + }, + setWrapDistances(input: SetWrapDistancesInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetWrapDistances(adapters.images, input, options); + }, + setPosition(input: SetPositionInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetPosition(adapters.images, input, options); + }, + setAnchorOptions(input: SetAnchorOptionsInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetAnchorOptions(adapters.images, input, options); + }, + setZOrder(input: SetZOrderInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetZOrder(adapters.images, input, options); + }, + }, lists: { list(query?: ListsListQuery): ListsListResult { return executeListsList(adapters.lists, query); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 0290c0c169..bdacc2e33f 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -241,5 +241,23 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'toc.listEntries': (input) => api.toc.listEntries(input), 'toc.getEntry': (input) => api.toc.getEntry(input), 'toc.editEntry': (input, options) => api.toc.editEntry(input, options), + + // --- create.image --- + 'create.image': (input, options) => api.create.image(input, options), + + // --- images.* --- + 'images.list': (input) => api.images.list(input ?? {}), + 'images.get': (input) => api.images.get(input), + 'images.delete': (input, options) => api.images.delete(input, options), + 'images.move': (input, options) => api.images.move(input, options), + 'images.convertToInline': (input, options) => api.images.convertToInline(input, options), + 'images.convertToFloating': (input, options) => api.images.convertToFloating(input, options), + 'images.setSize': (input, options) => api.images.setSize(input, options), + 'images.setWrapType': (input, options) => api.images.setWrapType(input, options), + 'images.setWrapSide': (input, options) => api.images.setWrapSide(input, options), + 'images.setWrapDistances': (input, options) => api.images.setWrapDistances(input, options), + 'images.setPosition': (input, options) => api.images.setPosition(input, options), + 'images.setAnchorOptions': (input, options) => api.images.setAnchorOptions(input, options), + 'images.setZOrder': (input, options) => api.images.setZOrder(input, options), }; } diff --git a/packages/document-api/src/types/media.types.ts b/packages/document-api/src/types/media.types.ts index 058d20c750..df6ac0fc01 100644 --- a/packages/document-api/src/types/media.types.ts +++ b/packages/document-api/src/types/media.types.ts @@ -12,9 +12,44 @@ export interface ImageSize { unit?: 'px' | 'pt' | 'twip'; } +/** Wrap type for OOXML image placement. */ +export type ImageWrapType = 'Inline' | 'None' | 'Square' | 'Tight' | 'Through' | 'TopAndBottom'; + +/** Wrap side — controls which side(s) text flows around the image. */ +export type ImageWrapSide = 'bothSides' | 'left' | 'right' | 'largest'; + +export interface ImageWrapAttrs { + wrapText?: string; + distTop?: number; + distBottom?: number; + distLeft?: number; + distRight?: number; +} + +export interface ImageWrapInfo { + type: ImageWrapType; + attrs?: ImageWrapAttrs; +} + +export interface ImageAnchorData { + hRelativeFrom?: string; + vRelativeFrom?: string; + alignH?: string; + alignV?: string; +} + +export interface ImageMarginOffset { + horizontal?: number; + top?: number; +} + export interface ImageProperties { src?: string; alt?: string; size?: ImageSize; - wrap?: string; + placement: 'inline' | 'floating'; + wrap: ImageWrapInfo; + anchorData?: ImageAnchorData | null; + marginOffset?: ImageMarginOffset | null; + relativeHeight?: number | null; } diff --git a/packages/super-editor/src/core/super-converter/image-dimensions.js b/packages/super-editor/src/core/super-converter/image-dimensions.js new file mode 100644 index 0000000000..0e1a4f5b01 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/image-dimensions.js @@ -0,0 +1,168 @@ +import { base64ToUint8Array } from './helpers.js'; + +/** + * Read intrinsic image dimensions from raw binary headers. + * Supports PNG, JPEG, GIF, BMP, and WEBP. + * + * @param {Uint8Array} bytes - Raw image bytes + * @returns {{ width: number, height: number } | null} Dimensions or null if unreadable + */ +export function readImageDimensions(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.length < 12) return null; + + // PNG: IHDR chunk at bytes 16-23 (big-endian int32 width, height) + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) { + if (bytes.length < 24) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getInt32(16); + const height = view.getInt32(20); + if (width > 0 && height > 0) return { width, height }; + return null; + } + + // JPEG: Scan for SOF0 (0xFFC0) or SOF2 (0xFFC2) marker + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return readJpegDimensions(bytes); + } + + // GIF: Logical screen descriptor at bytes 6-9 (little-endian uint16) + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { + if (bytes.length < 10) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getUint16(6, true); + const height = view.getUint16(8, true); + if (width > 0 && height > 0) return { width, height }; + return null; + } + + // BMP: DIB header at bytes 18-25 (little-endian int32) + if (bytes[0] === 0x42 && bytes[1] === 0x4d) { + if (bytes.length < 26) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getInt32(18, true); + const height = Math.abs(view.getInt32(22, true)); // height can be negative (top-down) + if (width > 0 && height > 0) return { width, height }; + return null; + } + + // WEBP: RIFF....WEBP + if ( + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return readWebpDimensions(bytes); + } + + return null; +} + +/** + * Scan JPEG markers for SOF0/SOF2 to read width/height. + * @param {Uint8Array} bytes + * @returns {{ width: number, height: number } | null} + */ +function readJpegDimensions(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let offset = 2; // skip SOI (0xFFD8) + + while (offset + 4 < bytes.length) { + if (bytes[offset] !== 0xff) return null; + const marker = bytes[offset + 1]; + + // SOF0 (0xC0) or SOF2 (0xC2) — baseline or progressive + if (marker === 0xc0 || marker === 0xc2) { + if (offset + 9 > bytes.length) return null; + const height = view.getUint16(offset + 5); + const width = view.getUint16(offset + 7); + if (width > 0 && height > 0) return { width, height }; + return null; + } + + // Skip non-SOF markers: read segment length and advance + if (marker === 0xd9) return null; // EOI — end of image + if (marker === 0xda) return null; // SOS — start of scan (no more metadata) + + const segmentLength = view.getUint16(offset + 2); + offset += 2 + segmentLength; + } + + return null; +} + +/** + * Read WEBP dimensions from VP8, VP8L, or VP8X sub-chunks. + * @param {Uint8Array} bytes + * @returns {{ width: number, height: number } | null} + */ +function readWebpDimensions(bytes) { + if (bytes.length < 16) return null; + + // Check sub-chunk type at byte 12 + const chunkTag = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]); + + if (chunkTag === 'VP8 ') { + // Lossy VP8: frame header starts at byte 20 (after 12-byte RIFF header + 8 chunk header) + // Bytes 26-27: width (LE uint16, lower 14 bits), 28-29: height (LE uint16, lower 14 bits) + if (bytes.length < 30) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getUint16(26, true) & 0x3fff; + const height = view.getUint16(28, true) & 0x3fff; + if (width > 0 && height > 0) return { width, height }; + return null; + } + + if (chunkTag === 'VP8L') { + // Lossless VP8L: signature byte at 21, then 4 bytes of packed width/height + if (bytes.length < 25) return null; + // Bytes 21-24 contain packed dimensions (after 0x2f signature byte at offset 21) + const b0 = bytes[21]; + const b1 = bytes[22]; + const b2 = bytes[23]; + const b3 = bytes[24]; + const width = (((b1 & 0x3f) << 8) | b0) + 1; + const height = (((b3 & 0x0f) << 10) | (b2 << 2) | (b1 >> 6)) + 1; + if (width > 0 && height > 0) return { width, height }; + return null; + } + + if (chunkTag === 'VP8X') { + // Extended VP8X: canvas size at bytes 24-29 + // width = 24-bit LE uint at byte 24 + 1, height = 24-bit LE uint at byte 27 + 1 + if (bytes.length < 30) return null; + const width = (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1; + const height = (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)) + 1; + if (width > 0 && height > 0) return { width, height }; + return null; + } + + return null; +} + +/** + * Extract dimensions from a data URI's base64 payload. + * + * @param {string} dataUri - A data URI (e.g. "data:image/png;base64,...") + * @returns {{ width: number, height: number } | null} Dimensions or null + */ +export function readImageDimensionsFromDataUri(dataUri) { + if (typeof dataUri !== 'string' || !dataUri.startsWith('data:')) return null; + + const commaIndex = dataUri.indexOf(','); + if (commaIndex === -1) return null; + + const base64Payload = dataUri.slice(commaIndex + 1); + if (!base64Payload) return null; + + try { + const bytes = base64ToUint8Array(base64Payload); + return readImageDimensions(bytes); + } catch { + return null; + } +} diff --git a/packages/super-editor/src/core/super-converter/image-dimensions.test.js b/packages/super-editor/src/core/super-converter/image-dimensions.test.js new file mode 100644 index 0000000000..2afbb1a044 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/image-dimensions.test.js @@ -0,0 +1,235 @@ +import { describe, it, expect } from 'vitest'; +import { readImageDimensions, readImageDimensionsFromDataUri } from './image-dimensions.js'; + +// --------------------------------------------------------------------------- +// Helpers to build minimal valid headers +// --------------------------------------------------------------------------- + +function pngHeader(width, height) { + // Minimal PNG: 8-byte signature + IHDR chunk (13 data bytes = width(4) + height(4) + depth(1) + colorType(1) + compression(1) + filter(1) + interlace(1)) + const buf = new ArrayBuffer(33); + const view = new DataView(buf); + const bytes = new Uint8Array(buf); + + // PNG signature + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + // IHDR chunk: length (13) + view.setUint32(8, 13); + // IHDR tag + bytes.set([0x49, 0x48, 0x44, 0x52], 12); + // width + height (big-endian) + view.setInt32(16, width); + view.setInt32(20, height); + + return bytes; +} + +function jpegHeader(width, height) { + // SOI + APP0 (minimal) + SOF0 with dimensions + const bytes = new Uint8Array(20); + const view = new DataView(bytes.buffer); + + // SOI + bytes[0] = 0xff; + bytes[1] = 0xd8; + // APP0 marker (will be skipped) + bytes[2] = 0xff; + bytes[3] = 0xe0; + view.setUint16(4, 5); // segment length = 5 (minimum: 2 + 3 bytes) + bytes[6] = 0x00; + bytes[7] = 0x00; + bytes[8] = 0x00; + // SOF0 marker + bytes[9] = 0xff; + bytes[10] = 0xc0; + view.setUint16(11, 8); // segment length + bytes[13] = 8; // precision + view.setUint16(14, height); + view.setUint16(16, width); + + return bytes; +} + +function gifHeader(width, height) { + // GIF89a + logical screen descriptor + const bytes = new Uint8Array(13); + const view = new DataView(bytes.buffer); + + // GIF89a signature + bytes.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); + // Width + height (little-endian uint16) + view.setUint16(6, width, true); + view.setUint16(8, height, true); + + return bytes; +} + +function bmpHeader(width, height) { + // BM + file header (14 bytes) + DIB header start with width/height + const bytes = new Uint8Array(26); + const view = new DataView(bytes.buffer); + + bytes[0] = 0x42; // B + bytes[1] = 0x4d; // M + // Skip file header bytes 2-13 + // DIB header: width at offset 18, height at offset 22 (little-endian int32) + view.setInt32(18, width, true); + view.setInt32(22, height, true); + + return bytes; +} + +function webpVP8Header(width, height) { + // RIFF....WEBP VP8 chunk with dimensions + const bytes = new Uint8Array(30); + const view = new DataView(bytes.buffer); + + // RIFF header + bytes.set([0x52, 0x49, 0x46, 0x46]); // RIFF + view.setUint32(4, 22, true); // file size (not critical for parsing) + bytes.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP + // VP8 chunk + bytes.set([0x56, 0x50, 0x38, 0x20], 12); // "VP8 " + view.setUint32(16, 10, true); // chunk size + // Frame header: 3 bytes of frame tag, then keyframe sync code (0x9D012A) + bytes[20] = 0x9d; + bytes[21] = 0x01; + bytes[22] = 0x2a; + // Padding bytes + bytes[23] = 0x00; + bytes[24] = 0x00; + bytes[25] = 0x00; + // width at 26-27 (LE uint16, lower 14 bits), height at 28-29 + view.setUint16(26, width, true); + view.setUint16(28, height, true); + + return bytes; +} + +function webpVP8XHeader(width, height) { + // RIFF....WEBP VP8X chunk with canvas dimensions + const bytes = new Uint8Array(30); + + bytes.set([0x52, 0x49, 0x46, 0x46]); // RIFF + bytes.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP + bytes.set([0x56, 0x50, 0x38, 0x58], 12); // "VP8X" + // VP8X chunk size at 16 (LE uint32) - 10 + bytes[16] = 10; + // Flags at 20 + bytes[20] = 0x00; + // Reserved bytes 21-23 + // Canvas width at 24-26 (24-bit LE, value = width - 1) + const w = width - 1; + bytes[24] = w & 0xff; + bytes[25] = (w >> 8) & 0xff; + bytes[26] = (w >> 16) & 0xff; + // Canvas height at 27-29 (24-bit LE, value = height - 1) + const h = height - 1; + bytes[27] = h & 0xff; + bytes[28] = (h >> 8) & 0xff; + bytes[29] = (h >> 16) & 0xff; + + return bytes; +} + +function toDataUri(bytes, mimeType) { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return `data:${mimeType};base64,${btoa(binary)}`; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('readImageDimensions', () => { + it('reads PNG dimensions', () => { + expect(readImageDimensions(pngHeader(800, 600))).toEqual({ width: 800, height: 600 }); + }); + + it('reads JPEG dimensions', () => { + expect(readImageDimensions(jpegHeader(1024, 768))).toEqual({ width: 1024, height: 768 }); + }); + + it('reads GIF dimensions', () => { + expect(readImageDimensions(gifHeader(320, 240))).toEqual({ width: 320, height: 240 }); + }); + + it('reads BMP dimensions', () => { + expect(readImageDimensions(bmpHeader(640, 480))).toEqual({ width: 640, height: 480 }); + }); + + it('reads BMP with negative height (top-down)', () => { + expect(readImageDimensions(bmpHeader(640, -480))).toEqual({ width: 640, height: 480 }); + }); + + it('reads WEBP VP8 (lossy) dimensions', () => { + expect(readImageDimensions(webpVP8Header(400, 300))).toEqual({ width: 400, height: 300 }); + }); + + it('reads WEBP VP8X (extended) dimensions', () => { + expect(readImageDimensions(webpVP8XHeader(1920, 1080))).toEqual({ width: 1920, height: 1080 }); + }); + + it('returns null for empty bytes', () => { + expect(readImageDimensions(new Uint8Array(0))).toBeNull(); + }); + + it('returns null for truncated PNG', () => { + const truncated = pngHeader(800, 600).slice(0, 18); + expect(readImageDimensions(truncated)).toBeNull(); + }); + + it('returns null for unknown format', () => { + const unknown = new Uint8Array(32); + unknown.fill(0xab); + expect(readImageDimensions(unknown)).toBeNull(); + }); + + it('returns null for non-Uint8Array input', () => { + expect(readImageDimensions('not bytes')).toBeNull(); + expect(readImageDimensions(null)).toBeNull(); + expect(readImageDimensions(undefined)).toBeNull(); + }); + + it('returns null for PNG with zero dimensions', () => { + expect(readImageDimensions(pngHeader(0, 600))).toBeNull(); + expect(readImageDimensions(pngHeader(800, 0))).toBeNull(); + }); +}); + +describe('readImageDimensionsFromDataUri', () => { + it('reads PNG dimensions from data URI', () => { + const uri = toDataUri(pngHeader(800, 600), 'image/png'); + expect(readImageDimensionsFromDataUri(uri)).toEqual({ width: 800, height: 600 }); + }); + + it('reads JPEG dimensions from data URI', () => { + const uri = toDataUri(jpegHeader(1024, 768), 'image/jpeg'); + expect(readImageDimensionsFromDataUri(uri)).toEqual({ width: 1024, height: 768 }); + }); + + it('reads GIF dimensions from data URI', () => { + const uri = toDataUri(gifHeader(320, 240), 'image/gif'); + expect(readImageDimensionsFromDataUri(uri)).toEqual({ width: 320, height: 240 }); + }); + + it('returns null for non-data-URI string', () => { + expect(readImageDimensionsFromDataUri('https://example.com/image.png')).toBeNull(); + }); + + it('returns null for malformed data URI', () => { + expect(readImageDimensionsFromDataUri('data:image/png')).toBeNull(); // no comma + }); + + it('returns null for empty base64 payload', () => { + expect(readImageDimensionsFromDataUri('data:image/png;base64,')).toBeNull(); + }); + + it('returns null for non-string input', () => { + expect(readImageDimensionsFromDataUri(null)).toBeNull(); + expect(readImageDimensionsFromDataUri(123)).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/anchor/helpers/translate-anchor-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/anchor/helpers/translate-anchor-node.js index 468074c0fb..0a73accecf 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/anchor/helpers/translate-anchor-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/anchor/helpers/translate-anchor-node.js @@ -1,6 +1,7 @@ import { translateImageNode } from '@converter/v3/handlers/wp/helpers/decode-image-node-helpers.js'; import { pixelsToEmu, objToPolygon } from '@converter/helpers.js'; import { mergeDrawingChildren } from '@converter/v3/handlers/wp/helpers/merge-drawing-children.js'; +import { parseRelativeHeight } from '@converter/v3/handlers/wp/helpers/relative-height.js'; /** * Translates anchor image @@ -72,9 +73,11 @@ export function translateAnchorNode(params) { ...(nodeElements.attributes || {}), }; - if (inlineAttrs.relativeHeight == null) { - inlineAttrs.relativeHeight = 1; - } + // Prefer the live top-level relativeHeight (updated by images.setZOrder) + // over the stale value in originalAttributes. Always serialize as unsignedInt. + const liveRelativeHeight = parseRelativeHeight(attrs.relativeHeight); + const originalRelativeHeight = parseRelativeHeight(inlineAttrs.relativeHeight); + inlineAttrs.relativeHeight = liveRelativeHeight ?? originalRelativeHeight ?? 1; if (attrs.originalAttributes?.simplePos === undefined && hasSimplePos) { inlineAttrs.simplePos = '1'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/anchor/helpers/translate-anchor-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/anchor/helpers/translate-anchor-node.test.js index b953c65360..a854861166 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/anchor/helpers/translate-anchor-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/anchor/helpers/translate-anchor-node.test.js @@ -630,7 +630,58 @@ describe('translateAnchorNode', () => { expect(result.attributes['wp14:anchorId']).toBe('52C3A784'); expect(result.attributes['wp14:editId']).toBe('36FE4467'); - expect(result.attributes.relativeHeight).toBe('251651584'); + expect(result.attributes.relativeHeight).toBe(251651584); + }); + + it('prefers live relativeHeight when it is a valid unsigned integer', () => { + const params = { + node: { + attrs: { + relativeHeight: 500, + originalAttributes: { + relativeHeight: '251651584', + }, + }, + }, + }; + + const result = translateAnchorNode(params); + + expect(result.attributes.relativeHeight).toBe(500); + }); + + it('falls back to original relativeHeight when live value is invalid', () => { + const params = { + node: { + attrs: { + relativeHeight: 1.5, + originalAttributes: { + relativeHeight: '251651584', + }, + }, + }, + }; + + const result = translateAnchorNode(params); + + expect(result.attributes.relativeHeight).toBe(251651584); + }); + + it('falls back to default relativeHeight=1 when both values are invalid', () => { + const params = { + node: { + attrs: { + relativeHeight: -1, + originalAttributes: { + relativeHeight: 'not-an-int', + }, + }, + }, + }; + + const result = translateAnchorNode(params); + + expect(result.attributes.relativeHeight).toBe(1); }); it('should apply polygonEdited value when provided', () => { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 14213b03f1..cd9358ec23 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -3,6 +3,7 @@ import { getFallbackImageNameFromDataUri, sanitizeDocxMediaName } from '@convert import { prepareTextAnnotation } from '@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js'; import { wrapTextInRun } from '@converter/exporter.js'; import { generateDocxRandomId } from '@core/helpers/index.js'; +import { readImageDimensionsFromDataUri } from '@converter/image-dimensions.js'; /** * Decodes image into export XML @@ -24,7 +25,6 @@ export const translateImageNode = (params) => { // Prefer originalSrc for round-trip fidelity (e.g., EMF/WMF files converted to SVG for display) const src = attrs.originalSrc || attrs.src || attrs.imageSrc; - const { originalWidth, originalHeight } = getPngDimensions(src); let imageName; if (params.node.type === 'image') { @@ -38,21 +38,35 @@ export const translateImageNode = (params) => { } imageName = sanitizeDocxMediaName(imageName); - let size = attrs.size - ? { - w: pixelsToEmu(attrs.size.width), - h: pixelsToEmu(attrs.size.height), - } - : imageSize; - - if (originalWidth && originalHeight) { - const boxWidthPx = emuToPixels(size.w); - const boxHeightPx = emuToPixels(size.h); - const { scaledWidth, scaledHeight } = getScaledSize(originalWidth, originalHeight, boxWidthPx, boxHeightPx); - size = { - w: pixelsToEmu(scaledWidth), - h: pixelsToEmu(scaledHeight), - }; + // For fieldAnnotations without a recognizable MIME type, fall back to text + // annotation before attempting size resolution (they have no image data). + if (params.node.type === 'fieldAnnotation' && !imageId) { + const type = src?.split(';')[0].split('/')[1]; + if (!type) { + return prepareTextAnnotation(params); + } + } + + let size = resolveExportSize(attrs, imageSize, src); + + // Scale box size to match intrinsic PNG aspect ratio (legacy behavior). + // Only applies to PNG data URIs — the old getPngDimensions only supported PNG. + if (src?.startsWith('data:image/png')) { + const intrinsicDims = readImageDimensionsFromDataUri(src); + if (intrinsicDims) { + const boxWidthPx = emuToPixels(size.w); + const boxHeightPx = emuToPixels(size.h); + const { scaledWidth, scaledHeight } = getScaledSize( + intrinsicDims.width, + intrinsicDims.height, + boxWidthPx, + boxHeightPx, + ); + size = { + w: pixelsToEmu(scaledWidth), + h: pixelsToEmu(scaledHeight), + }; + } } if (tableCell) { @@ -78,10 +92,8 @@ export const translateImageNode = (params) => { const path = src?.split('word/')[1]; imageId = addNewImageRelationship(params, path); } else if (params.node.type === 'fieldAnnotation' && !imageId) { + // We already handled the no-type case above; here the type IS valid. const type = src?.split(';')[0].split('/')[1]; - if (!type) { - return prepareTextAnnotation(params); - } const sanitizedHash = sanitizeDocxMediaName(attrs.hash, generateDocxRandomId(4)); const fileName = `${imageName}_${sanitizedHash}.${type}`; @@ -266,24 +278,44 @@ export const translateImageNode = (params) => { }; }; -function getPngDimensions(base64) { - if (!base64) return {}; +function isFinitePositive(value) { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} - const type = base64.split(';')[0].split('/')[1]; - if (!base64 || type !== 'png') { - return { - originalWidth: undefined, - originalHeight: undefined, - }; +/** + * Resolve export size from available sources, with strict validation. + * + * Priority: + * 1. attrs.size with valid finite positive dimensions + * 2. imageSize fallback (from paragraph measure) + * 3. Infer from data URI source bytes + * 4. Legacy fallback: use attrs.size / imageSize as-is (may produce NaN — matches pre-hardening behavior) + * + * @returns {{ w: number, h: number }} + */ +function resolveExportSize(attrs, imageSize, src) { + if (isFinitePositive(attrs.size?.width) && isFinitePositive(attrs.size?.height)) { + return { w: pixelsToEmu(attrs.size.width), h: pixelsToEmu(attrs.size.height) }; } - - let header = base64.split(',')[1].slice(0, 50); - let uint8 = Uint8Array.from(atob(header), (c) => c.charCodeAt(0)); - let dataView = new DataView(uint8.buffer, 0, 28); - + if (isFinitePositive(imageSize?.w) && isFinitePositive(imageSize?.h)) { + return imageSize; + } + if (src?.startsWith('data:')) { + const dims = readImageDimensionsFromDataUri(src); + if (dims) return { w: pixelsToEmu(dims.width), h: pixelsToEmu(dims.height) }; + } + // Legacy fallback: preserve old behavior for callers that pass + // non-validated imageSize or attrs.size (e.g., file-path images without + // explicit dimensions). The create.image path validates upstream. + const raw = attrs.size + ? { w: pixelsToEmu(attrs.size.width), h: pixelsToEmu(attrs.size.height) } + : imageSize || { w: 0, h: 0 }; + + // Clamp non-finite or non-positive values to 1 EMU so we never emit + // NaN or zero in / — both produce corrupt OOXML. return { - originalWidth: dataView.getInt32(16), - originalHeight: dataView.getInt32(20), + w: isFinitePositive(raw.w) ? raw.w : 1, + h: isFinitePositive(raw.h) ? raw.h : 1, }; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index de4803de08..c0f645fbbf 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -1,3 +1,4 @@ +import { v5 as uuidv5 } from 'uuid'; import { emuToPixels, rotToDegrees, polygonToObj } from '@converter/helpers.js'; import { carbonCopy } from '@core/utilities/carbonCopy.js'; import { @@ -17,11 +18,19 @@ import { extractParagraphAlignment, extractBodyPrProperties, } from './textbox-content-helpers.js'; +import { parseRelativeHeight } from './relative-height.js'; const DRAWING_XML_TAG = 'w:drawing'; const SHAPE_URI = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape'; const GROUP_URI = 'http://schemas.microsoft.com/office/word/2010/wordprocessingGroup'; +/** + * Namespace UUID for generating deterministic sdImageId values. + * Images imported from DOCX derive their sdImageId from rEmbed + document-part + * filename so the same image always receives the same ID across open cycles. + */ +const SD_IMAGE_ID_NAMESPACE = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + /** * Normalize a relationship target to a relative media path. * Strips leading slashes and collapses duplicated "word/" prefixes so lookups @@ -429,7 +438,19 @@ export function handleImageNode(node, params, isAnchor) { // which is not what we want for placeholder images that should maintain their original layout. const wrapValue = wrap; + // Extract relativeHeight from anchor attributes for first-class z-order support. + // We only accept OOXML-conformant unsignedInt values. + const relativeHeight = isAnchor ? parseRelativeHeight(attributes['relativeHeight']) : null; + + // Derive a deterministic sdImageId from the drawing's docPr id, the rEmbed, + // and the document-part filename so the same image always receives the same + // stable ID across multiple opens of the same DOCX. + const docPrId = docPr?.attributes?.id ?? ''; + const sdImageId = uuidv5(`${currentFile}:${rEmbed}:${docPrId}`, SD_IMAGE_ID_NAMESPACE); + const nodeAttrs = { + sdImageId, + relativeHeight, // originalXml: carbonCopy(node), src: finalSrc, alt: diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index 599598594a..335adb47a6 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -230,6 +230,42 @@ describe('handleImageNode', () => { expect(result.attrs.size).toEqual({ width: 5, height: 6 }); // emuToPixels mocked }); + it('parses valid anchor relativeHeight as unsigned integer', () => { + const node = makeNode({ + attributes: { + relativeHeight: '251651584', + }, + }); + + const result = handleImageNode(node, makeParams(), true); + + expect(result.attrs.relativeHeight).toBe(251651584); + }); + + it('drops fractional anchor relativeHeight values', () => { + const node = makeNode({ + attributes: { + relativeHeight: '1.5', + }, + }); + + const result = handleImageNode(node, makeParams(), true); + + expect(result.attrs.relativeHeight).toBeNull(); + }); + + it('drops out-of-range anchor relativeHeight values', () => { + const node = makeNode({ + attributes: { + relativeHeight: '4294967296', + }, + }); + + const result = handleImageNode(node, makeParams(), true); + + expect(result.attrs.relativeHeight).toBeNull(); + }); + it('calls convertTiffToPng for .tif images', () => { convertTiffToPng.mockReturnValue({ dataUri: 'data:image/png;base64,fake', format: 'png' }); const node = makeNode(); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/relative-height.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/relative-height.js new file mode 100644 index 0000000000..68c37d851e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/relative-height.js @@ -0,0 +1,45 @@ +/** + * OOXML unsignedInt bounds for wp:anchor@relativeHeight. + * ECMA-376 defines unsignedInt as 0..4294967295. + */ +export const RELATIVE_HEIGHT_MIN = 0; +export const RELATIVE_HEIGHT_MAX = 4_294_967_295; + +/** + * Check if a value is a valid OOXML unsignedInt (32-bit). + * + * @param {unknown} value + * @returns {value is number} + */ +export function isValidRelativeHeight(value) { + return ( + typeof value === 'number' && Number.isInteger(value) && value >= RELATIVE_HEIGHT_MIN && value <= RELATIVE_HEIGHT_MAX + ); +} + +/** + * Parse and normalize an OOXML relativeHeight value. + * + * Accepts: + * - numbers that are already valid unsignedInt values + * - digit-only strings (e.g. "251651584") + * + * Returns null for malformed, fractional, negative, or out-of-range values. + * + * @param {unknown} value + * @returns {number|null} + */ +export function parseRelativeHeight(value) { + if (isValidRelativeHeight(value)) return value; + + if (typeof value !== 'string') return null; + + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return null; + + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed)) return null; + if (!Number.isSafeInteger(parsed)) return null; + + return isValidRelativeHeight(parsed) ? parsed : null; +} diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index a3b29f7d51..b2b39e63a2 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -94,6 +94,20 @@ import { tocUnmarkEntryWrapper, tocEditEntryWrapper, } from '../plan-engine/toc-entry-wrappers.js'; +import { + createImageWrapper, + imagesDeleteWrapper, + imagesMoveWrapper, + imagesConvertToInlineWrapper, + imagesConvertToFloatingWrapper, + imagesSetSizeWrapper, + imagesSetWrapTypeWrapper, + imagesSetWrapSideWrapper, + imagesSetWrapDistancesWrapper, + imagesSetPositionWrapper, + imagesSetAnchorOptionsWrapper, + imagesSetZOrderWrapper, +} from '../plan-engine/images-wrappers.js'; import { listsInsertWrapper, listsIndentWrapper, @@ -1905,6 +1919,147 @@ function getFirstTocEntryAddress(editor: Editor): { kind: 'inline'; nodeType: 't }; } +/** + * Creates a mock editor containing one floating image node inside a paragraph. + * The image has `sdImageId: 'img-1'`, `isAnchor: true`, and `wrap: { type: 'Square' }`. + */ +function makeImageEditor(): Editor { + const imageNode = createNode('image', [], { + attrs: { + sdImageId: 'img-1', + src: 'https://example.com/test.png', + alt: 'Test image', + isAnchor: true, + wrap: { type: 'Square', attrs: { wrapText: 'bothSides' } }, + anchorData: { hRelativeFrom: 'column', vRelativeFrom: 'paragraph' }, + marginOffset: null, + relativeHeight: 251658240, + originalAttributes: {}, + size: { width: 100, height: 100 }, + }, + isInline: true, + isLeaf: true, + }); + const paragraph = createNode('paragraph', [imageNode], { + attrs: { sdBlockId: 'p-img' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const dispatch = vi.fn(); + const tr = { + insertText: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + setNodeMarkup: vi.fn().mockReturnThis(), + replaceWith: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: true, + steps: [{}], + doc, + }; + + return { + state: { + doc, + tr, + schema: { + nodes: { + image: { + create: vi.fn((attrs: Record) => + createNode('image', [], { attrs, isInline: true, isLeaf: true }), + ), + }, + }, + }, + }, + dispatch, + commands: { + setImage: vi.fn(() => true), + }, + schema: { marks: {} }, + options: {}, + on: () => {}, + } as unknown as Editor; +} + +/** + * Editor with two paragraphs to make image before/after/inParagraph positioning meaningful. + * p1 contains one floating image (img-1), p2 contains text ("Hello"). + */ +function makeMultiBlockImageEditor(): Editor { + const imageNode = createNode('image', [], { + attrs: { + sdImageId: 'img-1', + src: 'https://example.com/test.png', + isAnchor: true, + wrap: { type: 'Square', attrs: { wrapText: 'bothSides' } }, + anchorData: { hRelativeFrom: 'column', vRelativeFrom: 'paragraph' }, + marginOffset: null, + relativeHeight: 251658240, + originalAttributes: {}, + size: { width: 100, height: 100 }, + }, + isInline: true, + isLeaf: true, + }); + // p1: pos=0, nodeSize=3 (1 inline image + 2 wrapper) + const p1 = createNode('paragraph', [imageNode], { + attrs: { sdBlockId: 'p-img' }, + isBlock: true, + inlineContent: true, + }); + const textNode = createNode('text', [], { text: 'Hello' }); + // p2: pos=3, nodeSize=7 (5 text chars + 2 wrapper) + const p2 = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p-text' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [p1, p2], { isBlock: false }); + // doc.content.size = 10 + + const dispatch = vi.fn(); + const tr = { + insertText: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + setNodeMarkup: vi.fn().mockReturnThis(), + replaceWith: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: true, + steps: [{}], + doc, + }; + + return { + state: { + doc, + tr, + schema: { + nodes: { + image: { + create: vi.fn((attrs: Record) => + createNode('image', [], { attrs, isInline: true, isLeaf: true }), + ), + }, + }, + }, + }, + dispatch, + commands: { + setImage: vi.fn(() => true), + insertContentAt: vi.fn(() => true), + }, + schema: { marks: {} }, + options: {}, + on: () => {}, + } as unknown as Editor; +} + const mutationVectors: Partial> = { 'blocks.delete': { throwCase: () => { @@ -4017,6 +4172,297 @@ const mutationVectors: Partial> = { ); }, }, + + // ------------------------------------------------------------------------- + // Image operations + // ------------------------------------------------------------------------- + 'create.image': { + throwCase: () => { + // setImage command missing → CAPABILITY_UNAVAILABLE + const editor = makeImageEditor(); + (editor.commands as Record).setImage = undefined; + return createImageWrapper( + editor, + { src: 'https://example.com/img.png', size: { width: 100, height: 100 } }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + // URL src without explicit size → INVALID_INPUT (cannot infer dimensions) + const editor = makeImageEditor(); + return createImageWrapper(editor, { src: 'https://example.com/img.png' }, { changeMode: 'direct' }); + }, + applyCase: () => { + return createImageWrapper( + makeImageEditor(), + { src: 'https://example.com/img.png', size: { width: 100, height: 100 } }, + { changeMode: 'direct' }, + ); + }, + }, + 'images.delete': { + throwCase: () => imagesDeleteWrapper(makeImageEditor(), { imageId: 'missing' }, { changeMode: 'direct' }), + failureCase: () => { + // Transaction produces no change → NO_OP + const editor = makeImageEditor(); + const tr = (editor.state as unknown as { tr: Record }).tr; + tr.docChanged = false; + tr.steps = []; + return imagesDeleteWrapper(editor, { imageId: 'img-1' }, { changeMode: 'direct' }); + }, + applyCase: () => imagesDeleteWrapper(makeImageEditor(), { imageId: 'img-1' }, { changeMode: 'direct' }), + }, + 'images.move': { + throwCase: () => + imagesMoveWrapper( + makeImageEditor(), + { imageId: 'missing', to: { kind: 'documentEnd' } }, + { changeMode: 'direct' }, + ), + failureCase: () => { + // Transaction produces no change → NO_OP + const editor = makeImageEditor(); + const tr = (editor.state as unknown as { tr: Record }).tr; + tr.docChanged = false; + tr.steps = []; + return imagesMoveWrapper(editor, { imageId: 'img-1', to: { kind: 'documentEnd' } }, { changeMode: 'direct' }); + }, + applyCase: () => + imagesMoveWrapper(makeImageEditor(), { imageId: 'img-1', to: { kind: 'documentEnd' } }, { changeMode: 'direct' }), + }, + 'images.convertToInline': { + throwCase: () => imagesConvertToInlineWrapper(makeImageEditor(), { imageId: 'missing' }, { changeMode: 'direct' }), + failureCase: () => { + // Already inline → NO_OP + const inlineImg = createNode('image', [], { + attrs: { + sdImageId: 'img-inline-noop', + src: 'test.png', + isAnchor: false, + wrap: { type: 'Inline' }, + anchorData: null, + marginOffset: null, + relativeHeight: null, + originalAttributes: {}, + }, + isInline: true, + isLeaf: true, + }); + const p = createNode('paragraph', [inlineImg], { + attrs: { sdBlockId: 'p-x' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [p], { isBlock: false }); + const editor = { + state: { doc, tr: {}, schema: { nodes: {} } }, + dispatch: vi.fn(), + commands: { setImage: vi.fn(() => true) }, + schema: { marks: {} }, + options: {}, + on: () => {}, + } as unknown as Editor; + return imagesConvertToInlineWrapper(editor, { imageId: 'img-inline-noop' }, { changeMode: 'direct' }); + }, + applyCase: () => imagesConvertToInlineWrapper(makeImageEditor(), { imageId: 'img-1' }, { changeMode: 'direct' }), + }, + 'images.convertToFloating': { + throwCase: () => + imagesConvertToFloatingWrapper(makeImageEditor(), { imageId: 'missing' }, { changeMode: 'direct' }), + failureCase: () => { + // Already floating → NO_OP + return imagesConvertToFloatingWrapper(makeImageEditor(), { imageId: 'img-1' }, { changeMode: 'direct' }); + }, + applyCase: () => { + const inlineImg = createNode('image', [], { + attrs: { + sdImageId: 'img-for-float', + src: 'test.png', + isAnchor: false, + wrap: { type: 'Inline' }, + anchorData: null, + marginOffset: null, + relativeHeight: null, + originalAttributes: {}, + }, + isInline: true, + isLeaf: true, + }); + const p = createNode('paragraph', [inlineImg], { + attrs: { sdBlockId: 'p-f' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [p], { isBlock: false }); + const tr = { + setNodeMarkup: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: true, + steps: [{}], + doc, + }; + const editor = { + state: { doc, tr, schema: { nodes: {} } }, + dispatch: vi.fn(), + commands: { setImage: vi.fn(() => true) }, + schema: { marks: {} }, + options: {}, + on: () => {}, + } as unknown as Editor; + return imagesConvertToFloatingWrapper(editor, { imageId: 'img-for-float' }, { changeMode: 'direct' }); + }, + }, + 'images.setSize': { + throwCase: () => + imagesSetSizeWrapper( + makeImageEditor(), + { imageId: 'missing', size: { width: 220, height: 140 } }, + { changeMode: 'direct' }, + ), + failureCase: () => { + // Same size → NO_OP + return imagesSetSizeWrapper( + makeImageEditor(), + { imageId: 'img-1', size: { width: 100, height: 100 } }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetSizeWrapper( + makeImageEditor(), + { imageId: 'img-1', size: { width: 220, height: 140 } }, + { changeMode: 'direct' }, + ), + }, + 'images.setWrapType': { + throwCase: () => + imagesSetWrapTypeWrapper(makeImageEditor(), { imageId: 'missing', type: 'Tight' }, { changeMode: 'direct' }), + failureCase: () => { + // Same type → NO_OP + return imagesSetWrapTypeWrapper( + makeImageEditor(), + { imageId: 'img-1', type: 'Square' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetWrapTypeWrapper(makeImageEditor(), { imageId: 'img-1', type: 'Tight' }, { changeMode: 'direct' }), + }, + 'images.setWrapSide': { + throwCase: () => + imagesSetWrapSideWrapper(makeImageEditor(), { imageId: 'missing', side: 'left' }, { changeMode: 'direct' }), + failureCase: () => { + // Same side → NO_OP + return imagesSetWrapSideWrapper( + makeImageEditor(), + { imageId: 'img-1', side: 'bothSides' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetWrapSideWrapper(makeImageEditor(), { imageId: 'img-1', side: 'left' }, { changeMode: 'direct' }), + }, + 'images.setWrapDistances': { + throwCase: () => + imagesSetWrapDistancesWrapper( + makeImageEditor(), + { imageId: 'missing', distances: { distTop: 100 } }, + { changeMode: 'direct' }, + ), + failureCase: () => { + // Transaction produces no change → NO_OP + const editor = makeImageEditor(); + const tr = (editor.state as unknown as { tr: Record }).tr; + tr.docChanged = false; + tr.steps = []; + return imagesSetWrapDistancesWrapper( + editor, + { imageId: 'img-1', distances: { distTop: 100 } }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetWrapDistancesWrapper( + makeImageEditor(), + { imageId: 'img-1', distances: { distTop: 100 } }, + { changeMode: 'direct' }, + ), + }, + 'images.setPosition': { + throwCase: () => + imagesSetPositionWrapper( + makeImageEditor(), + { imageId: 'missing', position: { hRelativeFrom: 'page' } }, + { changeMode: 'direct' }, + ), + failureCase: () => { + // Transaction produces no change → NO_OP + const editor = makeImageEditor(); + const tr = (editor.state as unknown as { tr: Record }).tr; + tr.docChanged = false; + tr.steps = []; + return imagesSetPositionWrapper( + editor, + { imageId: 'img-1', position: { hRelativeFrom: 'page' } }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetPositionWrapper( + makeImageEditor(), + { imageId: 'img-1', position: { hRelativeFrom: 'page' } }, + { changeMode: 'direct' }, + ), + }, + 'images.setAnchorOptions': { + throwCase: () => + imagesSetAnchorOptionsWrapper( + makeImageEditor(), + { imageId: 'missing', options: { behindDoc: true } }, + { changeMode: 'direct' }, + ), + failureCase: () => { + // Transaction produces no change → NO_OP + const editor = makeImageEditor(); + const tr = (editor.state as unknown as { tr: Record }).tr; + tr.docChanged = false; + tr.steps = []; + return imagesSetAnchorOptionsWrapper( + editor, + { imageId: 'img-1', options: { behindDoc: true } }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetAnchorOptionsWrapper( + makeImageEditor(), + { imageId: 'img-1', options: { behindDoc: true } }, + { changeMode: 'direct' }, + ), + }, + 'images.setZOrder': { + throwCase: () => + imagesSetZOrderWrapper( + makeImageEditor(), + { imageId: 'missing', zOrder: { relativeHeight: 999 } }, + { changeMode: 'direct' }, + ), + failureCase: () => { + // Same relativeHeight → NO_OP + return imagesSetZOrderWrapper( + makeImageEditor(), + { imageId: 'img-1', zOrder: { relativeHeight: 251658240 } }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetZOrderWrapper( + makeImageEditor(), + { imageId: 'img-1', zOrder: { relativeHeight: 999999999 } }, + { changeMode: 'direct' }, + ), + }, }; const dryRunVectors: Partial unknown>> = { @@ -4968,6 +5414,170 @@ const dryRunVectors: Partial unknown>> = { expect(updateEntry).not.toHaveBeenCalled(); return result; }, + + // ------------------------------------------------------------------------- + // Image operations — dryRun vectors + // ------------------------------------------------------------------------- + 'create.image': () => { + const setImage = vi.fn(() => true); + const { editor } = makeTextEditor('Hello', { commands: { setImage } }); + const result = createImageWrapper( + editor, + { src: 'https://example.com/img.png', size: { width: 100, height: 100 } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(setImage).not.toHaveBeenCalled(); + return result; + }, + 'images.delete': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesDeleteWrapper(editor, { imageId: 'img-1' }, { changeMode: 'direct', dryRun: true }); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.move': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesMoveWrapper( + editor, + { imageId: 'img-1', to: { kind: 'documentEnd' } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.convertToInline': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesConvertToInlineWrapper(editor, { imageId: 'img-1' }, { changeMode: 'direct', dryRun: true }); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.convertToFloating': () => { + // Need an inline image for convertToFloating to be non-no-op + const inlineImageNode = createNode('image', [], { + attrs: { + sdImageId: 'img-inline', + src: 'https://example.com/test.png', + isAnchor: false, + wrap: { type: 'Inline' }, + anchorData: null, + marginOffset: null, + relativeHeight: null, + originalAttributes: {}, + }, + isInline: true, + isLeaf: true, + }); + const paragraph = createNode('paragraph', [inlineImageNode], { + attrs: { sdBlockId: 'p-img-inline' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + const dispatch = vi.fn(); + const tr = { + setNodeMarkup: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: true, + steps: [{}], + doc, + }; + const editor = { + state: { doc, tr, schema: { nodes: {} } }, + dispatch, + commands: { setImage: vi.fn(() => true) }, + schema: { marks: {} }, + options: {}, + on: () => {}, + } as unknown as Editor; + const result = imagesConvertToFloatingWrapper( + editor, + { imageId: 'img-inline' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setSize': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetSizeWrapper( + editor, + { imageId: 'img-1', size: { width: 220, height: 140 } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setWrapType': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetWrapTypeWrapper( + editor, + { imageId: 'img-1', type: 'Tight' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setWrapSide': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetWrapSideWrapper( + editor, + { imageId: 'img-1', side: 'left' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setWrapDistances': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetWrapDistancesWrapper( + editor, + { imageId: 'img-1', distances: { distTop: 100, distBottom: 100 } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setPosition': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetPositionWrapper( + editor, + { imageId: 'img-1', position: { hRelativeFrom: 'page' } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setAnchorOptions': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetAnchorOptionsWrapper( + editor, + { imageId: 'img-1', options: { behindDoc: true } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setZOrder': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetZOrderWrapper( + editor, + { imageId: 'img-1', zOrder: { relativeHeight: 999999999 } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, }; beforeEach(() => { @@ -5674,4 +6284,289 @@ describe('document-api adapter conformance', () => { expect(receipt.steps[0].effect, `${op} outcome should be 'changed'`).toBe('changed'); }, ); + + // ------------------------------------------------------------------------- + // Location semantics — coverage for create.image at / images.move to + // ------------------------------------------------------------------------- + + describe('image location semantics', () => { + /** Editor with two paragraphs to make before/after positions meaningful. */ + function makeMultiBlockImageEditor() { + const imageNode = createNode('image', [], { + attrs: { + sdImageId: 'img-1', + src: 'https://example.com/test.png', + isAnchor: true, + wrap: { type: 'Square', attrs: { wrapText: 'bothSides' } }, + anchorData: { hRelativeFrom: 'column', vRelativeFrom: 'paragraph' }, + marginOffset: null, + relativeHeight: 251658240, + originalAttributes: {}, + size: { width: 100, height: 100 }, + }, + isInline: true, + isLeaf: true, + }); + // p1: pos=0, nodeSize=3 (1 inline image + 2 wrapper) + const p1 = createNode('paragraph', [imageNode], { + attrs: { sdBlockId: 'p-img' }, + isBlock: true, + inlineContent: true, + }); + const textNode = createNode('text', [], { text: 'Hello' }); + // p2: pos=3, nodeSize=7 (5 text chars + 2 wrapper) + const p2 = createNode('paragraph', [textNode], { + attrs: { sdBlockId: 'p-text' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [p1, p2], { isBlock: false }); + // doc.content.size = 10 + + const dispatch = vi.fn(); + const tr = { + insertText: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + setNodeMarkup: vi.fn().mockReturnThis(), + replaceWith: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: true, + steps: [{}], + doc, + }; + + return { + state: { + doc, + tr, + schema: { + nodes: { + image: { + create: vi.fn((attrs: Record) => + createNode('image', [], { attrs, isInline: true, isLeaf: true }), + ), + }, + }, + }, + }, + dispatch, + commands: { + setImage: vi.fn(() => true), + insertContentAt: vi.fn(() => true), + }, + schema: { marks: {} }, + options: {}, + on: () => {}, + } as unknown as Editor; + } + + it('create.image with at: documentStart uses insertContentAt at position 0', () => { + const editor = makeMultiBlockImageEditor(); + const result = createImageWrapper( + editor, + { src: 'https://example.com/new.png', size: { width: 100, height: 100 }, at: { kind: 'documentStart' } }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + expect((editor.commands as any).insertContentAt).toHaveBeenCalledWith( + 0, + expect.objectContaining({ type: 'image' }), + ); + expect((editor.commands as any).setImage).not.toHaveBeenCalled(); + }); + + it('create.image with at: documentEnd uses insertContentAt at content size', () => { + const editor = makeMultiBlockImageEditor(); + const result = createImageWrapper( + editor, + { src: 'https://example.com/new.png', size: { width: 100, height: 100 }, at: { kind: 'documentEnd' } }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + expect((editor.commands as any).insertContentAt).toHaveBeenCalledWith( + 10, // doc.content.size + expect.objectContaining({ type: 'image' }), + ); + expect((editor.commands as any).setImage).not.toHaveBeenCalled(); + }); + + it('create.image with at: before resolves block insertion position', () => { + const editor = makeMultiBlockImageEditor(); + const result = createImageWrapper( + editor, + { + src: 'https://example.com/new.png', + size: { width: 100, height: 100 }, + at: { kind: 'before', target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-text' } }, + }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + expect((editor.commands as any).insertContentAt).toHaveBeenCalledWith( + 3, // p-text starts at pos 3 + expect.objectContaining({ type: 'image' }), + ); + }); + + it('create.image with at: after resolves block end position', () => { + const editor = makeMultiBlockImageEditor(); + const result = createImageWrapper( + editor, + { + src: 'https://example.com/new.png', + size: { width: 100, height: 100 }, + at: { kind: 'after', target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-img' } }, + }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + expect((editor.commands as any).insertContentAt).toHaveBeenCalledWith( + 3, // p-img ends at pos 3 (pos=0 + nodeSize=3) + expect.objectContaining({ type: 'image' }), + ); + }); + + it('create.image with at: inParagraph resolves inline offset position', () => { + const editor = makeMultiBlockImageEditor(); + const result = createImageWrapper( + editor, + { + src: 'https://example.com/new.png', + size: { width: 100, height: 100 }, + at: { kind: 'inParagraph', target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-text' }, offset: 2 }, + }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + // p-text starts at pos 3, +1 enters inline content, +2 offset = 6 + expect((editor.commands as any).insertContentAt).toHaveBeenCalledWith( + 6, + expect.objectContaining({ type: 'image' }), + ); + }); + + it('create.image without at uses setImage (selection-based)', () => { + const editor = makeMultiBlockImageEditor(); + const result = createImageWrapper( + editor, + { src: 'https://example.com/new.png', size: { width: 100, height: 100 } }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + expect((editor.commands as any).setImage).toHaveBeenCalled(); + expect((editor.commands as any).insertContentAt).not.toHaveBeenCalled(); + }); + + it('images.move with to: documentStart inserts at position 0', () => { + const editor = makeMultiBlockImageEditor(); + const result = imagesMoveWrapper( + editor, + { imageId: 'img-1', to: { kind: 'documentStart' } }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + const tr = (editor.state as unknown as { tr: { insert: ReturnType } }).tr; + expect(tr.insert).toHaveBeenCalledWith(0, expect.anything()); + }); + + it('images.move with to: before resolves block position', () => { + const editor = makeMultiBlockImageEditor(); + const result = imagesMoveWrapper( + editor, + { + imageId: 'img-1', + to: { kind: 'before', target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-text' } }, + }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + const tr = (editor.state as unknown as { tr: { insert: ReturnType } }).tr; + // p-text starts at pos 3, mapping.map(3) → 3 + expect(tr.insert).toHaveBeenCalledWith(3, expect.anything()); + }); + + it('images.move with to: after resolves block end position', () => { + const editor = makeMultiBlockImageEditor(); + const result = imagesMoveWrapper( + editor, + { imageId: 'img-1', to: { kind: 'after', target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p-text' } } }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + const tr = (editor.state as unknown as { tr: { insert: ReturnType } }).tr; + // p-text ends at pos 10, mapping.map(10) → 10 + expect(tr.insert).toHaveBeenCalledWith(10, expect.anything()); + }); + }); + + // ------------------------------------------------------------------------- + // Image dimension resolution & unique drawing ID + // ------------------------------------------------------------------------- + + describe('image dimension resolution', () => { + /** Minimal 1x1 PNG as data URI (valid IHDR with width=1, height=1). */ + function makePngDataUri(width: number, height: number): string { + // Build a minimal PNG header with the given width/height in IHDR + const buf = new ArrayBuffer(33); + const view = new DataView(buf); + const bytes = new Uint8Array(buf); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG signature + view.setUint32(8, 13); // IHDR length + bytes.set([0x49, 0x48, 0x44, 0x52], 12); // IHDR tag + view.setInt32(16, width); + view.setInt32(20, height); + let binary = ''; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return `data:image/png;base64,${btoa(binary)}`; + } + + it('create.image resolves dimensions from a data URI when size is omitted', () => { + const editor = makeImageEditor(); + const pngUri = makePngDataUri(200, 150); + const result = createImageWrapper(editor, { src: pngUri }, { changeMode: 'direct' }); + expect(result.success).toBe(true); + // The setImage command should have been called with resolved size + const setImage = (editor.commands as any).setImage; + const attrs = setImage.mock.calls[0]?.[0]; + expect(attrs.size).toEqual({ width: 200, height: 150 }); + }); + + it('create.image returns INVALID_INPUT when URL src has no size', () => { + const editor = makeImageEditor(); + const result = createImageWrapper(editor, { src: 'https://example.com/image.png' }, { changeMode: 'direct' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('INVALID_INPUT'); + } + }); + + it('create.image returns INVALID_INPUT for data URI with unsupported format', () => { + const editor = makeImageEditor(); + // A data URI that doesn't match any known image format + const badUri = `data:application/octet-stream;base64,${btoa('not a real image')}`; + const result = createImageWrapper(editor, { src: badUri }, { changeMode: 'direct' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('INVALID_INPUT'); + } + }); + + it('create.image assigns a unique drawing ID (attrs.id)', () => { + const editor = makeImageEditor(); + const result = createImageWrapper( + editor, + { src: 'https://example.com/img.png', size: { width: 100, height: 100 } }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + const setImage = (editor.commands as any).setImage; + const attrs = setImage.mock.calls[0]?.[0]; + // id should be a non-empty string (numeric string from generateUniqueDocPrId) + expect(attrs.id).toBeDefined(); + expect(typeof attrs.id).toBe('string'); + expect(attrs.id.length).toBeGreaterThan(0); + }); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index 68ffed189b..c10ee40d1f 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -149,6 +149,22 @@ import { tocUnmarkEntryWrapper, tocEditEntryWrapper, } from './plan-engine/toc-entry-wrappers.js'; +import { + createImageWrapper, + imagesListWrapper, + imagesGetWrapper, + imagesDeleteWrapper, + imagesMoveWrapper, + imagesConvertToInlineWrapper, + imagesConvertToFloatingWrapper, + imagesSetSizeWrapper, + imagesSetWrapTypeWrapper, + imagesSetWrapSideWrapper, + imagesSetWrapDistancesWrapper, + imagesSetPositionWrapper, + imagesSetAnchorOptionsWrapper, + imagesSetZOrderWrapper, +} from './plan-engine/images-wrappers.js'; /** * Assembles all document-api adapters for the given editor instance. @@ -233,6 +249,7 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters table: (input, options) => createTableWrapper(editor, input, options), sectionBreak: (input, options) => createSectionBreakAdapter(editor, input, options), tableOfContents: (input, options) => createTableOfContentsWrapper(editor, input, options), + image: (input, options) => createImageWrapper(editor, input, options), }, lists: { list: (query) => listsListWrapper(editor, query), @@ -329,6 +346,22 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters getEntry: (input) => tocGetEntryWrapper(editor, input), editEntry: (input, options) => tocEditEntryWrapper(editor, input, options), }, + images: { + image: (input, options) => createImageWrapper(editor, input, options), + list: (input) => imagesListWrapper(editor, input), + get: (input) => imagesGetWrapper(editor, input), + delete: (input, options) => imagesDeleteWrapper(editor, input, options), + move: (input, options) => imagesMoveWrapper(editor, input, options), + convertToInline: (input, options) => imagesConvertToInlineWrapper(editor, input, options), + convertToFloating: (input, options) => imagesConvertToFloatingWrapper(editor, input, options), + setSize: (input, options) => imagesSetSizeWrapper(editor, input, options), + setWrapType: (input, options) => imagesSetWrapTypeWrapper(editor, input, options), + setWrapSide: (input, options) => imagesSetWrapSideWrapper(editor, input, options), + setWrapDistances: (input, options) => imagesSetWrapDistancesWrapper(editor, input, options), + setPosition: (input, options) => imagesSetPositionWrapper(editor, input, options), + setAnchorOptions: (input, options) => imagesSetAnchorOptionsWrapper(editor, input, options), + setZOrder: (input, options) => imagesSetZOrderWrapper(editor, input, options), + }, query: { match: (input) => queryMatchAdapter(editor, input), }, diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index fd71740a3a..39f993a988 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -105,6 +105,19 @@ const REQUIRED_COMMANDS: Partial { + if (node.type.name !== 'image') return; + + const sdImageId = node.attrs.sdImageId; + if (typeof sdImageId !== 'string' || sdImageId.length === 0) return; + + results.push({ + pos, + node, + sdImageId, + placement: node.attrs.isAnchor ? 'floating' : 'inline', + }); + }); + + return results; +} + +// --------------------------------------------------------------------------- +// Lookup helpers +// --------------------------------------------------------------------------- + +/** + * Finds a single image by `sdImageId`. + * + * @throws {DocumentApiAdapterError} `TARGET_NOT_FOUND` when no image has the given ID. + * @throws {DocumentApiAdapterError} `AMBIGUOUS_TARGET` when multiple images share the same ID. + */ +export function findImageById(editor: Editor, sdImageId: string): ImageCandidate { + const candidates = collectImages(editor.state.doc); + const matches = candidates.filter((c) => c.sdImageId === sdImageId); + + if (matches.length === 0) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Image with sdImageId "${sdImageId}" was not found.`, { + sdImageId, + }); + } + + if (matches.length > 1) { + throw new DocumentApiAdapterError( + 'AMBIGUOUS_TARGET', + `Multiple images share sdImageId "${sdImageId}" (${matches.length} found).`, + { sdImageId, count: matches.length }, + ); + } + + return matches[0]; +} + +/** + * Requires that the targeted image has floating placement. + * + * @throws {DocumentApiAdapterError} `INVALID_TARGET` when the image is inline. + */ +export function requireFloatingPlacement(image: ImageCandidate, operation: string): void { + if (image.placement === 'floating') return; + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `${operation} requires a floating image, but image "${image.sdImageId}" has inline placement.`, + { sdImageId: image.sdImageId, placement: image.placement }, + ); +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts index 7b6d8e7fc7..7a9d3b0853 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts @@ -233,7 +233,10 @@ function mapTableOfContentsNode(candidate: BlockCandidate): TableOfContentsNodeI } function buildImageInfo(attrs: ImageAttrs | undefined, kind: 'block' | 'inline'): ImageNodeInfo { - const properties = { + const isFloating = Boolean(attrs?.isAnchor); + const wrapObj = attrs?.wrap; + + const properties: ImageNodeInfo['properties'] = { src: attrs?.src ?? undefined, alt: attrs?.alt ?? undefined, size: attrs?.size @@ -243,7 +246,14 @@ function buildImageInfo(attrs: ImageAttrs | undefined, kind: 'block' | 'inline') unit: undefined, } : undefined, - wrap: attrs?.wrap?.type ?? undefined, + placement: isFloating ? 'floating' : 'inline', + wrap: { + type: (wrapObj?.type as ImageNodeInfo['properties']['wrap']['type']) ?? 'Inline', + attrs: wrapObj?.attrs ?? undefined, + }, + anchorData: attrs?.anchorData ?? null, + marginOffset: attrs?.marginOffset ?? null, + relativeHeight: attrs?.relativeHeight ?? null, }; return { diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/images-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/images-wrappers.ts new file mode 100644 index 0000000000..dc76b9a282 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/images-wrappers.ts @@ -0,0 +1,772 @@ +/** + * Plan-engine wrappers for all images.* operations. + * + * All image attribute mutations use `tr.setNodeMarkup` at the resolved image + * position — no dedicated editor commands exist for size, position, anchor options, or z-order. + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { + MutationOptions, + CreateImageInput, + CreateImageResult, + ImagesListInput, + ImagesListResult, + ImagesGetInput, + ImageSummary, + ImagesDeleteInput, + ImagesMutationResult, + MoveImageInput, + ConvertToInlineInput, + ConvertToFloatingInput, + SetSizeInput, + SetWrapTypeInput, + SetWrapSideInput, + SetWrapDistancesInput, + SetPositionInput, + SetAnchorOptionsInput, + SetZOrderInput, + ImageAddress, + ImageWrapType, + ImageCreateLocation, +} from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { + collectImages, + findImageById, + requireFloatingPlacement, + type ImageCandidate, +} from '../helpers/image-resolver.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; +import { executeDomainCommand } from './plan-wrappers.js'; +import { resolveBlockInsertionPos } from './create-insertion.js'; +import { readImageDimensionsFromDataUri } from '../../core/super-converter/image-dimensions.js'; +import { generateUniqueDocPrId } from '../../extensions/image/imageHelpers/startImageUpload.js'; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +const ALLOWED_WRAP_ATTRS: Record = { + None: ['behindDoc'], + Square: ['wrapText', 'distTop', 'distBottom', 'distLeft', 'distRight'], + Through: ['wrapText', 'distTop', 'distBottom', 'distLeft', 'distRight', 'polygon'], + Tight: ['wrapText', 'distTop', 'distBottom', 'distLeft', 'distRight', 'polygon'], + TopAndBottom: ['distTop', 'distBottom'], + Inline: [], +}; + +const WRAP_TYPES_SUPPORTING_SIDE = new Set(['Square', 'Tight', 'Through']); +const WRAP_TYPES_SUPPORTING_DISTANCES = new Set(['Square', 'Tight', 'Through', 'TopAndBottom']); +const RELATIVE_HEIGHT_MIN = 0; +const RELATIVE_HEIGHT_MAX = 4_294_967_295; + +function buildImageAddress(candidate: ImageCandidate): ImageAddress { + return { + kind: 'inline', + nodeType: 'image', + nodeId: candidate.sdImageId, + placement: candidate.placement, + }; +} + +function buildSuccessResult(candidate: ImageCandidate): ImagesMutationResult { + return { success: true, image: buildImageAddress(candidate) }; +} + +function buildNoOpResult(message: string): ImagesMutationResult { + return { success: false, failure: { code: 'NO_OP', message } }; +} + +function buildImageSummary(candidate: ImageCandidate): ImageSummary { + const attrs = candidate.node.attrs; + return { + sdImageId: candidate.sdImageId, + address: buildImageAddress(candidate), + properties: { + src: attrs.src ?? undefined, + alt: attrs.alt ?? undefined, + size: attrs.size ?? undefined, + placement: candidate.placement, + wrap: { + type: (attrs.wrap?.type as ImageWrapType) ?? 'Inline', + attrs: attrs.wrap?.attrs ?? undefined, + }, + anchorData: attrs.anchorData ?? null, + marginOffset: attrs.marginOffset ?? null, + relativeHeight: attrs.relativeHeight ?? null, + }, + }; +} + +function isUnsignedInt32(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isInteger(value) && value >= RELATIVE_HEIGHT_MIN && value <= RELATIVE_HEIGHT_MAX + ); +} + +/** + * Resolve an ImageCreateLocation to a numeric ProseMirror position. + * + * Reuses the same block-index infrastructure as create.paragraph / create.heading + * so that `before` / `after` / `inParagraph` semantics are consistent. + */ +function resolveImageInsertPosition(editor: Editor, location: ImageCreateLocation): number { + switch (location.kind) { + case 'documentStart': + return 0; + case 'documentEnd': + return editor.state.doc.content.size; + case 'before': + case 'after': + return resolveBlockInsertionPos(editor, location.target.nodeId, location.kind); + case 'inParagraph': { + const pos = resolveBlockInsertionPos(editor, location.target.nodeId, 'before'); + // pos points to the start of the paragraph node; +1 enters the inline content. + // Add any caller-supplied character offset within the paragraph text. + return pos + 1 + (location.offset ?? 0); + } + default: { + const _exhaustive: never = location; + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Unknown image location kind: "${(location as { kind: string }).kind}".`, + ); + } + } +} + +/** Strip wrap.attrs to only the keys allowed for the given wrap type. */ +function filterWrapAttrs(type: string, attrs: Record): Record { + const allowed = ALLOWED_WRAP_ATTRS[type] ?? []; + const result: Record = {}; + for (const key of allowed) { + if (key in attrs) result[key] = attrs[key]; + } + return result; +} + +// --------------------------------------------------------------------------- +// Read operations +// --------------------------------------------------------------------------- + +export function imagesListWrapper(editor: Editor, input: ImagesListInput): ImagesListResult { + const allImages = collectImages(editor.state.doc); + const offset = input.offset ?? 0; + const limit = input.limit ?? allImages.length; + const items = allImages.slice(offset, offset + limit).map(buildImageSummary); + return { total: allImages.length, items }; +} + +export function imagesGetWrapper(editor: Editor, input: ImagesGetInput): ImageSummary { + const image = findImageById(editor, input.imageId); + return buildImageSummary(image); +} + +// --------------------------------------------------------------------------- +// Create image +// --------------------------------------------------------------------------- + +export function createImageWrapper( + editor: Editor, + input: CreateImageInput, + options?: MutationOptions, +): CreateImageResult { + rejectTrackedMode('create.image', options); + + if (typeof editor.commands.setImage !== 'function') { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'create.image requires the image extension (setImage command).', + ); + } + + // -- Resolve image dimensions ------------------------------------------------- + let resolvedSize = input.size; + + if (isFinitePositive(resolvedSize?.width) && isFinitePositive(resolvedSize?.height)) { + // Caller provided valid dimensions — use as-is. + } else if (input.src?.startsWith('data:')) { + const dims = readImageDimensionsFromDataUri(input.src); + if (dims) { + resolvedSize = dims; + } else { + return { + success: false, + failure: { + code: 'INVALID_INPUT', + message: + 'Image dimensions could not be determined. Provide explicit size.width and size.height, or use a data URI with a supported format (PNG, JPEG, GIF, BMP, WEBP).', + }, + }; + } + } else { + return { + success: false, + failure: { + code: 'INVALID_INPUT', + message: + 'Image dimensions are required. Provide size.width and size.height (finite positive numbers), or use a data URI src so dimensions can be inferred.', + }, + }; + } + + // -- Assign unique drawing ID ------------------------------------------------- + const drawingId = generateUniqueDocPrId(editor); + + const sdImageId = uuidv4(); + const insertPos = input.at ? resolveImageInsertPosition(editor, input.at) : null; + + if (options?.dryRun) { + return { + success: true, + image: { kind: 'inline', nodeType: 'image', nodeId: sdImageId, placement: 'inline' }, + }; + } + + const receipt = executeDomainCommand(editor, () => { + const attrs = { + src: input.src, + alt: input.alt, + title: input.title, + size: resolvedSize, + sdImageId, + id: drawingId, + }; + + if (insertPos !== null) { + // Targeted insertion — insert at the resolved position. + return Boolean(editor.commands.insertContentAt(insertPos, { type: 'image', attrs })); + } + + // No location specified — insert at current selection via setImage. + return Boolean(editor.commands.setImage(attrs)); + }); + + const commandSucceeded = receipt.steps[0]?.effect === 'changed'; + if (!commandSucceeded) { + return { success: false, failure: { code: 'INVALID_TARGET', message: 'Image could not be created.' } }; + } + + return { + success: true, + image: { kind: 'inline', nodeType: 'image', nodeId: sdImageId, placement: 'inline' }, + }; +} + +function isFinitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +// --------------------------------------------------------------------------- +// Delete image +// --------------------------------------------------------------------------- + +export function imagesDeleteWrapper( + editor: Editor, + input: ImagesDeleteInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.delete', options); + + const image = findImageById(editor, input.imageId); + + if (options?.dryRun) { + return buildSuccessResult(image); + } + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + tr.delete(pos, pos + node.nodeSize); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) { + return buildNoOpResult('Image deletion produced no change.'); + } + + return buildSuccessResult(image); +} + +// --------------------------------------------------------------------------- +// Move image +// --------------------------------------------------------------------------- + +export function imagesMoveWrapper( + editor: Editor, + input: MoveImageInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.move', options); + + const image = findImageById(editor, input.imageId); + + // Resolve target position BEFORE the mutation (and before dry-run bail-out) + // so that invalid destinations are caught even in dry-run mode. + const targetPos = resolveImageInsertPosition(editor, input.to); + + if (options?.dryRun) { + return buildSuccessResult(image); + } + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const attrs = { ...node.attrs }; + const tr = editor.state.tr; + + // Delete the source image first. + tr.delete(pos, pos + node.nodeSize); + + // Map the pre-resolved target through the delete mapping so it remains + // accurate after the deletion step shifts positions. + const mappedPos = tr.mapping.map(targetPos); + + const imageNode = editor.state.schema.nodes.image.create(attrs); + tr.insert(mappedPos, imageNode); + + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) { + return { success: false, failure: { code: 'INVALID_TARGET', message: 'Image move produced no change.' } }; + } + + // Re-resolve after move + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} + +// --------------------------------------------------------------------------- +// Convert placement +// --------------------------------------------------------------------------- + +export function imagesConvertToInlineWrapper( + editor: Editor, + input: ConvertToInlineInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.convertToInline', options); + + const image = findImageById(editor, input.imageId); + + if (image.placement === 'inline') { + return buildNoOpResult('Image is already inline.'); + } + + if (options?.dryRun) { + return buildSuccessResult(image); + } + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + isAnchor: false, + wrap: { type: 'Inline' }, + anchorData: null, + marginOffset: null, + relativeHeight: null, + }); + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) return buildNoOpResult('Convert to inline produced no change.'); + + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} + +export function imagesConvertToFloatingWrapper( + editor: Editor, + input: ConvertToFloatingInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.convertToFloating', options); + + const image = findImageById(editor, input.imageId); + + if (image.placement === 'floating') { + return buildNoOpResult('Image is already floating.'); + } + + if (options?.dryRun) { + return buildSuccessResult(image); + } + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + isAnchor: true, + wrap: { type: 'Square', attrs: {} }, + anchorData: { + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + }, + }); + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) return buildNoOpResult('Convert to floating produced no change.'); + + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} + +// --------------------------------------------------------------------------- +// Size +// --------------------------------------------------------------------------- + +export function imagesSetSizeWrapper( + editor: Editor, + input: SetSizeInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setSize', options); + + if (!isFinitePositive(input.size?.width) || !isFinitePositive(input.size?.height)) { + throw new DocumentApiAdapterError( + 'INVALID_INPUT', + 'images.setSize requires size.width and size.height as finite positive numbers.', + ); + } + + const image = findImageById(editor, input.imageId); + const currentSize = image.node.attrs.size ?? {}; + const nextSize = { + width: input.size.width, + height: input.size.height, + ...(input.size.unit !== undefined ? { unit: input.size.unit } : {}), + }; + + if ( + currentSize.width === nextSize.width && + currentSize.height === nextSize.height && + currentSize.unit === nextSize.unit + ) { + return buildNoOpResult(`Image size is already ${nextSize.width}x${nextSize.height}.`); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + size: nextSize, + }); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) return buildNoOpResult('Set image size produced no change.'); + + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} + +// --------------------------------------------------------------------------- +// Wrap type +// --------------------------------------------------------------------------- + +export function imagesSetWrapTypeWrapper( + editor: Editor, + input: SetWrapTypeInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setWrapType', options); + + const image = findImageById(editor, input.imageId); + requireFloatingPlacement(image, 'images.setWrapType'); + + const currentType = image.node.attrs.wrap?.type; + if (currentType === input.type) { + return buildNoOpResult(`Wrap type is already "${input.type}".`); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + const existingAttrs = node.attrs.wrap?.attrs ?? {}; + const filteredAttrs = filterWrapAttrs(input.type, existingAttrs); + const becomingInline = input.type === 'Inline'; + + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + wrap: { type: input.type, attrs: filteredAttrs }, + isAnchor: !becomingInline, + // When transitioning to Inline, clear floating-only fields to stay + // consistent with convertToInline and prevent stale anchor data. + ...(becomingInline ? { anchorData: null, marginOffset: null, relativeHeight: null } : {}), + }); + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) return buildNoOpResult('Set wrap type produced no change.'); + + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} + +// --------------------------------------------------------------------------- +// Wrap side +// --------------------------------------------------------------------------- + +export function imagesSetWrapSideWrapper( + editor: Editor, + input: SetWrapSideInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setWrapSide', options); + + const image = findImageById(editor, input.imageId); + requireFloatingPlacement(image, 'images.setWrapSide'); + + const currentWrapType = image.node.attrs.wrap?.type; + if (!WRAP_TYPES_SUPPORTING_SIDE.has(currentWrapType)) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `images.setWrapSide is not valid for wrap type "${currentWrapType}".`, + { wrapType: currentWrapType }, + ); + } + + const currentSide = image.node.attrs.wrap?.attrs?.wrapText; + if (currentSide === input.side) { + return buildNoOpResult(`Wrap side is already "${input.side}".`); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + wrap: { + ...node.attrs.wrap, + attrs: { ...(node.attrs.wrap?.attrs ?? {}), wrapText: input.side }, + }, + }); + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) return buildNoOpResult('Set wrap side produced no change.'); + + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} + +// --------------------------------------------------------------------------- +// Wrap distances +// --------------------------------------------------------------------------- + +export function imagesSetWrapDistancesWrapper( + editor: Editor, + input: SetWrapDistancesInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setWrapDistances', options); + + const image = findImageById(editor, input.imageId); + requireFloatingPlacement(image, 'images.setWrapDistances'); + + const currentWrapType = image.node.attrs.wrap?.type; + if (!WRAP_TYPES_SUPPORTING_DISTANCES.has(currentWrapType)) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `images.setWrapDistances is not valid for wrap type "${currentWrapType}".`, + { wrapType: currentWrapType }, + ); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + const currentAttrs = node.attrs.wrap?.attrs ?? {}; + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + wrap: { + ...node.attrs.wrap, + attrs: { ...currentAttrs, ...input.distances }, + }, + }); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) return buildNoOpResult('Set wrap distances produced no change.'); + + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} + +// --------------------------------------------------------------------------- +// Position +// --------------------------------------------------------------------------- + +export function imagesSetPositionWrapper( + editor: Editor, + input: SetPositionInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setPosition', options); + + const image = findImageById(editor, input.imageId); + requireFloatingPlacement(image, 'images.setPosition'); + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + const { position } = input; + + const newAnchorData = { + ...(node.attrs.anchorData ?? {}), + ...(position.hRelativeFrom !== undefined ? { hRelativeFrom: position.hRelativeFrom } : {}), + ...(position.vRelativeFrom !== undefined ? { vRelativeFrom: position.vRelativeFrom } : {}), + ...(position.alignH !== undefined ? { alignH: position.alignH } : {}), + ...(position.alignV !== undefined ? { alignV: position.alignV } : {}), + }; + + const newMarginOffset = position.marginOffset + ? { ...(node.attrs.marginOffset ?? {}), ...position.marginOffset } + : node.attrs.marginOffset; + + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + anchorData: newAnchorData, + marginOffset: newMarginOffset, + }); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) return buildNoOpResult('Set position produced no change.'); + + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} + +// --------------------------------------------------------------------------- +// Anchor options +// --------------------------------------------------------------------------- + +export function imagesSetAnchorOptionsWrapper( + editor: Editor, + input: SetAnchorOptionsInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setAnchorOptions', options); + + const image = findImageById(editor, input.imageId); + requireFloatingPlacement(image, 'images.setAnchorOptions'); + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + const { options: anchorOpts } = input; + + const currentOrigAttrs = node.attrs.originalAttributes ?? {}; + const updatedOrigAttrs = { + ...currentOrigAttrs, + ...(anchorOpts.behindDoc !== undefined ? { behindDoc: anchorOpts.behindDoc ? '1' : '0' } : {}), + ...(anchorOpts.allowOverlap !== undefined ? { allowOverlap: anchorOpts.allowOverlap ? '1' : '0' } : {}), + ...(anchorOpts.layoutInCell !== undefined ? { layoutInCell: anchorOpts.layoutInCell ? '1' : '0' } : {}), + ...(anchorOpts.lockAnchor !== undefined ? { locked: anchorOpts.lockAnchor ? '1' : '0' } : {}), + }; + + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + originalAttributes: updatedOrigAttrs, + ...(anchorOpts.simplePos !== undefined ? { simplePos: anchorOpts.simplePos } : {}), + }); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) return buildNoOpResult('Set anchor options produced no change.'); + + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} + +// --------------------------------------------------------------------------- +// Z-order +// --------------------------------------------------------------------------- + +export function imagesSetZOrderWrapper( + editor: Editor, + input: SetZOrderInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setZOrder', options); + + if (!isUnsignedInt32(input.zOrder?.relativeHeight)) { + throw new DocumentApiAdapterError( + 'INVALID_INPUT', + `images.setZOrder requires zOrder.relativeHeight as an unsigned 32-bit integer (${RELATIVE_HEIGHT_MIN}..${RELATIVE_HEIGHT_MAX}).`, + ); + } + + const image = findImageById(editor, input.imageId); + requireFloatingPlacement(image, 'images.setZOrder'); + + const currentHeight = image.node.attrs.relativeHeight; + if (currentHeight === input.zOrder.relativeHeight) { + return buildNoOpResult(`relativeHeight is already ${input.zOrder.relativeHeight}.`); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + relativeHeight: input.zOrder.relativeHeight, + }); + editor.dispatch(tr); + return true; + }); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) return buildNoOpResult('Set z-order produced no change.'); + + const updated = findImageById(editor, input.imageId); + return buildSuccessResult(updated); +} diff --git a/packages/super-editor/src/extensions/content-block/content-block.js b/packages/super-editor/src/extensions/content-block/content-block.js index 7e94fbed14..3a127a2cd4 100644 --- a/packages/super-editor/src/extensions/content-block/content-block.js +++ b/packages/super-editor/src/extensions/content-block/content-block.js @@ -1,6 +1,7 @@ // @ts-nocheck import { Node, Attribute } from '@core/index.js'; +import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; /** * Size configuration for content blocks @@ -115,9 +116,7 @@ export const ContentBlock = Node.create({ // Use relativeHeight from OOXML for proper z-ordering of overlapping elements const relativeHeight = attrs.originalAttributes?.relativeHeight; if (relativeHeight != null) { - // Scale down the relativeHeight value to a reasonable CSS z-index range - // OOXML uses large numbers (e.g., 251659318), we normalize to a smaller range - const zIndex = Math.floor(relativeHeight / 1000000); + const zIndex = Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE); style += `z-index: ${zIndex}; `; } else { style += 'z-index: 1; '; diff --git a/packages/super-editor/src/extensions/image/image.js b/packages/super-editor/src/extensions/image/image.js index d14d5a7589..f180065cc8 100644 --- a/packages/super-editor/src/extensions/image/image.js +++ b/packages/super-editor/src/extensions/image/image.js @@ -1,3 +1,4 @@ +import { v4 as uuidv4 } from 'uuid'; import { Attribute, Node } from '@core/index.js'; import { formatInsetClipPathTransform } from '@superdoc/contracts'; import { ImageRegistrationPlugin } from './imageHelpers/imageRegistrationPlugin.js'; @@ -88,6 +89,18 @@ export const Image = Node.create({ addAttributes() { return { + /** Stable, session-scoped image identity. Assigned on import and create. */ + sdImageId: { + default: null, + rendered: false, + }, + + /** Raw OOXML relativeHeight for z-ordering. Only meaningful for floating images. */ + relativeHeight: { + default: null, + rendered: false, + }, + src: { default: null, renderDOM: ({ src }) => { @@ -375,12 +388,10 @@ export const Image = Node.create({ switch (type) { case 'None': style += 'position: absolute;'; - // Use relativeHeight from OOXML for proper z-ordering of overlapping elements - const relativeHeight = node.attrs.originalAttributes?.relativeHeight; + // Use first-class relativeHeight attr, falling back to originalAttributes for legacy docs + const relativeHeight = node.attrs.relativeHeight ?? node.attrs.originalAttributes?.relativeHeight; if (relativeHeight != null) { - // Scale down the relativeHeight value to a reasonable CSS z-index range - // OOXML uses large numbers (e.g., 251659318), we normalize to a smaller range - const zIndex = Math.floor(relativeHeight / 1000000); + const zIndex = Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE); style += `z-index: ${zIndex};`; } else if (attrs.behindDoc) { style += 'z-index: -1;'; @@ -676,7 +687,7 @@ export const Image = Node.create({ ({ commands }) => { return commands.insertContent({ type: this.name, - attrs: options, + attrs: { ...options, sdImageId: options.sdImageId ?? uuidv4() }, }); }, diff --git a/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js b/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js index 8a774f4155..0b85757946 100644 --- a/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js +++ b/packages/super-editor/src/extensions/shape-group/ShapeGroupView.js @@ -1,6 +1,7 @@ // @ts-expect-error - preset-geometry package may not have type definitions import { getPresetShapeSvg } from '@superdoc/preset-geometry'; import { createGradient, createTextElement } from '../shared/svg-utils.js'; +import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; export class ShapeGroupView { node; @@ -128,7 +129,7 @@ export class ShapeGroupView { // Use relativeHeight from OOXML for proper z-ordering of overlapping elements const relativeHeight = originalAttributes?.relativeHeight; if (relativeHeight != null) { - const zIndex = Math.floor(relativeHeight / 1000000); + const zIndex = Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE); container.style.zIndex = zIndex.toString(); } else { container.style.zIndex = '1'; diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index 1779cef7b1..bd6d69a848 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -451,6 +451,10 @@ export interface ImageTransformData { /** Image node attributes */ export interface ImageAttrs extends ShapeNodeAttributes { + /** Stable, session-scoped image identity (UUID assigned on import / create). */ + sdImageId?: string | null; + /** Raw OOXML relativeHeight for z-ordering. Only meaningful for floating images. */ + relativeHeight?: number | null; /** Image source URL or base64 data */ src: string | null; /** Alternative text for accessibility */ diff --git a/packages/super-editor/src/extensions/vector-shape/VectorShapeView.js b/packages/super-editor/src/extensions/vector-shape/VectorShapeView.js index 2cec0d41f9..417c98a514 100644 --- a/packages/super-editor/src/extensions/vector-shape/VectorShapeView.js +++ b/packages/super-editor/src/extensions/vector-shape/VectorShapeView.js @@ -1,6 +1,7 @@ // @ts-expect-error - preset-geometry package may not have type definitions import { getPresetShapeSvg } from '@superdoc/preset-geometry'; import { inchesToPixels } from '@converter/helpers.js'; +import { OOXML_Z_INDEX_BASE } from '@extensions/shared/constants.js'; import { createGradient, createTextElement, @@ -9,13 +10,6 @@ import { generateTransforms, } from '../shared/svg-utils.js'; -/** - * Scaling factor to convert OOXML relativeHeight values to CSS z-index range. - * OOXML uses large numbers (e.g., 251659318), so we scale down by dividing by this factor. - * This ensures proper z-ordering of overlapping elements while staying within reasonable CSS limits. - */ -const Z_INDEX_SCALE_FACTOR = 1000000; - export class VectorShapeView { node; @@ -204,9 +198,7 @@ export class VectorShapeView { // Use relativeHeight from OOXML for proper z-ordering of overlapping elements const relativeHeight = originalAttributes?.relativeHeight; if (relativeHeight != null) { - // Scale down the relativeHeight value to a reasonable CSS z-index range - // OOXML uses large numbers (e.g., 251659318), we normalize to a smaller range - const zIndex = Math.floor(relativeHeight / Z_INDEX_SCALE_FACTOR); + const zIndex = Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE); style += `z-index: ${zIndex};`; } else if (wrap?.attrs?.behindDoc) { style += 'z-index: -1;'; diff --git a/tests/doc-api-stories/tests/images/all-commands.ts b/tests/doc-api-stories/tests/images/all-commands.ts new file mode 100644 index 0000000000..7343131a99 --- /dev/null +++ b/tests/doc-api-stories/tests/images/all-commands.ts @@ -0,0 +1,548 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { corpusDoc, unwrap, useStoryHarness } from '../harness'; + +// --------------------------------------------------------------------------- +// Test image — read from local assets as a data URI +// --------------------------------------------------------------------------- + +const TEST_IMAGE_PATH = path.resolve(import.meta.dirname, 'assets/test-image.webp'); +const SET_SIZE_WIDTH_PX = 321; +const SET_SIZE_HEIGHT_PX = 123; +const PX_TO_EMU = 9_525; + +async function imageDataUri(): Promise { + const buf = await readFile(TEST_IMAGE_PATH); + return `data:image/webp;base64,${buf.toString('base64')}`; +} + +/** + * Corpus document with images already embedded. The converter assigns `sdImageId` + * on import, so `images.list` / `images.get` / etc. can resolve them immediately. + */ +const IMAGE_CORPUS_DOC = corpusDoc('basic/image-wrapping.docx'); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ImageFixture = { + imageId: string; +}; + +const ALL_IMAGE_COMMAND_IDS = [ + 'create.image', + 'images.list', + 'images.get', + 'images.delete', + 'images.move', + 'images.convertToInline', + 'images.convertToFloating', + 'images.setSize', + 'images.setWrapType', + 'images.setWrapSide', + 'images.setWrapDistances', + 'images.setPosition', + 'images.setAnchorOptions', + 'images.setZOrder', +] as const; + +type ImageCommandId = (typeof ALL_IMAGE_COMMAND_IDS)[number]; + +type SetupKind = 'blank' | 'inlineImage' | 'floatingImage'; + +type Scenario = { + operationId: ImageCommandId; + setup: SetupKind; + prepare?: (sessionId: string, fixture: ImageFixture | null) => Promise; + run: (sessionId: string, fixture: ImageFixture | null) => Promise; +}; + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('document-api story: all image commands', () => { + const { client, outPath } = useStoryHarness('images/all-commands', { + preserveResults: true, + }); + + const api = client as any; + const readOperationIds = new Set(['images.list', 'images.get']); + + // -- helpers --------------------------------------------------------------- + + function makeSessionId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + } + + function docNameFor(operationId: ImageCommandId): string { + return `${operationId.replace(/\./g, '-')}.docx`; + } + + function sourceDocNameFor(operationId: ImageCommandId): string { + return `${operationId.replace(/\./g, '-')}-source.docx`; + } + + function readOutputNameFor(operationId: ImageCommandId): string { + return `${operationId.replace(/\./g, '-')}-read-output.json`; + } + + async function saveSource(sessionId: string, operationId: ImageCommandId) { + await api.doc.save({ + sessionId, + out: outPath(sourceDocNameFor(operationId)), + force: true, + }); + } + + async function saveResult(sessionId: string, operationId: ImageCommandId) { + await api.doc.save({ + sessionId, + out: outPath(docNameFor(operationId)), + force: true, + }); + } + + async function saveReadOutput(operationId: ImageCommandId, result: any) { + const payload = { operationId, output: result }; + await writeFile(outPath(readOutputNameFor(operationId)), `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + } + + function assertMutationSuccess(operationId: ImageCommandId, result: any) { + if (result?.success === true || result?.receipt?.success === true) return; + const code = result?.failure?.code ?? result?.receipt?.failure?.code ?? 'UNKNOWN'; + throw new Error(`${operationId} did not report success (code: ${code}).`); + } + + function assertReadOutput(operationId: ImageCommandId, result: any) { + if (operationId === 'images.list') { + expect(typeof result?.total).toBe('number'); + expect(Array.isArray(result?.items)).toBe(true); + expect(result.items.length).toBeGreaterThan(0); + expect(typeof result.items[0]?.sdImageId).toBe('string'); + return; + } + + if (operationId === 'images.get') { + expect(typeof result?.sdImageId).toBe('string'); + expect(result?.address).toBeDefined(); + expect(result?.properties).toBeDefined(); + return; + } + + throw new Error(`Unexpected read assertion branch for ${operationId}.`); + } + + function requireFixture(operationId: ImageCommandId, fixture: ImageFixture | null): ImageFixture { + if (!fixture) throw new Error(`${operationId} requires an image fixture.`); + return fixture; + } + + // -- fixture setup --------------------------------------------------------- + + /** + * Resolve an image by placement from a session that already has images. + * The corpus doc has both inline and floating images so we can pick the + * right one for each test scenario. + */ + async function resolveImageByPlacement(sessionId: string, placement: 'inline' | 'floating'): Promise { + const listResult = unwrap(await api.doc.images.list({ sessionId })); + const items: any[] = listResult?.items ?? []; + const match = items.find((it) => it?.address?.placement === placement); + if (match) return match.sdImageId; + + // Fallback: return first image regardless of placement + const imageId = items[0]?.sdImageId; + if (!imageId) { + throw new Error(`resolveImageByPlacement: images.list returned no images (wanted ${placement}).`); + } + return imageId; + } + + /** Open the corpus doc that has images, return the first inline image's id. */ + async function setupInlineImageFixture(sessionId: string): Promise { + await api.doc.open({ sessionId, doc: IMAGE_CORPUS_DOC }); + const imageId = await resolveImageByPlacement(sessionId, 'inline'); + return { imageId }; + } + + /** Open the corpus doc, return the first floating image's id. */ + async function setupFloatingImageFixture(sessionId: string): Promise { + await api.doc.open({ sessionId, doc: IMAGE_CORPUS_DOC }); + const imageId = await resolveImageByPlacement(sessionId, 'floating'); + return { imageId }; + } + + // -- scenarios ------------------------------------------------------------- + + const scenarios: Scenario[] = [ + { + operationId: 'create.image', + setup: 'blank', + run: async (sessionId) => { + const src = await imageDataUri(); + return unwrap( + await api.doc.create.image({ + sessionId, + src, + alt: 'butterfly logo', + at: { kind: 'documentEnd' }, + }), + ); + }, + }, + { + operationId: 'images.list', + setup: 'inlineImage', + run: async (sessionId) => { + return unwrap(await api.doc.images.list({ sessionId })); + }, + }, + { + operationId: 'images.get', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.get', fixture); + return unwrap(await api.doc.images.get({ sessionId, imageId: f.imageId })); + }, + }, + { + operationId: 'images.delete', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.delete', fixture); + return unwrap(await api.doc.images.delete({ sessionId, imageId: f.imageId })); + }, + }, + { + operationId: 'images.move', + setup: 'inlineImage', + prepare: async (sessionId) => { + const result = unwrap( + await api.doc.create.paragraph({ + sessionId, + at: { kind: 'documentEnd' }, + text: 'Paragraph below the image.', + }), + ); + if (result?.success !== true) { + throw new Error('images.move prepare: failed to create target paragraph.'); + } + }, + run: async (sessionId, fixture) => { + const f = requireFixture('images.move', fixture); + return unwrap( + await api.doc.images.move({ + sessionId, + imageId: f.imageId, + to: { kind: 'documentStart' }, + }), + ); + }, + }, + { + operationId: 'images.convertToFloating', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.convertToFloating', fixture); + return unwrap( + await api.doc.images.convertToFloating({ + sessionId, + imageId: f.imageId, + }), + ); + }, + }, + { + operationId: 'images.convertToInline', + setup: 'floatingImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.convertToInline', fixture); + return unwrap( + await api.doc.images.convertToInline({ + sessionId, + imageId: f.imageId, + }), + ); + }, + }, + { + operationId: 'images.setSize', + setup: 'blank', + run: async (sessionId) => { + const src = await imageDataUri(); + const createdResult = unwrap( + await api.doc.create.image({ + sessionId, + src, + alt: 'resizable image', + at: { kind: 'documentEnd' }, + }), + ); + if (createdResult?.success !== true) { + throw new Error('images.setSize setup: create.image did not succeed.'); + } + + const listResult = unwrap(await api.doc.images.list({ sessionId })); + const imageId = listResult?.items?.[0]?.sdImageId; + if (typeof imageId !== 'string' || imageId.length === 0) { + throw new Error('images.setSize setup: images.list did not return an image id.'); + } + return unwrap( + await api.doc.images.setSize({ + sessionId, + imageId, + size: { width: SET_SIZE_WIDTH_PX, height: SET_SIZE_HEIGHT_PX }, + }), + ); + }, + }, + { + operationId: 'images.setWrapType', + setup: 'floatingImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.setWrapType', fixture); + return unwrap( + await api.doc.images.setWrapType({ + sessionId, + imageId: f.imageId, + type: 'Tight', + }), + ); + }, + }, + { + operationId: 'images.setWrapSide', + setup: 'floatingImage', + prepare: async (sessionId, fixture) => { + const f = requireFixture('images.setWrapSide', fixture); + // setWrapSide requires a wrap type that supports side (Square/Tight/Through). + // The corpus image may already have Square — ignore no-op errors. + try { + unwrap( + await api.doc.images.setWrapType({ + sessionId, + imageId: f.imageId, + type: 'Square', + }), + ); + } catch { + /* already Square — fine */ + } + }, + run: async (sessionId, fixture) => { + const f = requireFixture('images.setWrapSide', fixture); + return unwrap( + await api.doc.images.setWrapSide({ + sessionId, + imageId: f.imageId, + side: 'left', + }), + ); + }, + }, + { + operationId: 'images.setWrapDistances', + setup: 'floatingImage', + prepare: async (sessionId, fixture) => { + const f = requireFixture('images.setWrapDistances', fixture); + // Ensure wrap type supports distances — ignore no-op errors. + try { + unwrap( + await api.doc.images.setWrapType({ + sessionId, + imageId: f.imageId, + type: 'Square', + }), + ); + } catch { + /* already Square — fine */ + } + }, + run: async (sessionId, fixture) => { + const f = requireFixture('images.setWrapDistances', fixture); + return unwrap( + await api.doc.images.setWrapDistances({ + sessionId, + imageId: f.imageId, + distances: { distTop: 100, distBottom: 100 }, + }), + ); + }, + }, + { + operationId: 'images.setPosition', + setup: 'floatingImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.setPosition', fixture); + return unwrap( + await api.doc.images.setPosition({ + sessionId, + imageId: f.imageId, + position: { hRelativeFrom: 'column', alignH: 'center' }, + }), + ); + }, + }, + { + operationId: 'images.setAnchorOptions', + setup: 'floatingImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.setAnchorOptions', fixture); + return unwrap( + await api.doc.images.setAnchorOptions({ + sessionId, + imageId: f.imageId, + options: { behindDoc: true, allowOverlap: false }, + }), + ); + }, + }, + { + operationId: 'images.setZOrder', + setup: 'floatingImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.setZOrder', fixture); + return unwrap( + await api.doc.images.setZOrder({ + sessionId, + imageId: f.imageId, + zOrder: { relativeHeight: 500 }, + }), + ); + }, + }, + ]; + + // -- coverage check -------------------------------------------------------- + + it('covers every image command currently defined on this branch', () => { + const scenarioIds = scenarios.map((scenario) => scenario.operationId); + expect(new Set(scenarioIds).size).toBe(scenarioIds.length); + expect(new Set(scenarioIds)).toEqual(new Set(ALL_IMAGE_COMMAND_IDS)); + }); + + // -- test runner ----------------------------------------------------------- + + for (const scenario of scenarios) { + it(`${scenario.operationId}: executes and saves source/result docs`, async () => { + const sessionId = makeSessionId(scenario.operationId.replace(/\./g, '-')); + + let fixture: ImageFixture | null = null; + if (scenario.setup === 'inlineImage') { + fixture = await setupInlineImageFixture(sessionId); + } else if (scenario.setup === 'floatingImage') { + fixture = await setupFloatingImageFixture(sessionId); + } else { + // blank — just open an empty doc and seed a paragraph + await api.doc.open({ sessionId }); + await api.doc.insert({ sessionId, value: 'Blank document for image test.' }); + } + + if (scenario.prepare) { + await scenario.prepare(sessionId, fixture); + } + + await saveSource(sessionId, scenario.operationId); + + const result = await scenario.run(sessionId, fixture); + + if (readOperationIds.has(scenario.operationId)) { + assertReadOutput(scenario.operationId, result); + await saveReadOutput(scenario.operationId, result); + } else { + assertMutationSuccess(scenario.operationId, result); + } + + await saveResult(sessionId, scenario.operationId); + }); + } + + // -- OOXML validity invariants ----------------------------------------------- + + it('create.image output has valid wp:extent, a:ext, and non-zero docPr IDs', async () => { + const docxPath = outPath(docNameFor('create.image')); + + // Extract word/document.xml from the saved docx + const xml = execFileSync('unzip', ['-p', docxPath, 'word/document.xml'], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + // --- wp:extent must have positive integer cx/cy --- + const extentMatches = [...xml.matchAll(/]*)\/?>/g)]; + expect(extentMatches.length).toBeGreaterThan(0); + for (const m of extentMatches) { + const attrs = m[1]; + const cx = Number(attrs.match(/cx="(\d+)"/)?.[1]); + const cy = Number(attrs.match(/cy="(\d+)"/)?.[1]); + expect(cx).toBeGreaterThan(0); + expect(cy).toBeGreaterThan(0); + expect(Number.isNaN(cx)).toBe(false); + expect(Number.isNaN(cy)).toBe(false); + } + + // --- a:ext must have positive integer cx/cy --- + const aExtMatches = [...xml.matchAll(/]*)\/?>/g)]; + expect(aExtMatches.length).toBeGreaterThan(0); + for (const m of aExtMatches) { + const attrs = m[1]; + const cx = Number(attrs.match(/cx="(\d+)"/)?.[1]); + const cy = Number(attrs.match(/cy="(\d+)"/)?.[1]); + expect(cx).toBeGreaterThan(0); + expect(cy).toBeGreaterThan(0); + expect(Number.isNaN(cx)).toBe(false); + expect(Number.isNaN(cy)).toBe(false); + } + + // --- wp:docPr must have non-zero id --- + const docPrMatches = [...xml.matchAll(/]*)\/?>/g)]; + expect(docPrMatches.length).toBeGreaterThan(0); + for (const m of docPrMatches) { + const id = Number(m[1].match(/id="(\d+)"/)?.[1]); + expect(id).toBeGreaterThan(0); + } + + // --- pic:cNvPr must have non-zero id --- + const cNvPrMatches = [...xml.matchAll(/]*)\/?>/g)]; + expect(cNvPrMatches.length).toBeGreaterThan(0); + for (const m of cNvPrMatches) { + const id = Number(m[1].match(/id="(\d+)"/)?.[1]); + expect(id).toBeGreaterThan(0); + } + }); + + it('images.setSize output contains the requested extent values in OOXML', async () => { + const docxPath = outPath(docNameFor('images.setSize')); + const xml = execFileSync('unzip', ['-p', docxPath, 'word/document.xml'], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + const expectedCx = SET_SIZE_WIDTH_PX * PX_TO_EMU; + const expectedCy = SET_SIZE_HEIGHT_PX * PX_TO_EMU; + + const wpExtents = [...xml.matchAll(/]*)\/?>/g)]; + expect(wpExtents.length).toBeGreaterThan(0); + const hasExpectedWpExtent = wpExtents.some((m) => { + const attrs = m[1]; + const cx = Number(attrs.match(/cx="(\d+)"/)?.[1]); + const cy = Number(attrs.match(/cy="(\d+)"/)?.[1]); + return cx === expectedCx && cy === expectedCy; + }); + expect(hasExpectedWpExtent).toBe(true); + + const aExtents = [...xml.matchAll(/]*)\/?>/g)]; + expect(aExtents.length).toBeGreaterThan(0); + const hasValidAExtent = aExtents.some((m) => { + const attrs = m[1]; + const cx = Number(attrs.match(/cx="(\d+)"/)?.[1]); + const cy = Number(attrs.match(/cy="(\d+)"/)?.[1]); + return cx > 0 && cy > 0; + }); + expect(hasValidAExtent).toBe(true); + }); +}); diff --git a/tests/doc-api-stories/tests/images/assets/test-image.webp b/tests/doc-api-stories/tests/images/assets/test-image.webp new file mode 100644 index 0000000000..c173359609 Binary files /dev/null and b/tests/doc-api-stories/tests/images/assets/test-image.webp differ