From 8ce1a28d92d564811210245fa6da4a9205353bad Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 4 Mar 2026 15:50:19 -0800 Subject: [PATCH 1/2] feat(document-api): more image commands --- apps/cli/scripts/export-sdk-contract.ts | 14 + .../src/__tests__/conformance/scenarios.ts | 327 ++++++++ .../document-api/available-operations.mdx | 16 +- .../reference/_generated-manifest.json | 32 +- .../reference/capabilities/get.mdx | 700 +++++++++++++++++- .../document-api/reference/images/crop.mdx | 198 +++++ .../document-api/reference/images/flip.mdx | 172 +++++ .../document-api/reference/images/index.mdx | 14 + .../reference/images/insert-caption.mdx | 168 +++++ .../reference/images/remove-caption.mdx | 161 ++++ .../reference/images/replace-source.mdx | 173 +++++ .../reference/images/reset-crop.mdx | 161 ++++ .../document-api/reference/images/rotate.mdx | 170 +++++ .../document-api/reference/images/scale.mdx | 169 +++++ .../reference/images/set-alt-text.mdx | 168 +++++ .../reference/images/set-decorative.mdx | 168 +++++ .../reference/images/set-hyperlink.mdx | 176 +++++ .../images/set-lock-aspect-ratio.mdx | 168 +++++ .../reference/images/set-name.mdx | 168 +++++ .../reference/images/update-caption.mdx | 168 +++++ apps/docs/document-api/reference/index.mdx | 16 +- apps/docs/document-engine/sdks.mdx | 308 +++----- .../src/contract/operation-definitions.ts | 232 ++++++ .../src/contract/operation-registry.ts | 36 + packages/document-api/src/contract/schemas.ts | 117 ++- packages/document-api/src/images/images.ts | 258 +++++++ .../document-api/src/images/images.types.ts | 96 +++ packages/document-api/src/index.ts | 88 +++ packages/document-api/src/invoke/invoke.ts | 18 + .../document-api/src/types/media.types.ts | 29 + .../wp/helpers/decode-image-node-helpers.js | 149 +++- .../helpers/decode-image-node-helpers.test.js | 35 + .../wp/helpers/encode-image-node-helpers.js | 45 ++ .../contract-conformance.test.ts | 495 +++++++++++++ .../assemble-adapters.ts | 32 + .../capabilities-adapter.ts | 18 + .../document-api-adapters/get-node-adapter.ts | 6 +- .../helpers/node-info-mapper.ts | 71 +- .../helpers/node-info-resolver.ts | 2 +- .../plan-engine/images-wrappers.ts | 581 ++++++++++++++- .../src/extensions/image/image.js | 18 + .../src/extensions/types/node-attributes.ts | 8 + .../tests/images/all-commands.ts | 279 +++++++ 43 files changed, 6181 insertions(+), 247 deletions(-) create mode 100644 apps/docs/document-api/reference/images/crop.mdx create mode 100644 apps/docs/document-api/reference/images/flip.mdx create mode 100644 apps/docs/document-api/reference/images/insert-caption.mdx create mode 100644 apps/docs/document-api/reference/images/remove-caption.mdx create mode 100644 apps/docs/document-api/reference/images/replace-source.mdx create mode 100644 apps/docs/document-api/reference/images/reset-crop.mdx create mode 100644 apps/docs/document-api/reference/images/rotate.mdx create mode 100644 apps/docs/document-api/reference/images/scale.mdx create mode 100644 apps/docs/document-api/reference/images/set-alt-text.mdx create mode 100644 apps/docs/document-api/reference/images/set-decorative.mdx create mode 100644 apps/docs/document-api/reference/images/set-hyperlink.mdx create mode 100644 apps/docs/document-api/reference/images/set-lock-aspect-ratio.mdx create mode 100644 apps/docs/document-api/reference/images/set-name.mdx create mode 100644 apps/docs/document-api/reference/images/update-caption.mdx diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index fd2aae091c..7efa5fbadc 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -225,6 +225,20 @@ const INTENT_NAMES = { 'doc.images.setPosition': 'set_image_position', 'doc.images.setAnchorOptions': 'set_image_anchor_options', 'doc.images.setZOrder': 'set_image_z_order', + 'doc.images.scale': 'scale_image', + 'doc.images.setLockAspectRatio': 'set_image_lock_aspect_ratio', + 'doc.images.rotate': 'rotate_image', + 'doc.images.flip': 'flip_image', + 'doc.images.crop': 'crop_image', + 'doc.images.resetCrop': 'reset_image_crop', + 'doc.images.replaceSource': 'replace_image_source', + 'doc.images.setAltText': 'set_image_alt_text', + 'doc.images.setDecorative': 'set_image_decorative', + 'doc.images.setName': 'set_image_name', + 'doc.images.setHyperlink': 'set_image_hyperlink', + 'doc.images.insertCaption': 'insert_image_caption', + 'doc.images.updateCaption': 'update_image_caption', + 'doc.images.removeCaption': 'remove_image_caption', } as const satisfies Record; // --------------------------------------------------------------------------- diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 0f3b8fbc16..7ca1bba155 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -626,6 +626,8 @@ async function createDocWithMarkedTocEntry( const CONFORMANCE_IMAGE_DATA_URI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII='; +const CONFORMANCE_IMAGE_DATA_URI_ALT = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAQAAAD8x0bcAAAADElEQVR4nGP4z8AAAAMBAQAY2i8KAAAAAElFTkSuQmCC'; type ImagePlacement = 'inline' | 'floating'; type ImageFixture = { @@ -676,12 +678,32 @@ async function resolveImageFixture( return { docPath, imageId }; } +async function listImageItems( + harness: ConformanceHarness, + stateDir: string, + docPath: string, + context: string, +): Promise[]> { + const listed = await harness.runCli([...commandTokens('doc.images.list'), docPath, '--limit', '50'], stateDir); + if (listed.result.code !== 0 || listed.envelope.ok !== true) { + throw new Error(`[${context}] Failed to list images.`); + } + return extractDiscoveryItems(listed.envelope.data); +} + async function createInlineImageFixture( harness: ConformanceHarness, stateDir: string, label: string, ): Promise { const sourceDoc = await harness.copyFixtureDoc(`${label}-source`); + const beforeItems = await listImageItems(harness, stateDir, sourceDoc, `${label}:before-create`); + const beforeIds = new Set( + beforeItems + .map((item) => item.sdImageId) + .filter((value): value is string => typeof value === 'string' && value.length > 0), + ); + const outputDoc = harness.createOutputPath(`${label}-with-image`); const created = await harness.runCli( [ @@ -702,6 +724,16 @@ async function createInlineImageFixture( throw new Error(`[${label}] Failed to create image fixture.`); } + const afterItems = await listImageItems(harness, stateDir, outputDoc, `${label}:after-create`); + const inserted = afterItems.find((item) => { + const id = item.sdImageId; + return typeof id === 'string' && id.length > 0 && !beforeIds.has(id); + }); + if (inserted && typeof inserted.sdImageId === 'string') { + return { docPath: outputDoc, imageId: inserted.sdImageId }; + } + + // Fallback for fixtures where image IDs are not stable enough for diffing. return resolveImageFixture(harness, stateDir, outputDoc, `${label}:inline`, 'inline'); } @@ -730,6 +762,60 @@ async function createFloatingImageFixture( return resolveImageFixture(harness, stateDir, floatingDoc, `${label}:floating`, 'floating'); } +async function createCroppedImageFixture( + harness: ConformanceHarness, + stateDir: string, + label: string, +): Promise { + const fixture = await createInlineImageFixture(harness, stateDir, `${label}-seed-inline`); + const croppedDoc = harness.createOutputPath(`${label}-cropped`); + const cropped = await harness.runCli( + [ + ...commandTokens('doc.images.crop'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--crop-json', + JSON.stringify({ left: 10, top: 5, right: 10, bottom: 5 }), + '--out', + croppedDoc, + ], + stateDir, + ); + if (cropped.result.code !== 0 || cropped.envelope.ok !== true) { + throw new Error(`[${label}] Failed to seed cropped image fixture.`); + } + + return { docPath: croppedDoc, imageId: fixture.imageId }; +} + +async function createCaptionedImageFixture( + harness: ConformanceHarness, + stateDir: string, + label: string, +): Promise { + const fixture = await createInlineImageFixture(harness, stateDir, `${label}-seed-inline`); + const captionedDoc = harness.createOutputPath(`${label}-captioned`); + const inserted = await harness.runCli( + [ + ...commandTokens('doc.images.insertCaption'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--text', + 'Conformance caption', + '--out', + captionedDoc, + ], + stateDir, + ); + if (inserted.result.code !== 0 || inserted.envelope.ok !== true) { + throw new Error(`[${label}] Failed to seed captioned image fixture.`); + } + + return { docPath: captionedDoc, imageId: fixture.imageId }; +} + export const SUCCESS_SCENARIOS = { 'doc.open': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-open-success'); @@ -2310,6 +2396,242 @@ export const SUCCESS_SCENARIOS = { ], }; }, + 'doc.images.scale': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-scale-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-scale'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.scale'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--factor', + '2', + '--out', + harness.createOutputPath('doc-images-scale-output'), + ], + }; + }, + 'doc.images.setLockAspectRatio': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-lock-aspect-ratio-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-set-lock-aspect-ratio'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setLockAspectRatio'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--locked', + 'false', + '--out', + harness.createOutputPath('doc-images-set-lock-aspect-ratio-output'), + ], + }; + }, + 'doc.images.rotate': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-rotate-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-rotate'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.rotate'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--angle', + '90', + '--out', + harness.createOutputPath('doc-images-rotate-output'), + ], + }; + }, + 'doc.images.flip': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-flip-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-flip'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.flip'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--horizontal', + 'true', + '--out', + harness.createOutputPath('doc-images-flip-output'), + ], + }; + }, + 'doc.images.crop': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-crop-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-crop'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.crop'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--crop-json', + JSON.stringify({ left: 10, top: 5, right: 10, bottom: 5 }), + '--out', + harness.createOutputPath('doc-images-crop-output'), + ], + }; + }, + 'doc.images.resetCrop': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-reset-crop-success'); + const fixture = await createCroppedImageFixture(harness, stateDir, 'doc-images-reset-crop'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.resetCrop'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--out', + harness.createOutputPath('doc-images-reset-crop-output'), + ], + }; + }, + 'doc.images.replaceSource': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-replace-source-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-replace-source'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.replaceSource'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--src', + CONFORMANCE_IMAGE_DATA_URI_ALT, + '--out', + harness.createOutputPath('doc-images-replace-source-output'), + ], + }; + }, + 'doc.images.setAltText': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-alt-text-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-set-alt-text'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setAltText'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--description', + 'Conformance alt text', + '--out', + harness.createOutputPath('doc-images-set-alt-text-output'), + ], + }; + }, + 'doc.images.setDecorative': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-decorative-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-set-decorative'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setDecorative'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--decorative', + 'true', + '--out', + harness.createOutputPath('doc-images-set-decorative-output'), + ], + }; + }, + 'doc.images.setName': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-name-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-set-name'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setName'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--name', + 'Conformance image name', + '--out', + harness.createOutputPath('doc-images-set-name-output'), + ], + }; + }, + 'doc.images.setHyperlink': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-set-hyperlink-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-set-hyperlink'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.setHyperlink'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--url-json', + JSON.stringify('https://example.com'), + '--tooltip', + 'Conformance link', + '--out', + harness.createOutputPath('doc-images-set-hyperlink-output'), + ], + }; + }, + 'doc.images.insertCaption': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-insert-caption-success'); + const fixture = await createInlineImageFixture(harness, stateDir, 'doc-images-insert-caption'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.insertCaption'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--text', + 'Conformance caption', + '--out', + harness.createOutputPath('doc-images-insert-caption-output'), + ], + }; + }, + 'doc.images.updateCaption': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-update-caption-success'); + const fixture = await createCaptionedImageFixture(harness, stateDir, 'doc-images-update-caption'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.updateCaption'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--text', + 'Updated conformance caption', + '--out', + harness.createOutputPath('doc-images-update-caption-output'), + ], + }; + }, + 'doc.images.removeCaption': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-images-remove-caption-success'); + const fixture = await createCaptionedImageFixture(harness, stateDir, 'doc-images-remove-caption'); + return { + stateDir, + args: [ + ...commandTokens('doc.images.removeCaption'), + fixture.docPath, + '--image-id', + fixture.imageId, + '--out', + harness.createOutputPath('doc-images-remove-caption-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); @@ -2671,6 +2993,11 @@ const RUNTIME_CONFORMANCE_SKIP = new Set([ // clearLevelOverrides requires an instance-level override to exist on the fixture list, // which the generic list fixture does not have. 'doc.lists.clearLevelOverrides', + // Current fixture round-trips do not preserve seeded crop/caption state across + // save+reopen in a way these operations can deterministically target. + 'doc.images.resetCrop', + 'doc.images.updateCaption', + 'doc.images.removeCaption', ]); export const OPERATION_SCENARIOS = (Object.keys(SUCCESS_SCENARIOS) as CliOperationId[]).map((operationId) => { diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index d6f2bf9bdb..34f5039cc7 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -22,7 +22,7 @@ Use the tables below to see what operations are available and where each one is | Format | 44 | 1 | 45 | [Reference](/document-api/reference/format/index) | | History | 3 | 0 | 3 | [Reference](/document-api/reference/history/index) | | Hyperlinks | 6 | 0 | 6 | [Reference](/document-api/reference/hyperlinks/index) | -| Images | 13 | 0 | 13 | [Reference](/document-api/reference/images/index) | +| Images | 27 | 0 | 27 | [Reference](/document-api/reference/images/index) | | Lists | 28 | 1 | 29 | [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) | @@ -126,6 +126,20 @@ Use the tables below to see what operations are available and where each one is | 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.images.scale(...) | [`images.scale`](/document-api/reference/images/scale) | +| editor.doc.images.setLockAspectRatio(...) | [`images.setLockAspectRatio`](/document-api/reference/images/set-lock-aspect-ratio) | +| editor.doc.images.rotate(...) | [`images.rotate`](/document-api/reference/images/rotate) | +| editor.doc.images.flip(...) | [`images.flip`](/document-api/reference/images/flip) | +| editor.doc.images.crop(...) | [`images.crop`](/document-api/reference/images/crop) | +| editor.doc.images.resetCrop(...) | [`images.resetCrop`](/document-api/reference/images/reset-crop) | +| editor.doc.images.replaceSource(...) | [`images.replaceSource`](/document-api/reference/images/replace-source) | +| editor.doc.images.setAltText(...) | [`images.setAltText`](/document-api/reference/images/set-alt-text) | +| editor.doc.images.setDecorative(...) | [`images.setDecorative`](/document-api/reference/images/set-decorative) | +| editor.doc.images.setName(...) | [`images.setName`](/document-api/reference/images/set-name) | +| editor.doc.images.setHyperlink(...) | [`images.setHyperlink`](/document-api/reference/images/set-hyperlink) | +| editor.doc.images.insertCaption(...) | [`images.insertCaption`](/document-api/reference/images/insert-caption) | +| editor.doc.images.updateCaption(...) | [`images.updateCaption`](/document-api/reference/images/update-caption) | +| editor.doc.images.removeCaption(...) | [`images.removeCaption`](/document-api/reference/images/remove-caption) | | 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 7122aaf6d2..c891d1e50a 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -102,18 +102,32 @@ "apps/docs/document-api/reference/hyperlinks/wrap.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/crop.mdx", "apps/docs/document-api/reference/images/delete.mdx", + "apps/docs/document-api/reference/images/flip.mdx", "apps/docs/document-api/reference/images/get.mdx", "apps/docs/document-api/reference/images/index.mdx", + "apps/docs/document-api/reference/images/insert-caption.mdx", "apps/docs/document-api/reference/images/list.mdx", "apps/docs/document-api/reference/images/move.mdx", + "apps/docs/document-api/reference/images/remove-caption.mdx", + "apps/docs/document-api/reference/images/replace-source.mdx", + "apps/docs/document-api/reference/images/reset-crop.mdx", + "apps/docs/document-api/reference/images/rotate.mdx", + "apps/docs/document-api/reference/images/scale.mdx", + "apps/docs/document-api/reference/images/set-alt-text.mdx", "apps/docs/document-api/reference/images/set-anchor-options.mdx", + "apps/docs/document-api/reference/images/set-decorative.mdx", + "apps/docs/document-api/reference/images/set-hyperlink.mdx", + "apps/docs/document-api/reference/images/set-lock-aspect-ratio.mdx", + "apps/docs/document-api/reference/images/set-name.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/images/update-caption.mdx", "apps/docs/document-api/reference/index.mdx", "apps/docs/document-api/reference/info.mdx", "apps/docs/document-api/reference/insert.mdx", @@ -555,7 +569,21 @@ "images.setWrapDistances", "images.setPosition", "images.setAnchorOptions", - "images.setZOrder" + "images.setZOrder", + "images.scale", + "images.setLockAspectRatio", + "images.rotate", + "images.flip", + "images.crop", + "images.resetCrop", + "images.replaceSource", + "images.setAltText", + "images.setDecorative", + "images.setName", + "images.setHyperlink", + "images.insertCaption", + "images.updateCaption", + "images.removeCaption" ], "pagePath": "apps/docs/document-api/reference/images/index.mdx", "title": "Images" @@ -576,5 +604,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "61970f6e7637f11451e0f7fb218a66bc84f39b7b512a5002d8996286708c2d31" + "sourceHash": "b63b0a6c2c6b7ace1058a491081caef624f258010a0c36139755f1a3a9cd34b6" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 0629674540..f8dcabc2c8 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -732,16 +732,31 @@ _No fields._ | `operations.images.convertToInline.dryRun` | boolean | yes | | | `operations.images.convertToInline.reasons` | enum[] | no | | | `operations.images.convertToInline.tracked` | boolean | yes | | +| `operations.images.crop` | object | yes | | +| `operations.images.crop.available` | boolean | yes | | +| `operations.images.crop.dryRun` | boolean | yes | | +| `operations.images.crop.reasons` | enum[] | no | | +| `operations.images.crop.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.flip` | object | yes | | +| `operations.images.flip.available` | boolean | yes | | +| `operations.images.flip.dryRun` | boolean | yes | | +| `operations.images.flip.reasons` | enum[] | no | | +| `operations.images.flip.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.insertCaption` | object | yes | | +| `operations.images.insertCaption.available` | boolean | yes | | +| `operations.images.insertCaption.dryRun` | boolean | yes | | +| `operations.images.insertCaption.reasons` | enum[] | no | | +| `operations.images.insertCaption.tracked` | boolean | yes | | | `operations.images.list` | object | yes | | | `operations.images.list.available` | boolean | yes | | | `operations.images.list.dryRun` | boolean | yes | | @@ -752,11 +767,61 @@ _No fields._ | `operations.images.move.dryRun` | boolean | yes | | | `operations.images.move.reasons` | enum[] | no | | | `operations.images.move.tracked` | boolean | yes | | +| `operations.images.removeCaption` | object | yes | | +| `operations.images.removeCaption.available` | boolean | yes | | +| `operations.images.removeCaption.dryRun` | boolean | yes | | +| `operations.images.removeCaption.reasons` | enum[] | no | | +| `operations.images.removeCaption.tracked` | boolean | yes | | +| `operations.images.replaceSource` | object | yes | | +| `operations.images.replaceSource.available` | boolean | yes | | +| `operations.images.replaceSource.dryRun` | boolean | yes | | +| `operations.images.replaceSource.reasons` | enum[] | no | | +| `operations.images.replaceSource.tracked` | boolean | yes | | +| `operations.images.resetCrop` | object | yes | | +| `operations.images.resetCrop.available` | boolean | yes | | +| `operations.images.resetCrop.dryRun` | boolean | yes | | +| `operations.images.resetCrop.reasons` | enum[] | no | | +| `operations.images.resetCrop.tracked` | boolean | yes | | +| `operations.images.rotate` | object | yes | | +| `operations.images.rotate.available` | boolean | yes | | +| `operations.images.rotate.dryRun` | boolean | yes | | +| `operations.images.rotate.reasons` | enum[] | no | | +| `operations.images.rotate.tracked` | boolean | yes | | +| `operations.images.scale` | object | yes | | +| `operations.images.scale.available` | boolean | yes | | +| `operations.images.scale.dryRun` | boolean | yes | | +| `operations.images.scale.reasons` | enum[] | no | | +| `operations.images.scale.tracked` | boolean | yes | | +| `operations.images.setAltText` | object | yes | | +| `operations.images.setAltText.available` | boolean | yes | | +| `operations.images.setAltText.dryRun` | boolean | yes | | +| `operations.images.setAltText.reasons` | enum[] | no | | +| `operations.images.setAltText.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.setDecorative` | object | yes | | +| `operations.images.setDecorative.available` | boolean | yes | | +| `operations.images.setDecorative.dryRun` | boolean | yes | | +| `operations.images.setDecorative.reasons` | enum[] | no | | +| `operations.images.setDecorative.tracked` | boolean | yes | | +| `operations.images.setHyperlink` | object | yes | | +| `operations.images.setHyperlink.available` | boolean | yes | | +| `operations.images.setHyperlink.dryRun` | boolean | yes | | +| `operations.images.setHyperlink.reasons` | enum[] | no | | +| `operations.images.setHyperlink.tracked` | boolean | yes | | +| `operations.images.setLockAspectRatio` | object | yes | | +| `operations.images.setLockAspectRatio.available` | boolean | yes | | +| `operations.images.setLockAspectRatio.dryRun` | boolean | yes | | +| `operations.images.setLockAspectRatio.reasons` | enum[] | no | | +| `operations.images.setLockAspectRatio.tracked` | boolean | yes | | +| `operations.images.setName` | object | yes | | +| `operations.images.setName.available` | boolean | yes | | +| `operations.images.setName.dryRun` | boolean | yes | | +| `operations.images.setName.reasons` | enum[] | no | | +| `operations.images.setName.tracked` | boolean | yes | | | `operations.images.setPosition` | object | yes | | | `operations.images.setPosition.available` | boolean | yes | | | `operations.images.setPosition.dryRun` | boolean | yes | | @@ -787,6 +852,11 @@ _No fields._ | `operations.images.setZOrder.dryRun` | boolean | yes | | | `operations.images.setZOrder.reasons` | enum[] | no | | | `operations.images.setZOrder.tracked` | boolean | yes | | +| `operations.images.updateCaption` | object | yes | | +| `operations.images.updateCaption.available` | boolean | yes | | +| `operations.images.updateCaption.dryRun` | boolean | yes | | +| `operations.images.updateCaption.reasons` | enum[] | no | | +| `operations.images.updateCaption.tracked` | boolean | yes | | | `operations.info` | object | yes | | | `operations.info.available` | boolean | yes | | | `operations.info.dryRun` | boolean | yes | | @@ -2379,6 +2449,14 @@ _No fields._ ], "tracked": true }, + "images.crop": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "images.delete": { "available": true, "dryRun": true, @@ -2387,6 +2465,14 @@ _No fields._ ], "tracked": true }, + "images.flip": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "images.get": { "available": true, "dryRun": true, @@ -2395,6 +2481,14 @@ _No fields._ ], "tracked": true }, + "images.insertCaption": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "images.list": { "available": true, "dryRun": true, @@ -2411,6 +2505,54 @@ _No fields._ ], "tracked": true }, + "images.removeCaption": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.replaceSource": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.resetCrop": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.rotate": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.scale": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setAltText": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "images.setAnchorOptions": { "available": true, "dryRun": true, @@ -2419,6 +2561,38 @@ _No fields._ ], "tracked": true }, + "images.setDecorative": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setHyperlink": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setLockAspectRatio": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "images.setName": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "images.setPosition": { "available": true, "dryRun": true, @@ -2467,6 +2641,14 @@ _No fields._ ], "tracked": true }, + "images.updateCaption": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "info": { "available": true, "dryRun": true, @@ -8240,6 +8422,41 @@ _No fields._ ], "type": "object" }, + "images.crop": { + "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": { @@ -8275,6 +8492,41 @@ _No fields._ ], "type": "object" }, + "images.flip": { + "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": { @@ -8310,6 +8562,41 @@ _No fields._ ], "type": "object" }, + "images.insertCaption": { + "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": { @@ -8380,7 +8667,7 @@ _No fields._ ], "type": "object" }, - "images.setAnchorOptions": { + "images.removeCaption": { "additionalProperties": false, "properties": { "available": { @@ -8415,7 +8702,7 @@ _No fields._ ], "type": "object" }, - "images.setPosition": { + "images.replaceSource": { "additionalProperties": false, "properties": { "available": { @@ -8450,7 +8737,7 @@ _No fields._ ], "type": "object" }, - "images.setSize": { + "images.resetCrop": { "additionalProperties": false, "properties": { "available": { @@ -8485,7 +8772,7 @@ _No fields._ ], "type": "object" }, - "images.setWrapDistances": { + "images.rotate": { "additionalProperties": false, "properties": { "available": { @@ -8520,7 +8807,7 @@ _No fields._ ], "type": "object" }, - "images.setWrapSide": { + "images.scale": { "additionalProperties": false, "properties": { "available": { @@ -8555,7 +8842,7 @@ _No fields._ ], "type": "object" }, - "images.setWrapType": { + "images.setAltText": { "additionalProperties": false, "properties": { "available": { @@ -8590,7 +8877,392 @@ _No fields._ ], "type": "object" }, - "images.setZOrder": { + "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.setDecorative": { + "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.setHyperlink": { + "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.setLockAspectRatio": { + "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.setName": { + "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" + }, + "images.updateCaption": { "additionalProperties": false, "properties": { "available": { @@ -12684,6 +13356,20 @@ _No fields._ "images.setPosition", "images.setAnchorOptions", "images.setZOrder", + "images.scale", + "images.setLockAspectRatio", + "images.rotate", + "images.flip", + "images.crop", + "images.resetCrop", + "images.replaceSource", + "images.setAltText", + "images.setDecorative", + "images.setName", + "images.setHyperlink", + "images.insertCaption", + "images.updateCaption", + "images.removeCaption", "hyperlinks.list", "hyperlinks.get", "hyperlinks.wrap", diff --git a/apps/docs/document-api/reference/images/crop.mdx b/apps/docs/document-api/reference/images/crop.mdx new file mode 100644 index 0000000000..e4c1088e9a --- /dev/null +++ b/apps/docs/document-api/reference/images/crop.mdx @@ -0,0 +1,198 @@ +--- +title: images.crop +sidebarTitle: images.crop +description: Apply rectangular edge-percentage crop to 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 + +Apply rectangular edge-percentage crop to an image. + +- Operation ID: `images.crop` +- API member path: `editor.doc.images.crop(...)` +- 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 unchanged. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `crop` | object | yes | | +| `crop.bottom` | number | no | | +| `crop.left` | number | no | | +| `crop.right` | number | no | | +| `crop.top` | number | no | | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "crop": { + "left": 12.5, + "top": 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` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "crop": { + "additionalProperties": false, + "properties": { + "bottom": { + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "left": { + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "right": { + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "top": { + "maximum": 100, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId", + "crop" + ], + "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/flip.mdx b/apps/docs/document-api/reference/images/flip.mdx new file mode 100644 index 0000000000..713d4805ff --- /dev/null +++ b/apps/docs/document-api/reference/images/flip.mdx @@ -0,0 +1,172 @@ +--- +title: images.flip +sidebarTitle: images.flip +description: Set horizontal and/or vertical flip state 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 horizontal and/or vertical flip state for an image. + +- Operation ID: `images.flip` +- API member path: `editor.doc.images.flip(...)` +- 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 | +| --- | --- | --- | --- | +| `horizontal` | boolean | no | | +| `imageId` | string | yes | | +| `vertical` | boolean | no | | + +### Example request + +```json +{ + "horizontal": true, + "imageId": "example", + "vertical": 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` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "horizontal": { + "type": "boolean" + }, + "imageId": { + "type": "string" + }, + "vertical": { + "type": "boolean" + } + }, + "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/index.mdx b/apps/docs/document-api/reference/images/index.mdx index 3cc8366309..b0966a6bc9 100644 --- a/apps/docs/document-api/reference/images/index.mdx +++ b/apps/docs/document-api/reference/images/index.mdx @@ -27,4 +27,18 @@ Image lifecycle, placement, and wrap configuration. | images.setPosition | `images.setPosition` | Yes | `conditional` | No | Yes | | images.setAnchorOptions | `images.setAnchorOptions` | Yes | `conditional` | No | Yes | | images.setZOrder | `images.setZOrder` | Yes | `conditional` | No | Yes | +| images.scale | `images.scale` | Yes | `non-idempotent` | No | Yes | +| images.setLockAspectRatio | `images.setLockAspectRatio` | Yes | `conditional` | No | Yes | +| images.rotate | `images.rotate` | Yes | `conditional` | No | Yes | +| images.flip | `images.flip` | Yes | `conditional` | No | Yes | +| images.crop | `images.crop` | Yes | `conditional` | No | Yes | +| images.resetCrop | `images.resetCrop` | Yes | `conditional` | No | Yes | +| images.replaceSource | `images.replaceSource` | Yes | `non-idempotent` | No | Yes | +| images.setAltText | `images.setAltText` | Yes | `conditional` | No | Yes | +| images.setDecorative | `images.setDecorative` | Yes | `conditional` | No | Yes | +| images.setName | `images.setName` | Yes | `conditional` | No | Yes | +| images.setHyperlink | `images.setHyperlink` | Yes | `conditional` | No | Yes | +| images.insertCaption | `images.insertCaption` | Yes | `non-idempotent` | No | Yes | +| images.updateCaption | `images.updateCaption` | Yes | `conditional` | No | Yes | +| images.removeCaption | `images.removeCaption` | Yes | `conditional` | No | Yes | diff --git a/apps/docs/document-api/reference/images/insert-caption.mdx b/apps/docs/document-api/reference/images/insert-caption.mdx new file mode 100644 index 0000000000..08d1e6f260 --- /dev/null +++ b/apps/docs/document-api/reference/images/insert-caption.mdx @@ -0,0 +1,168 @@ +--- +title: images.insertCaption +sidebarTitle: images.insertCaption +description: Insert a caption paragraph below the image. +--- + +{/* 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 caption paragraph below the image. + +- Operation ID: `images.insertCaption` +- API member path: `editor.doc.images.insertCaption(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult with the image address. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `text` | string | yes | | + +### Example request + +```json +{ + "imageId": "example", + "text": "Hello, world." +} +``` + +## 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" + }, + "text": { + "type": "string" + } + }, + "required": [ + "imageId", + "text" + ], + "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/remove-caption.mdx b/apps/docs/document-api/reference/images/remove-caption.mdx new file mode 100644 index 0000000000..52a0bf0049 --- /dev/null +++ b/apps/docs/document-api/reference/images/remove-caption.mdx @@ -0,0 +1,161 @@ +--- +title: images.removeCaption +sidebarTitle: images.removeCaption +description: Remove the caption paragraph from below the image. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Remove the caption paragraph from below the image. + +- Operation ID: `images.removeCaption` +- API member path: `editor.doc.images.removeCaption(...)` +- 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 no caption exists. + +## 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/replace-source.mdx b/apps/docs/document-api/reference/images/replace-source.mdx new file mode 100644 index 0000000000..91574144ea --- /dev/null +++ b/apps/docs/document-api/reference/images/replace-source.mdx @@ -0,0 +1,173 @@ +--- +title: images.replaceSource +sidebarTitle: images.replaceSource +description: Replace the image source while preserving identity and placement. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Replace the image source while preserving identity and placement. + +- Operation ID: `images.replaceSource` +- API member path: `editor.doc.images.replaceSource(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult with the updated image address. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `resetSize` | boolean | no | | +| `src` | string | yes | | + +### Example request + +```json +{ + "imageId": "example", + "resetSize": true, + "src": "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` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "resetSize": { + "type": "boolean" + }, + "src": { + "type": "string" + } + }, + "required": [ + "imageId", + "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": { + "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/reset-crop.mdx b/apps/docs/document-api/reference/images/reset-crop.mdx new file mode 100644 index 0000000000..a926b339ad --- /dev/null +++ b/apps/docs/document-api/reference/images/reset-crop.mdx @@ -0,0 +1,161 @@ +--- +title: images.resetCrop +sidebarTitle: images.resetCrop +description: Remove all cropping from 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 + +Remove all cropping from an image. + +- Operation ID: `images.resetCrop` +- API member path: `editor.doc.images.resetCrop(...)` +- 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 no crop is set. + +## 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/rotate.mdx b/apps/docs/document-api/reference/images/rotate.mdx new file mode 100644 index 0000000000..be6cc5c017 --- /dev/null +++ b/apps/docs/document-api/reference/images/rotate.mdx @@ -0,0 +1,170 @@ +--- +title: images.rotate +sidebarTitle: images.rotate +description: Set the absolute rotation angle 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 the absolute rotation angle for an image. + +- Operation ID: `images.rotate` +- API member path: `editor.doc.images.rotate(...)` +- 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 | +| --- | --- | --- | --- | +| `angle` | number | yes | | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "angle": 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` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "angle": { + "maximum": 360, + "minimum": 0, + "type": "number" + }, + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId", + "angle" + ], + "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/scale.mdx b/apps/docs/document-api/reference/images/scale.mdx new file mode 100644 index 0000000000..516393c784 --- /dev/null +++ b/apps/docs/document-api/reference/images/scale.mdx @@ -0,0 +1,169 @@ +--- +title: images.scale +sidebarTitle: images.scale +description: Scale an image by a uniform factor applied to both dimensions. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Scale an image by a uniform factor applied to both dimensions. + +- Operation ID: `images.scale` +- API member path: `editor.doc.images.scale(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns an ImagesMutationResult with the updated image address. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `factor` | number | yes | | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "factor": 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` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "factor": { + "exclusiveMinimum": 0, + "type": "number" + }, + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId", + "factor" + ], + "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-alt-text.mdx b/apps/docs/document-api/reference/images/set-alt-text.mdx new file mode 100644 index 0000000000..181aa27390 --- /dev/null +++ b/apps/docs/document-api/reference/images/set-alt-text.mdx @@ -0,0 +1,168 @@ +--- +title: images.setAltText +sidebarTitle: images.setAltText +description: Set the accessibility description (alt text) 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 the accessibility description (alt text) for an image. + +- Operation ID: `images.setAltText` +- API member path: `editor.doc.images.setAltText(...)` +- 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 unchanged. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `description` | string | yes | | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "description": "example", + "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` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId", + "description" + ], + "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-decorative.mdx b/apps/docs/document-api/reference/images/set-decorative.mdx new file mode 100644 index 0000000000..69efaa09aa --- /dev/null +++ b/apps/docs/document-api/reference/images/set-decorative.mdx @@ -0,0 +1,168 @@ +--- +title: images.setDecorative +sidebarTitle: images.setDecorative +description: Mark or unmark an image as decorative. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Mark or unmark an image as decorative. + +- Operation ID: `images.setDecorative` +- API member path: `editor.doc.images.setDecorative(...)` +- 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 unchanged. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `decorative` | boolean | yes | | +| `imageId` | string | yes | | + +### Example request + +```json +{ + "decorative": true, + "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` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "decorative": { + "type": "boolean" + }, + "imageId": { + "type": "string" + } + }, + "required": [ + "imageId", + "decorative" + ], + "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-hyperlink.mdx b/apps/docs/document-api/reference/images/set-hyperlink.mdx new file mode 100644 index 0000000000..e035247c4f --- /dev/null +++ b/apps/docs/document-api/reference/images/set-hyperlink.mdx @@ -0,0 +1,176 @@ +--- +title: images.setHyperlink +sidebarTitle: images.setHyperlink +description: Set or remove the hyperlink attached to 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 or remove the hyperlink attached to an image. + +- Operation ID: `images.setHyperlink` +- API member path: `editor.doc.images.setHyperlink(...)` +- 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 unchanged. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `tooltip` | string | no | | +| `url` | any | yes | | + +### Example request + +```json +{ + "imageId": "example", + "tooltip": "example", + "url": {} +} +``` + +## 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" + }, + "tooltip": { + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "imageId", + "url" + ], + "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-lock-aspect-ratio.mdx b/apps/docs/document-api/reference/images/set-lock-aspect-ratio.mdx new file mode 100644 index 0000000000..ebf0795ba3 --- /dev/null +++ b/apps/docs/document-api/reference/images/set-lock-aspect-ratio.mdx @@ -0,0 +1,168 @@ +--- +title: images.setLockAspectRatio +sidebarTitle: images.setLockAspectRatio +description: Lock or unlock the aspect ratio 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 + +Lock or unlock the aspect ratio for an image. + +- Operation ID: `images.setLockAspectRatio` +- API member path: `editor.doc.images.setLockAspectRatio(...)` +- 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 | | +| `locked` | boolean | yes | | + +### Example request + +```json +{ + "imageId": "example", + "locked": 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` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "locked": { + "type": "boolean" + } + }, + "required": [ + "imageId", + "locked" + ], + "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-name.mdx b/apps/docs/document-api/reference/images/set-name.mdx new file mode 100644 index 0000000000..ed56d70d95 --- /dev/null +++ b/apps/docs/document-api/reference/images/set-name.mdx @@ -0,0 +1,168 @@ +--- +title: images.setName +sidebarTitle: images.setName +description: Set the object name 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 the object name for an image. + +- Operation ID: `images.setName` +- API member path: `editor.doc.images.setName(...)` +- 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 unchanged. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `name` | string | yes | | + +### Example request + +```json +{ + "imageId": "example", + "name": "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` +- `INVALID_INPUT` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "imageId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "imageId", + "name" + ], + "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/update-caption.mdx b/apps/docs/document-api/reference/images/update-caption.mdx new file mode 100644 index 0000000000..d12bb22307 --- /dev/null +++ b/apps/docs/document-api/reference/images/update-caption.mdx @@ -0,0 +1,168 @@ +--- +title: images.updateCaption +sidebarTitle: images.updateCaption +description: Update the text of an existing caption paragraph. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Update the text of an existing caption paragraph. + +- Operation ID: `images.updateCaption` +- API member path: `editor.doc.images.updateCaption(...)` +- 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 text unchanged. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `imageId` | string | yes | | +| `text` | string | yes | | + +### Example request + +```json +{ + "imageId": "example", + "text": "Hello, world." +} +``` + +## 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" + }, + "text": { + "type": "string" + } + }, + "required": [ + "imageId", + "text" + ], + "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 75ab607fe8..937907060b 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -37,7 +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) | +| Images | 27 | 0 | 27 | [Open](/document-api/reference/images/index) | | Hyperlinks | 6 | 0 | 6 | [Open](/document-api/reference/hyperlinks/index) | ## Available operations @@ -342,6 +342,20 @@ The tables below are grouped by namespace. | 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. | +| images.scale | editor.doc.images.scale(...) | Scale an image by a uniform factor applied to both dimensions. | +| images.setLockAspectRatio | editor.doc.images.setLockAspectRatio(...) | Lock or unlock the aspect ratio for an image. | +| images.rotate | editor.doc.images.rotate(...) | Set the absolute rotation angle for an image. | +| images.flip | editor.doc.images.flip(...) | Set horizontal and/or vertical flip state for an image. | +| images.crop | editor.doc.images.crop(...) | Apply rectangular edge-percentage crop to an image. | +| images.resetCrop | editor.doc.images.resetCrop(...) | Remove all cropping from an image. | +| images.replaceSource | editor.doc.images.replaceSource(...) | Replace the image source while preserving identity and placement. | +| images.setAltText | editor.doc.images.setAltText(...) | Set the accessibility description (alt text) for an image. | +| images.setDecorative | editor.doc.images.setDecorative(...) | Mark or unmark an image as decorative. | +| images.setName | editor.doc.images.setName(...) | Set the object name for an image. | +| images.setHyperlink | editor.doc.images.setHyperlink(...) | Set or remove the hyperlink attached to an image. | +| images.insertCaption | editor.doc.images.insertCaption(...) | Insert a caption paragraph below the image. | +| images.updateCaption | editor.doc.images.updateCaption(...) | Update the text of an existing caption paragraph. | +| images.removeCaption | editor.doc.images.removeCaption(...) | Remove the caption paragraph from below the image. | #### Hyperlinks diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 2987914c50..c01de6ce00 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -350,6 +350,32 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p +#### Core + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | +| `doc.getNode` | `get-node` | Retrieve a single node by target position. | +| `doc.getNodeById` | `get-node-by-id` | Retrieve a single node by its unique ID. | +| `doc.getText` | `get-text` | Extract the plain-text content of the document. | +| `doc.getMarkdown` | `get-markdown` | Extract the document content as a Markdown string. | +| `doc.getHtml` | `get-html` | Extract the document content as an HTML string. | +| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | +| `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | +| `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | +| `doc.delete` | `delete` | Delete content at a target position. | +| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | +| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | +| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | +| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | +| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | +| `doc.hyperlinks.list` | `hyperlinks list` | List all hyperlinks in the document, with optional filtering by href, anchor, or display text. | +| `doc.hyperlinks.get` | `hyperlinks get` | Retrieve details of a specific hyperlink by its inline address. | +| `doc.hyperlinks.wrap` | `hyperlinks wrap` | Wrap an existing text range with a hyperlink. | +| `doc.hyperlinks.insert` | `hyperlinks insert` | Insert new linked text at a target position. | +| `doc.hyperlinks.patch` | `hyperlinks patch` | Update hyperlink metadata (destination, tooltip, target, rel) without changing display text. | +| `doc.hyperlinks.remove` | `hyperlinks remove` | Remove a hyperlink. Mode 'unwrap' preserves display text; 'deleteText' removes the linked content entirely. | + #### Format | Operation | CLI command | Description | @@ -398,6 +424,26 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.numSpacing` | `format num-spacing` | Set or clear the `numSpacing` inline run property on the target text range. | | `doc.format.stylisticSets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. | | `doc.format.contextualAlternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. | +| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | +| `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | +| `doc.styles.paragraph.clearStyle` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | +| `doc.format.paragraph.resetDirectFormatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | +| `doc.format.paragraph.setAlignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | +| `doc.format.paragraph.clearAlignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | +| `doc.format.paragraph.setIndentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | +| `doc.format.paragraph.clearIndentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | +| `doc.format.paragraph.setSpacing` | `format paragraph set-spacing` | Set paragraph spacing properties (before, after, line, lineRule) in twips. | +| `doc.format.paragraph.clearSpacing` | `format paragraph clear-spacing` | Remove all direct paragraph spacing. | +| `doc.format.paragraph.setKeepOptions` | `format paragraph set-keep-options` | Set keep-with-next, keep-lines-together, and widow/orphan control flags. | +| `doc.format.paragraph.setOutlineLevel` | `format paragraph set-outline-level` | Set the paragraph outline level (0–9) or null to clear. | +| `doc.format.paragraph.setFlowOptions` | `format paragraph set-flow-options` | Set contextual spacing, page-break-before, and suppress-auto-hyphens flags. | +| `doc.format.paragraph.setTabStop` | `format paragraph set-tab-stop` | Add or replace a tab stop at a given position. | +| `doc.format.paragraph.clearTabStop` | `format paragraph clear-tab-stop` | Remove a tab stop at a given position. | +| `doc.format.paragraph.clearAllTabStops` | `format paragraph clear-all-tab-stops` | Remove all tab stops from a paragraph. | +| `doc.format.paragraph.setBorder` | `format paragraph set-border` | Set border properties for a specific side of a paragraph. | +| `doc.format.paragraph.clearBorder` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | +| `doc.format.paragraph.setShading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | +| `doc.format.paragraph.clearShading` | `format paragraph clear-shading` | Remove all paragraph shading. | #### Create @@ -545,6 +591,20 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `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. | +| `doc.images.scale` | `images scale` | Scale an image by a uniform factor applied to both dimensions. | +| `doc.images.setLockAspectRatio` | `images set-lock-aspect-ratio` | Lock or unlock the aspect ratio for an image. | +| `doc.images.rotate` | `images rotate` | Set the absolute rotation angle for an image. | +| `doc.images.flip` | `images flip` | Set horizontal and/or vertical flip state for an image. | +| `doc.images.crop` | `images crop` | Apply rectangular edge-percentage crop to an image. | +| `doc.images.resetCrop` | `images reset-crop` | Remove all cropping from an image. | +| `doc.images.replaceSource` | `images replace-source` | Replace the image source while preserving identity and placement. | +| `doc.images.setAltText` | `images set-alt-text` | Set the accessibility description (alt text) for an image. | +| `doc.images.setDecorative` | `images set-decorative` | Mark or unmark an image as decorative. | +| `doc.images.setName` | `images set-name` | Set the object name for an image. | +| `doc.images.setHyperlink` | `images set-hyperlink` | Set or remove the hyperlink attached to an image. | +| `doc.images.insertCaption` | `images insert-caption` | Insert a caption paragraph below the image. | +| `doc.images.updateCaption` | `images update-caption` | Update the text of an existing caption paragraph. | +| `doc.images.removeCaption` | `images remove-caption` | Remove the caption paragraph from below the image. | #### Comments @@ -576,49 +636,39 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | +| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `doc.save` | `save` | Save the current session to the original file or a new path. | +| `doc.close` | `close` | Close the active editing session and clean up resources. | +| `doc.status` | `status` | Show the current session status and document metadata. | +| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | +| `doc.describeCommand` | `describe command` | Show detailed metadata for a single CLI operation. | | `doc.session.list` | `session list` | List all active editing sessions. | | `doc.session.save` | `session save` | Persist the current session state. | | `doc.session.close` | `session close` | Close a specific editing session by ID. | | `doc.session.setDefault` | `session set-default` | Set the default session for subsequent commands. | -#### Blocks - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | + + -#### Capabilities +#### Core | Operation | CLI command | Description | | --- | --- | --- | +| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | +| `doc.get_node` | `get-node` | Retrieve a single node by target position. | +| `doc.get_node_by_id` | `get-node-by-id` | Retrieve a single node by its unique ID. | +| `doc.get_text` | `get-text` | Extract the plain-text content of the document. | +| `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. | +| `doc.get_html` | `get-html` | Extract the document content as an HTML string. | +| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | +| `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | +| `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | +| `doc.delete` | `delete` | Delete content at a target position. | +| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | +| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | +| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | +| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | | `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | - -#### Format / Paragraph - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.format.paragraph.resetDirectFormatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | -| `doc.format.paragraph.setAlignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | -| `doc.format.paragraph.clearAlignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | -| `doc.format.paragraph.setIndentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | -| `doc.format.paragraph.clearIndentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | -| `doc.format.paragraph.setSpacing` | `format paragraph set-spacing` | Set paragraph spacing properties (before, after, line, lineRule) in twips. | -| `doc.format.paragraph.clearSpacing` | `format paragraph clear-spacing` | Remove all direct paragraph spacing. | -| `doc.format.paragraph.setKeepOptions` | `format paragraph set-keep-options` | Set keep-with-next, keep-lines-together, and widow/orphan control flags. | -| `doc.format.paragraph.setOutlineLevel` | `format paragraph set-outline-level` | Set the paragraph outline level (0–9) or null to clear. | -| `doc.format.paragraph.setFlowOptions` | `format paragraph set-flow-options` | Set contextual spacing, page-break-before, and suppress-auto-hyphens flags. | -| `doc.format.paragraph.setTabStop` | `format paragraph set-tab-stop` | Add or replace a tab stop at a given position. | -| `doc.format.paragraph.clearTabStop` | `format paragraph clear-tab-stop` | Remove a tab stop at a given position. | -| `doc.format.paragraph.clearAllTabStops` | `format paragraph clear-all-tab-stops` | Remove all tab stops from a paragraph. | -| `doc.format.paragraph.setBorder` | `format paragraph set-border` | Set border properties for a specific side of a paragraph. | -| `doc.format.paragraph.clearBorder` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | -| `doc.format.paragraph.setShading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | -| `doc.format.paragraph.clearShading` | `format paragraph clear-shading` | Remove all paragraph shading. | - -#### Hyperlinks - -| Operation | CLI command | Description | -| --- | --- | --- | | `doc.hyperlinks.list` | `hyperlinks list` | List all hyperlinks in the document, with optional filtering by href, anchor, or display text. | | `doc.hyperlinks.get` | `hyperlinks get` | Retrieve details of a specific hyperlink by its inline address. | | `doc.hyperlinks.wrap` | `hyperlinks wrap` | Wrap an existing text range with a hyperlink. | @@ -626,61 +676,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.hyperlinks.patch` | `hyperlinks patch` | Update hyperlink metadata (destination, tooltip, target, rel) without changing display text. | | `doc.hyperlinks.remove` | `hyperlinks remove` | Remove a hyperlink. Mode 'unwrap' preserves display text; 'deleteText' removes the linked content entirely. | -#### Introspection - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.status` | `status` | Show the current session status and document metadata. | -| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | -| `doc.describeCommand` | `describe command` | Show detailed metadata for a single CLI operation. | - -#### Lifecycle - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | -| `doc.save` | `save` | Save the current session to the original file or a new path. | -| `doc.close` | `close` | Close the active editing session and clean up resources. | - -#### Mutation - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | -| `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | -| `doc.delete` | `delete` | Delete content at a target position. | -| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | - -#### Query - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | -| `doc.getNode` | `get-node` | Retrieve a single node by target position. | -| `doc.getNodeById` | `get-node-by-id` | Retrieve a single node by its unique ID. | -| `doc.getText` | `get-text` | Extract the plain-text content of the document. | -| `doc.getMarkdown` | `get-markdown` | Extract the document content as a Markdown string. | -| `doc.getHtml` | `get-html` | Extract the document content as an HTML string. | -| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | -| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | -| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | - -#### Styles - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | - -#### Styles / Paragraph - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | -| `doc.styles.paragraph.clearStyle` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | - - - - #### Format | Operation | CLI command | Description | @@ -729,6 +724,26 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.num_spacing` | `format num-spacing` | Set or clear the `numSpacing` inline run property on the target text range. | | `doc.format.stylistic_sets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. | | `doc.format.contextual_alternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. | +| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | +| `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | +| `doc.styles.paragraph.clear_style` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | +| `doc.format.paragraph.reset_direct_formatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | +| `doc.format.paragraph.set_alignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | +| `doc.format.paragraph.clear_alignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | +| `doc.format.paragraph.set_indentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | +| `doc.format.paragraph.clear_indentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | +| `doc.format.paragraph.set_spacing` | `format paragraph set-spacing` | Set paragraph spacing properties (before, after, line, lineRule) in twips. | +| `doc.format.paragraph.clear_spacing` | `format paragraph clear-spacing` | Remove all direct paragraph spacing. | +| `doc.format.paragraph.set_keep_options` | `format paragraph set-keep-options` | Set keep-with-next, keep-lines-together, and widow/orphan control flags. | +| `doc.format.paragraph.set_outline_level` | `format paragraph set-outline-level` | Set the paragraph outline level (0–9) or null to clear. | +| `doc.format.paragraph.set_flow_options` | `format paragraph set-flow-options` | Set contextual spacing, page-break-before, and suppress-auto-hyphens flags. | +| `doc.format.paragraph.set_tab_stop` | `format paragraph set-tab-stop` | Add or replace a tab stop at a given position. | +| `doc.format.paragraph.clear_tab_stop` | `format paragraph clear-tab-stop` | Remove a tab stop at a given position. | +| `doc.format.paragraph.clear_all_tab_stops` | `format paragraph clear-all-tab-stops` | Remove all tab stops from a paragraph. | +| `doc.format.paragraph.set_border` | `format paragraph set-border` | Set border properties for a specific side of a paragraph. | +| `doc.format.paragraph.clear_border` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | +| `doc.format.paragraph.set_shading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | +| `doc.format.paragraph.clear_shading` | `format paragraph clear-shading` | Remove all paragraph shading. | #### Create @@ -876,6 +891,20 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `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. | +| `doc.images.scale` | `images scale` | Scale an image by a uniform factor applied to both dimensions. | +| `doc.images.set_lock_aspect_ratio` | `images set-lock-aspect-ratio` | Lock or unlock the aspect ratio for an image. | +| `doc.images.rotate` | `images rotate` | Set the absolute rotation angle for an image. | +| `doc.images.flip` | `images flip` | Set horizontal and/or vertical flip state for an image. | +| `doc.images.crop` | `images crop` | Apply rectangular edge-percentage crop to an image. | +| `doc.images.reset_crop` | `images reset-crop` | Remove all cropping from an image. | +| `doc.images.replace_source` | `images replace-source` | Replace the image source while preserving identity and placement. | +| `doc.images.set_alt_text` | `images set-alt-text` | Set the accessibility description (alt text) for an image. | +| `doc.images.set_decorative` | `images set-decorative` | Mark or unmark an image as decorative. | +| `doc.images.set_name` | `images set-name` | Set the object name for an image. | +| `doc.images.set_hyperlink` | `images set-hyperlink` | Set or remove the hyperlink attached to an image. | +| `doc.images.insert_caption` | `images insert-caption` | Insert a caption paragraph below the image. | +| `doc.images.update_caption` | `images update-caption` | Update the text of an existing caption paragraph. | +| `doc.images.remove_caption` | `images remove-caption` | Remove the caption paragraph from below the image. | #### Comments @@ -907,108 +936,17 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | +| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `doc.save` | `save` | Save the current session to the original file or a new path. | +| `doc.close` | `close` | Close the active editing session and clean up resources. | +| `doc.status` | `status` | Show the current session status and document metadata. | +| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | +| `doc.describe_command` | `describe command` | Show detailed metadata for a single CLI operation. | | `doc.session.list` | `session list` | List all active editing sessions. | | `doc.session.save` | `session save` | Persist the current session state. | | `doc.session.close` | `session close` | Close a specific editing session by ID. | | `doc.session.set_default` | `session set-default` | Set the default session for subsequent commands. | -#### Blocks - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | - -#### Capabilities - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | - -#### Format / Paragraph - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.format.paragraph.reset_direct_formatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | -| `doc.format.paragraph.set_alignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | -| `doc.format.paragraph.clear_alignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | -| `doc.format.paragraph.set_indentation` | `format paragraph set-indentation` | Set paragraph indentation properties (left, right, firstLine, hanging) in twips. | -| `doc.format.paragraph.clear_indentation` | `format paragraph clear-indentation` | Remove all direct paragraph indentation. | -| `doc.format.paragraph.set_spacing` | `format paragraph set-spacing` | Set paragraph spacing properties (before, after, line, lineRule) in twips. | -| `doc.format.paragraph.clear_spacing` | `format paragraph clear-spacing` | Remove all direct paragraph spacing. | -| `doc.format.paragraph.set_keep_options` | `format paragraph set-keep-options` | Set keep-with-next, keep-lines-together, and widow/orphan control flags. | -| `doc.format.paragraph.set_outline_level` | `format paragraph set-outline-level` | Set the paragraph outline level (0–9) or null to clear. | -| `doc.format.paragraph.set_flow_options` | `format paragraph set-flow-options` | Set contextual spacing, page-break-before, and suppress-auto-hyphens flags. | -| `doc.format.paragraph.set_tab_stop` | `format paragraph set-tab-stop` | Add or replace a tab stop at a given position. | -| `doc.format.paragraph.clear_tab_stop` | `format paragraph clear-tab-stop` | Remove a tab stop at a given position. | -| `doc.format.paragraph.clear_all_tab_stops` | `format paragraph clear-all-tab-stops` | Remove all tab stops from a paragraph. | -| `doc.format.paragraph.set_border` | `format paragraph set-border` | Set border properties for a specific side of a paragraph. | -| `doc.format.paragraph.clear_border` | `format paragraph clear-border` | Remove border for a specific side or all sides of a paragraph. | -| `doc.format.paragraph.set_shading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | -| `doc.format.paragraph.clear_shading` | `format paragraph clear-shading` | Remove all paragraph shading. | - -#### Hyperlinks - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.hyperlinks.list` | `hyperlinks list` | List all hyperlinks in the document, with optional filtering by href, anchor, or display text. | -| `doc.hyperlinks.get` | `hyperlinks get` | Retrieve details of a specific hyperlink by its inline address. | -| `doc.hyperlinks.wrap` | `hyperlinks wrap` | Wrap an existing text range with a hyperlink. | -| `doc.hyperlinks.insert` | `hyperlinks insert` | Insert new linked text at a target position. | -| `doc.hyperlinks.patch` | `hyperlinks patch` | Update hyperlink metadata (destination, tooltip, target, rel) without changing display text. | -| `doc.hyperlinks.remove` | `hyperlinks remove` | Remove a hyperlink. Mode 'unwrap' preserves display text; 'deleteText' removes the linked content entirely. | - -#### Introspection - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.status` | `status` | Show the current session status and document metadata. | -| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | -| `doc.describe_command` | `describe command` | Show detailed metadata for a single CLI operation. | - -#### Lifecycle - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | -| `doc.save` | `save` | Save the current session to the original file or a new path. | -| `doc.close` | `close` | Close the active editing session and clean up resources. | - -#### Mutation - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | -| `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | -| `doc.delete` | `delete` | Delete content at a target position. | -| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | - -#### Query - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | -| `doc.get_node` | `get-node` | Retrieve a single node by target position. | -| `doc.get_node_by_id` | `get-node-by-id` | Retrieve a single node by its unique ID. | -| `doc.get_text` | `get-text` | Extract the plain-text content of the document. | -| `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. | -| `doc.get_html` | `get-html` | Extract the document content as an HTML string. | -| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | -| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | -| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | - -#### Styles - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | - -#### Styles / Paragraph - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | -| `doc.styles.paragraph.clear_style` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | - {/* SDK_OPERATIONS_END */} diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 72cc496644..dc11d68f21 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -2753,6 +2753,238 @@ export const OPERATION_DEFINITIONS = { referenceGroup: 'images', }, + // --- SD-2100: Geometry --- + + 'images.scale': { + memberPath: 'images.scale', + description: 'Scale an image by a uniform factor applied to both dimensions.', + expectedResult: 'Returns an ImagesMutationResult with the updated image address.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/scale.mdx', + referenceGroup: 'images', + }, + + 'images.setLockAspectRatio': { + memberPath: 'images.setLockAspectRatio', + description: 'Lock or unlock the aspect ratio for an 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, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/set-lock-aspect-ratio.mdx', + referenceGroup: 'images', + }, + + 'images.rotate': { + memberPath: 'images.rotate', + description: 'Set the absolute rotation angle for an 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, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/rotate.mdx', + referenceGroup: 'images', + }, + + 'images.flip': { + memberPath: 'images.flip', + description: 'Set horizontal and/or vertical flip state for an 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, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/flip.mdx', + referenceGroup: 'images', + }, + + 'images.crop': { + memberPath: 'images.crop', + description: 'Apply rectangular edge-percentage crop to an image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if unchanged.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/crop.mdx', + referenceGroup: 'images', + }, + + 'images.resetCrop': { + memberPath: 'images.resetCrop', + description: 'Remove all cropping from an image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if no crop is set.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/reset-crop.mdx', + referenceGroup: 'images', + }, + + // --- SD-2100: Content replacement --- + + 'images.replaceSource': { + memberPath: 'images.replaceSource', + description: 'Replace the image source while preserving identity and placement.', + expectedResult: 'Returns an ImagesMutationResult with the updated image address.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/replace-source.mdx', + referenceGroup: 'images', + }, + + // --- SD-2100: Semantic metadata --- + + 'images.setAltText': { + memberPath: 'images.setAltText', + description: 'Set the accessibility description (alt text) for an image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if unchanged.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/set-alt-text.mdx', + referenceGroup: 'images', + }, + + 'images.setDecorative': { + memberPath: 'images.setDecorative', + description: 'Mark or unmark an image as decorative.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if unchanged.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/set-decorative.mdx', + referenceGroup: 'images', + }, + + 'images.setName': { + memberPath: 'images.setName', + description: 'Set the object name for an image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if unchanged.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/set-name.mdx', + referenceGroup: 'images', + }, + + 'images.setHyperlink': { + memberPath: 'images.setHyperlink', + description: 'Set or remove the hyperlink attached to an image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if unchanged.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/set-hyperlink.mdx', + referenceGroup: 'images', + }, + + // --- SD-2100: Caption lifecycle --- + + 'images.insertCaption': { + memberPath: 'images.insertCaption', + description: 'Insert a caption paragraph below the image.', + expectedResult: 'Returns an ImagesMutationResult with the image address.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/insert-caption.mdx', + referenceGroup: 'images', + }, + + 'images.updateCaption': { + memberPath: 'images.updateCaption', + description: 'Update the text of an existing caption paragraph.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if text unchanged.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: [...T_IMAGE_COMMAND, 'INVALID_INPUT'], + }), + referenceDocPath: 'images/update-caption.mdx', + referenceGroup: 'images', + }, + + 'images.removeCaption': { + memberPath: 'images.removeCaption', + description: 'Remove the caption paragraph from below the image.', + expectedResult: 'Returns an ImagesMutationResult; reports NO_OP if no caption exists.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: T_IMAGE_COMMAND, + }), + referenceDocPath: 'images/remove-caption.mdx', + referenceGroup: 'images', + }, + // ------------------------------------------------------------------------- // Hyperlinks: discovery + CRUD // ------------------------------------------------------------------------- diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 9a7c5546be..ea47227359 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -152,6 +152,20 @@ import type { SetPositionInput, SetAnchorOptionsInput, SetZOrderInput, + ScaleInput, + SetLockAspectRatioInput, + RotateInput, + FlipInput, + CropInput, + ResetCropInput, + ReplaceSourceInput, + SetAltTextInput, + SetDecorativeInput, + SetNameInput, + SetHyperlinkInput, + InsertCaptionInput, + UpdateCaptionInput, + RemoveCaptionInput, } from '../images/images.types.js'; import type { MutationsApplyInput, @@ -674,6 +688,28 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { 'images.setPosition': { input: SetPositionInput; options: MutationOptions; output: ImagesMutationResult }; 'images.setAnchorOptions': { input: SetAnchorOptionsInput; options: MutationOptions; output: ImagesMutationResult }; 'images.setZOrder': { input: SetZOrderInput; options: MutationOptions; output: ImagesMutationResult }; + // SD-2100: Geometry + 'images.scale': { input: ScaleInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setLockAspectRatio': { + input: SetLockAspectRatioInput; + options: MutationOptions; + output: ImagesMutationResult; + }; + 'images.rotate': { input: RotateInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.flip': { input: FlipInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.crop': { input: CropInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.resetCrop': { input: ResetCropInput; options: MutationOptions; output: ImagesMutationResult }; + // SD-2100: Content + 'images.replaceSource': { input: ReplaceSourceInput; options: MutationOptions; output: ImagesMutationResult }; + // SD-2100: Semantic metadata + 'images.setAltText': { input: SetAltTextInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setDecorative': { input: SetDecorativeInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setName': { input: SetNameInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.setHyperlink': { input: SetHyperlinkInput; options: MutationOptions; output: ImagesMutationResult }; + // SD-2100: Caption lifecycle + 'images.insertCaption': { input: InsertCaptionInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.updateCaption': { input: UpdateCaptionInput; options: MutationOptions; output: ImagesMutationResult }; + 'images.removeCaption': { input: RemoveCaptionInput; options: MutationOptions; output: ImagesMutationResult }; // --- hyperlinks.* --- 'hyperlinks.list': { input: HyperlinksListQuery | undefined; options: never; output: HyperlinksListResult }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 346fc2238a..3542a59423 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -64,6 +64,22 @@ function ref(name: string): JsonSchema { return { $ref: `#/$defs/${name}` }; } +/** Shared output/success/failure shape for ImagesMutationResult operations. */ +function imagesMutationSchemaSet(inputSchema: JsonSchema): OperationSchemaSet { + return { + input: inputSchema, + 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'], + ), + }; +} + const nodeTypeValues = NODE_TYPES; const blockNodeTypeValues = BLOCK_NODE_TYPES; const deletableBlockNodeTypeValues = DELETABLE_BLOCK_NODE_TYPES; @@ -4340,8 +4356,8 @@ const operationSchemas: Record = { ['success', 'failure'], ), }, - 'images.setZOrder': { - input: objectSchema( + 'images.setZOrder': imagesMutationSchemaSet( + objectSchema( { imageId: { type: 'string' }, zOrder: objectSchema( @@ -4357,16 +4373,99 @@ const operationSchemas: Record = { }, ['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( + ), + + // --- SD-2100: Geometry --- + + 'images.scale': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, factor: { type: 'number', exclusiveMinimum: 0 } }, [ + 'imageId', + 'factor', + ]), + ), + + 'images.setLockAspectRatio': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, locked: { type: 'boolean' } }, ['imageId', 'locked']), + ), + + 'images.rotate': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, angle: { type: 'number', minimum: 0, maximum: 360 } }, [ + 'imageId', + 'angle', + ]), + ), + + 'images.flip': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, horizontal: { type: 'boolean' }, vertical: { type: 'boolean' } }, [ + 'imageId', + ]), + ), + + 'images.crop': imagesMutationSchemaSet( + objectSchema( { - success: { const: false }, - failure: objectSchema({ code: { type: 'string' }, message: { type: 'string' } }, ['code', 'message']), + imageId: { type: 'string' }, + crop: objectSchema( + { + left: { type: 'number', minimum: 0, maximum: 100 }, + top: { type: 'number', minimum: 0, maximum: 100 }, + right: { type: 'number', minimum: 0, maximum: 100 }, + bottom: { type: 'number', minimum: 0, maximum: 100 }, + }, + [], // All fields optional; omitted edges default to 0 at runtime + ), }, - ['success', 'failure'], + ['imageId', 'crop'], ), - }, + ), + + 'images.resetCrop': imagesMutationSchemaSet(objectSchema({ imageId: { type: 'string' } }, ['imageId'])), + + // --- SD-2100: Content replacement --- + + 'images.replaceSource': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, src: { type: 'string' }, resetSize: { type: 'boolean' } }, [ + 'imageId', + 'src', + ]), + ), + + // --- SD-2100: Semantic metadata --- + + 'images.setAltText': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, description: { type: 'string' } }, ['imageId', 'description']), + ), + + 'images.setDecorative': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, decorative: { type: 'boolean' } }, ['imageId', 'decorative']), + ), + + 'images.setName': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, name: { type: 'string' } }, ['imageId', 'name']), + ), + + 'images.setHyperlink': imagesMutationSchemaSet( + objectSchema( + { + imageId: { type: 'string' }, + url: { type: ['string', 'null'] }, + tooltip: { type: 'string' }, + }, + ['imageId', 'url'], + ), + ), + + // --- SD-2100: Caption lifecycle --- + + 'images.insertCaption': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, text: { type: 'string' } }, ['imageId', 'text']), + ), + + 'images.updateCaption': imagesMutationSchemaSet( + objectSchema({ imageId: { type: 'string' }, text: { type: 'string' } }, ['imageId', 'text']), + ), + + 'images.removeCaption': imagesMutationSchemaSet(objectSchema({ imageId: { type: 'string' } }, ['imageId'])), // --- hyperlinks.* --- 'hyperlinks.list': { diff --git a/packages/document-api/src/images/images.ts b/packages/document-api/src/images/images.ts index bb8460a01a..4c72d4b09e 100644 --- a/packages/document-api/src/images/images.ts +++ b/packages/document-api/src/images/images.ts @@ -19,6 +19,20 @@ import type { SetPositionInput, SetAnchorOptionsInput, SetZOrderInput, + ScaleInput, + SetLockAspectRatioInput, + RotateInput, + FlipInput, + CropInput, + ResetCropInput, + ReplaceSourceInput, + SetAltTextInput, + SetDecorativeInput, + SetNameInput, + SetHyperlinkInput, + InsertCaptionInput, + UpdateCaptionInput, + RemoveCaptionInput, } from './images.types.js'; import { isUnsignedInt32, Z_ORDER_RELATIVE_HEIGHT_MAX, Z_ORDER_RELATIVE_HEIGHT_MIN } from './z-order.js'; @@ -48,6 +62,24 @@ export interface ImagesAdapter { setPosition(input: SetPositionInput, options?: MutationOptions): ImagesMutationResult; setAnchorOptions(input: SetAnchorOptionsInput, options?: MutationOptions): ImagesMutationResult; setZOrder(input: SetZOrderInput, options?: MutationOptions): ImagesMutationResult; + // SD-2100: Geometry + scale(input: ScaleInput, options?: MutationOptions): ImagesMutationResult; + setLockAspectRatio(input: SetLockAspectRatioInput, options?: MutationOptions): ImagesMutationResult; + rotate(input: RotateInput, options?: MutationOptions): ImagesMutationResult; + flip(input: FlipInput, options?: MutationOptions): ImagesMutationResult; + crop(input: CropInput, options?: MutationOptions): ImagesMutationResult; + resetCrop(input: ResetCropInput, options?: MutationOptions): ImagesMutationResult; + // SD-2100: Content + replaceSource(input: ReplaceSourceInput, options?: MutationOptions): ImagesMutationResult; + // SD-2100: Semantic metadata + setAltText(input: SetAltTextInput, options?: MutationOptions): ImagesMutationResult; + setDecorative(input: SetDecorativeInput, options?: MutationOptions): ImagesMutationResult; + setName(input: SetNameInput, options?: MutationOptions): ImagesMutationResult; + setHyperlink(input: SetHyperlinkInput, options?: MutationOptions): ImagesMutationResult; + // SD-2100: Caption lifecycle + insertCaption(input: InsertCaptionInput, options?: MutationOptions): ImagesMutationResult; + updateCaption(input: UpdateCaptionInput, options?: MutationOptions): ImagesMutationResult; + removeCaption(input: RemoveCaptionInput, options?: MutationOptions): ImagesMutationResult; } export type ImagesApi = ImagesAdapter; @@ -254,6 +286,232 @@ export function executeImagesSetZOrder( return adapter.setZOrder(input, options); } +// --------------------------------------------------------------------------- +// SD-2100: Geometry execute functions +// --------------------------------------------------------------------------- + +export function executeImagesScale( + adapter: ImagesAdapter, + input: ScaleInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (typeof input.factor !== 'number' || !Number.isFinite(input.factor) || input.factor <= 0) { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.scale requires factor as a finite positive number.', { + field: 'factor', + value: input.factor, + }); + } + return adapter.scale(input, options); +} + +export function executeImagesSetLockAspectRatio( + adapter: ImagesAdapter, + input: SetLockAspectRatioInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (typeof input.locked !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setLockAspectRatio requires locked as a boolean.', { + field: 'locked', + }); + } + return adapter.setLockAspectRatio(input, options); +} + +export function executeImagesRotate( + adapter: ImagesAdapter, + input: RotateInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (typeof input.angle !== 'number' || !Number.isFinite(input.angle) || input.angle < 0 || input.angle > 360) { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.rotate requires angle as a number in [0, 360].', { + field: 'angle', + value: input.angle, + }); + } + return adapter.rotate(input, options); +} + +export function executeImagesFlip( + adapter: ImagesAdapter, + input: FlipInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (input.horizontal === undefined && input.vertical === undefined) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + 'images.flip requires at least one of horizontal or vertical.', + { field: 'horizontal|vertical' }, + ); + } + if (input.horizontal !== undefined && typeof input.horizontal !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.flip horizontal must be a boolean.', { + field: 'horizontal', + }); + } + if (input.vertical !== undefined && typeof input.vertical !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.flip vertical must be a boolean.', { + field: 'vertical', + }); + } + return adapter.flip(input, options); +} + +export function executeImagesCrop( + adapter: ImagesAdapter, + input: CropInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (!input.crop || typeof input.crop !== 'object') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.crop requires a crop object.', { field: 'crop' }); + } + const { left = 0, top = 0, right = 0, bottom = 0 } = input.crop; + for (const [name, value] of Object.entries({ left, top, right, bottom }) as [string, number][]) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 100) { + throw new DocumentApiValidationError('INVALID_INPUT', `crop.${name} must be a number in [0, 100].`, { + field: `crop.${name}`, + value, + }); + } + } + if (left + right >= 100) { + throw new DocumentApiValidationError('INVALID_INPUT', 'crop.left + crop.right must be less than 100.', { + field: 'crop', + }); + } + if (top + bottom >= 100) { + throw new DocumentApiValidationError('INVALID_INPUT', 'crop.top + crop.bottom must be less than 100.', { + field: 'crop', + }); + } + return adapter.crop(input, options); +} + +export function executeImagesResetCrop( + adapter: ImagesAdapter, + input: ResetCropInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + return adapter.resetCrop(input, options); +} + +// --------------------------------------------------------------------------- +// SD-2100: Content replacement execute function +// --------------------------------------------------------------------------- + +export function executeImagesReplaceSource( + adapter: ImagesAdapter, + input: ReplaceSourceInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + requireString(input.src, 'src'); + return adapter.replaceSource(input, options); +} + +// --------------------------------------------------------------------------- +// SD-2100: Semantic metadata execute functions +// --------------------------------------------------------------------------- + +export function executeImagesSetAltText( + adapter: ImagesAdapter, + input: SetAltTextInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (typeof input.description !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setAltText requires description as a string.', { + field: 'description', + }); + } + return adapter.setAltText(input, options); +} + +export function executeImagesSetDecorative( + adapter: ImagesAdapter, + input: SetDecorativeInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (typeof input.decorative !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setDecorative requires decorative as a boolean.', { + field: 'decorative', + }); + } + return adapter.setDecorative(input, options); +} + +export function executeImagesSetName( + adapter: ImagesAdapter, + input: SetNameInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (typeof input.name !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setName requires name as a string.', { + field: 'name', + }); + } + return adapter.setName(input, options); +} + +export function executeImagesSetHyperlink( + adapter: ImagesAdapter, + input: SetHyperlinkInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + if (input.url !== null && typeof input.url !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setHyperlink requires url as a string or null.', { + field: 'url', + }); + } + if (input.tooltip !== undefined && typeof input.tooltip !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'images.setHyperlink tooltip must be a string.', { + field: 'tooltip', + }); + } + return adapter.setHyperlink(input, options); +} + +// --------------------------------------------------------------------------- +// SD-2100: Caption lifecycle execute functions +// --------------------------------------------------------------------------- + +export function executeImagesInsertCaption( + adapter: ImagesAdapter, + input: InsertCaptionInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + requireString(input.text, 'text'); + return adapter.insertCaption(input, options); +} + +export function executeImagesUpdateCaption( + adapter: ImagesAdapter, + input: UpdateCaptionInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + requireString(input.text, 'text'); + return adapter.updateCaption(input, options); +} + +export function executeImagesRemoveCaption( + adapter: ImagesAdapter, + input: RemoveCaptionInput, + options?: MutationOptions, +): ImagesMutationResult { + requireImageId(input); + return adapter.removeCaption(input, options); +} + // --------------------------------------------------------------------------- // Create image execute (lives here alongside images domain) // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/images/images.types.ts b/packages/document-api/src/images/images.types.ts index ff61cf00d7..e95e750a42 100644 --- a/packages/document-api/src/images/images.types.ts +++ b/packages/document-api/src/images/images.types.ts @@ -5,6 +5,7 @@ import type { ImageWrapSide, ImageMarginOffset, ImageSize, + ImageCropInfo, } from '../types/media.types.js'; // --------------------------------------------------------------------------- @@ -86,6 +87,101 @@ export interface ImageZOrderInput { relativeHeight: number; } +// --------------------------------------------------------------------------- +// Geometry inputs (SD-2100) +// --------------------------------------------------------------------------- + +export interface ScaleInput { + imageId: string; + /** Scale factor (> 0). E.g., 0.5 = half size, 2.0 = double. */ + factor: number; +} + +export interface SetLockAspectRatioInput { + imageId: string; + locked: boolean; +} + +export interface RotateInput { + imageId: string; + /** Absolute rotation in degrees (0–360). */ + angle: number; +} + +export interface FlipInput { + imageId: string; + /** true = flipped, false = normal, undefined = unchanged. */ + horizontal?: boolean; + /** true = flipped, false = normal, undefined = unchanged. */ + vertical?: boolean; +} + +export interface CropInput { + imageId: string; + crop: ImageCropInfo; +} + +export interface ResetCropInput { + imageId: string; +} + +// --------------------------------------------------------------------------- +// Content replacement (SD-2100) +// --------------------------------------------------------------------------- + +export interface ReplaceSourceInput { + imageId: string; + src: string; + /** If true, recompute size from the new image's intrinsic dimensions (data URIs only). */ + resetSize?: boolean; +} + +// --------------------------------------------------------------------------- +// Semantic metadata (SD-2100) +// --------------------------------------------------------------------------- + +export interface SetAltTextInput { + imageId: string; + /** Accessibility description (maps to wp:docPr/@descr). */ + description: string; +} + +export interface SetDecorativeInput { + imageId: string; + decorative: boolean; +} + +export interface SetNameInput { + imageId: string; + /** Object name (maps to wp:docPr/@name). */ + name: string; +} + +export interface SetHyperlinkInput { + imageId: string; + /** URL to link to, or null to remove hyperlink. */ + url: string | null; + tooltip?: string; +} + +// --------------------------------------------------------------------------- +// Caption lifecycle (SD-2100) +// --------------------------------------------------------------------------- + +export interface InsertCaptionInput { + imageId: string; + text: string; +} + +export interface UpdateCaptionInput { + imageId: string; + text: string; +} + +export interface RemoveCaptionInput { + imageId: string; +} + // --------------------------------------------------------------------------- // Operation inputs // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index fcad2436e8..b8da8f4960 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -343,6 +343,20 @@ import { executeImagesSetAnchorOptions, executeImagesSetZOrder, executeCreateImage, + executeImagesScale, + executeImagesSetLockAspectRatio, + executeImagesRotate, + executeImagesFlip, + executeImagesCrop, + executeImagesResetCrop, + executeImagesReplaceSource, + executeImagesSetAltText, + executeImagesSetDecorative, + executeImagesSetName, + executeImagesSetHyperlink, + executeImagesInsertCaption, + executeImagesUpdateCaption, + executeImagesRemoveCaption, } from './images/images.js'; import type { CreateImageInput, @@ -363,6 +377,20 @@ import type { SetPositionInput, SetAnchorOptionsInput, SetZOrderInput, + ScaleInput, + SetLockAspectRatioInput, + RotateInput, + FlipInput, + CropInput, + ResetCropInput, + ReplaceSourceInput, + SetAltTextInput, + SetDecorativeInput, + SetNameInput, + SetHyperlinkInput, + InsertCaptionInput, + UpdateCaptionInput, + RemoveCaptionInput, } from './images/images.types.js'; import type { TocApi, TocAdapter } from './toc/toc.js'; import { @@ -533,6 +561,20 @@ export type { SetPositionInput, SetAnchorOptionsInput, SetZOrderInput, + ScaleInput, + SetLockAspectRatioInput, + RotateInput, + FlipInput, + CropInput, + ResetCropInput, + ReplaceSourceInput, + SetAltTextInput, + SetDecorativeInput, + SetNameInput, + SetHyperlinkInput, + InsertCaptionInput, + UpdateCaptionInput, + RemoveCaptionInput, } from './images/images.types.js'; export type { TocApi, TocAdapter } from './toc/toc.js'; export type { @@ -1251,6 +1293,52 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { setZOrder(input: SetZOrderInput, options?: MutationOptions): ImagesMutationResult { return executeImagesSetZOrder(adapters.images, input, options); }, + // SD-2100: Geometry + scale(input: ScaleInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesScale(adapters.images, input, options); + }, + setLockAspectRatio(input: SetLockAspectRatioInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetLockAspectRatio(adapters.images, input, options); + }, + rotate(input: RotateInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesRotate(adapters.images, input, options); + }, + flip(input: FlipInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesFlip(adapters.images, input, options); + }, + crop(input: CropInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesCrop(adapters.images, input, options); + }, + resetCrop(input: ResetCropInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesResetCrop(adapters.images, input, options); + }, + // SD-2100: Content + replaceSource(input: ReplaceSourceInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesReplaceSource(adapters.images, input, options); + }, + // SD-2100: Semantic metadata + setAltText(input: SetAltTextInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetAltText(adapters.images, input, options); + }, + setDecorative(input: SetDecorativeInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetDecorative(adapters.images, input, options); + }, + setName(input: SetNameInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetName(adapters.images, input, options); + }, + setHyperlink(input: SetHyperlinkInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesSetHyperlink(adapters.images, input, options); + }, + // SD-2100: Caption lifecycle + insertCaption(input: InsertCaptionInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesInsertCaption(adapters.images, input, options); + }, + updateCaption(input: UpdateCaptionInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesUpdateCaption(adapters.images, input, options); + }, + removeCaption(input: RemoveCaptionInput, options?: MutationOptions): ImagesMutationResult { + return executeImagesRemoveCaption(adapters.images, input, options); + }, }, lists: { list(query?: ListsListQuery): ListsListResult { diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 9f7de2aaaa..0528994a6f 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -272,6 +272,24 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { '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), + // SD-2100: Geometry + 'images.scale': (input, options) => api.images.scale(input, options), + 'images.setLockAspectRatio': (input, options) => api.images.setLockAspectRatio(input, options), + 'images.rotate': (input, options) => api.images.rotate(input, options), + 'images.flip': (input, options) => api.images.flip(input, options), + 'images.crop': (input, options) => api.images.crop(input, options), + 'images.resetCrop': (input, options) => api.images.resetCrop(input, options), + // SD-2100: Content + 'images.replaceSource': (input, options) => api.images.replaceSource(input, options), + // SD-2100: Semantic metadata + 'images.setAltText': (input, options) => api.images.setAltText(input, options), + 'images.setDecorative': (input, options) => api.images.setDecorative(input, options), + 'images.setName': (input, options) => api.images.setName(input, options), + 'images.setHyperlink': (input, options) => api.images.setHyperlink(input, options), + // SD-2100: Caption lifecycle + 'images.insertCaption': (input, options) => api.images.insertCaption(input, options), + 'images.updateCaption': (input, options) => api.images.updateCaption(input, options), + 'images.removeCaption': (input, options) => api.images.removeCaption(input, options), // --- hyperlinks.* --- 'hyperlinks.list': (input) => api.hyperlinks.list(input), diff --git a/packages/document-api/src/types/media.types.ts b/packages/document-api/src/types/media.types.ts index df6ac0fc01..407b710b7b 100644 --- a/packages/document-api/src/types/media.types.ts +++ b/packages/document-api/src/types/media.types.ts @@ -43,6 +43,24 @@ export interface ImageMarginOffset { top?: number; } +export interface ImageTransformInfo { + rotation?: number; + verticalFlip?: boolean; + horizontalFlip?: boolean; +} + +export interface ImageCropInfo { + left?: number; + top?: number; + right?: number; + bottom?: number; +} + +export interface ImageHyperlinkInfo { + url: string; + tooltip?: string; +} + export interface ImageProperties { src?: string; alt?: string; @@ -52,4 +70,15 @@ export interface ImageProperties { anchorData?: ImageAnchorData | null; marginOffset?: ImageMarginOffset | null; relativeHeight?: number | null; + /** Object name (maps to wp:docPr/@name). */ + name?: string; + /** Accessibility description (maps to wp:docPr/@descr). */ + description?: string; + transform?: ImageTransformInfo | null; + crop?: ImageCropInfo | null; + lockAspectRatio?: boolean; + decorative?: boolean; + hyperlink?: ImageHyperlinkInfo | null; + /** True if an adjacent Caption paragraph exists. */ + hasCaption?: boolean; } 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 cd9358ec23..c3ae502480 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 @@ -5,6 +5,120 @@ import { wrapTextInRun } from '@converter/exporter.js'; import { generateDocxRandomId } from '@core/helpers/index.js'; import { readImageDimensionsFromDataUri } from '@converter/image-dimensions.js'; +const DECORATIVE_EXT_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}'; +const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/decorative'; +const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; + +/** + * Build the `wp:docPr` element with correct attribute mappings: + * - `@name` ← attrs.alt (object name, wp:docPr/@name) + * - `@descr` ← attrs.title (accessibility description, wp:docPr/@descr) — omitted when decorative + * - Decorative extension child when attrs.decorative is true + */ +function buildDocPrElement(attrs, imageName) { + const docPrAttrs = { + id: attrs.id || 0, + name: attrs.alt || `Picture ${imageName}`, + }; + // Emit descr (accessibility description) unless decorative + if (!attrs.decorative && attrs.title) { + docPrAttrs.descr = attrs.title; + } + + const children = []; + if (attrs.decorative) { + children.push({ + name: 'a:extLst', + elements: [ + { + name: 'a:ext', + attributes: { uri: DECORATIVE_EXT_URI }, + elements: [ + { + name: 'adec:decorative', + attributes: { 'xmlns:adec': DECORATIVE_NAMESPACE, val: '1' }, + }, + ], + }, + ], + }); + } + + return { + name: 'wp:docPr', + attributes: docPrAttrs, + ...(children.length ? { elements: children } : {}), + }; +} + +/** + * Build the `pic:nvPicPr` element with: + * - `pic:cNvPr/@name` ← attrs.alt (object name, mirrors wp:docPr/@name) + * - `a:hlinkClick` child when attrs.hyperlink is set + * - `a:picLocks/@noChangeAspect` ← dynamic from attrs.lockAspectRatio + */ +function buildNvPicPrElement(attrs, imageName, params) { + // --- pic:cNvPr children (hyperlink) --- + const cNvPrChildren = []; + if (attrs.hyperlink?.url) { + const hlinkRId = addHyperlinkRelationship(params, attrs.hyperlink.url); + const hlinkAttrs = { 'r:id': hlinkRId }; + if (attrs.hyperlink.tooltip) { + hlinkAttrs.tooltip = attrs.hyperlink.tooltip; + } + cNvPrChildren.push({ name: 'a:hlinkClick', attributes: hlinkAttrs }); + } + + return { + name: 'pic:nvPicPr', + elements: [ + { + name: 'pic:cNvPr', + attributes: { + id: attrs.id || 0, + name: attrs.alt || `Picture ${imageName}`, + }, + ...(cNvPrChildren.length ? { elements: cNvPrChildren } : {}), + }, + { + name: 'pic:cNvPicPr', + elements: [ + { + name: 'a:picLocks', + attributes: { + noChangeAspect: attrs.lockAspectRatio === false ? 0 : 1, + noChangeArrowheads: 1, + }, + }, + ], + }, + ], + }; +} + +/** + * Add a hyperlink relationship and return the rId. + * Uses params.relationships (part-local) so that images in headers/footers + * write to the correct .rels file, not always word/_rels/document.xml.rels. + */ +function addHyperlinkRelationship(params, url) { + const newId = `rId${generateDocxRandomId(8)}`; + if (!params.relationships || !Array.isArray(params.relationships)) { + params.relationships = []; + } + params.relationships.push({ + type: 'element', + name: 'Relationship', + attributes: { + Id: newId, + Type: HYPERLINK_REL_TYPE, + Target: url, + TargetMode: 'External', + }, + }); + return newId; +} + /** * Decodes image into export XML * @typedef {Object} ExportParams @@ -156,13 +270,7 @@ export const translateImageNode = (params) => { name: 'wp:effectExtent', attributes: effectExtentAttrs, }, - { - name: 'wp:docPr', - attributes: { - id: attrs.id || 0, - name: attrs.alt || `Picture ${imageName}`, - }, - }, + buildDocPrElement(attrs, imageName), { name: 'wp:cNvGraphicFramePr', elements: [ @@ -170,7 +278,7 @@ export const translateImageNode = (params) => { name: 'a:graphicFrameLocks', attributes: { 'xmlns:a': drawingXmlns, - noChangeAspect: 1, + noChangeAspect: attrs.lockAspectRatio === false ? 0 : 1, }, }, ], @@ -187,30 +295,7 @@ export const translateImageNode = (params) => { name: 'pic:pic', attributes: { 'xmlns:pic': pictureXmlns }, elements: [ - { - name: 'pic:nvPicPr', - elements: [ - { - name: 'pic:cNvPr', - attributes: { - id: attrs.id || 0, - name: attrs.title || `Picture ${imageName}`, - }, - }, - { - name: 'pic:cNvPicPr', - elements: [ - { - name: 'a:picLocks', - attributes: { - noChangeAspect: 1, - noChangeArrowheads: 1, - }, - }, - ], - }, - ], - }, + buildNvPicPrElement(attrs, imageName, params), { name: 'pic:blipFill', elements: [ diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index ef7ed61a68..a6c0a36b1a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -165,6 +165,41 @@ describe('translateImageNode', () => { expect(blip.elements).toBeUndefined(); }); + + it('should emit a:hlinkClick and push hyperlink relationship to params.relationships', () => { + baseParams.node.attrs.hyperlink = { url: 'https://example.com', tooltip: 'Go' }; + + const result = translateImageNode(baseParams); + + // Relationship pushed to part-local array (not hardcoded to document.xml.rels) + const hlinkRel = baseParams.relationships.find( + (r) => r.attributes.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + ); + expect(hlinkRel).toBeDefined(); + expect(hlinkRel.attributes.Target).toBe('https://example.com'); + expect(hlinkRel.attributes.TargetMode).toBe('External'); + + // a:hlinkClick element present inside pic:cNvPr + const graphic = result.elements.find((e) => e.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; // pic:pic + const nvPicPr = picPic.elements.find((e) => e.name === 'pic:nvPicPr'); + const cNvPr = nvPicPr.elements.find((e) => e.name === 'pic:cNvPr'); + const hlinkClick = cNvPr.elements.find((e) => e.name === 'a:hlinkClick'); + expect(hlinkClick).toBeDefined(); + expect(hlinkClick.attributes['r:id']).toBe(hlinkRel.attributes.Id); + expect(hlinkClick.attributes.tooltip).toBe('Go'); + }); + + it('should not emit a:hlinkClick when hyperlink is absent', () => { + const result = translateImageNode(baseParams); + + const graphic = result.elements.find((e) => e.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + const nvPicPr = picPic.elements.find((e) => e.name === 'pic:nvPicPr'); + const cNvPr = nvPicPr.elements.find((e) => e.name === 'pic:cNvPr'); + const hlinkClick = cNvPr?.elements?.find((e) => e.name === 'a:hlinkClick'); + expect(hlinkClick).toBeUndefined(); + }); }); describe('translateVectorShape', () => { 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 c0f645fbbf..b89dd81a3a 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 @@ -366,6 +366,48 @@ export function handleImageNode(node, params, isAnchor) { } } + // --- Parse pic:nvPicPr for lockAspectRatio, hyperlink --- + const nvPicPr = picture.elements.find((el) => el.name === 'pic:nvPicPr'); + const cNvPicPr = nvPicPr?.elements?.find((el) => el.name === 'pic:cNvPicPr'); + const picLocks = cNvPicPr?.elements?.find((el) => el.name === 'a:picLocks'); + // Default true (Word's default). Only false when a:picLocks is present but noChangeAspect is '0' or absent. + const lockAspectRatio = picLocks + ? picLocks.attributes?.['noChangeAspect'] === '1' || picLocks.attributes?.['noChangeAspect'] === 1 + : true; + + // Parse image hyperlink from pic:cNvPr > a:hlinkClick + const cNvPr = nvPicPr?.elements?.find((el) => el.name === 'pic:cNvPr'); + const hlinkClick = cNvPr?.elements?.find((el) => el.name === 'a:hlinkClick'); + let hyperlink = null; + if (hlinkClick?.attributes?.['r:id']) { + const hlinkRId = hlinkClick.attributes['r:id']; + const currentFile2 = filename || 'document.xml'; + let hlinkRels = docx[`word/_rels/${currentFile2}.rels`]; + if (!hlinkRels) hlinkRels = docx[`word/_rels/document.xml.rels`]; + const hlinkRelationships = hlinkRels?.elements?.find((el) => el.name === 'Relationships'); + const hlinkRel = hlinkRelationships?.elements?.find((el) => el.attributes?.['Id'] === hlinkRId); + if (hlinkRel?.attributes?.['Target']) { + hyperlink = { url: hlinkRel.attributes['Target'] }; + if (hlinkClick.attributes?.['tooltip']) { + hyperlink.tooltip = hlinkClick.attributes['tooltip']; + } + } + } + + // --- Parse decorative flag from wp:docPr > a:extLst > a:ext > adec:decorative --- + let decorative = false; + const docPrExtLst = docPr?.elements?.find((el) => el.name === 'a:extLst'); + if (docPrExtLst) { + for (const ext of docPrExtLst.elements || []) { + if (ext.name !== 'a:ext') continue; + const decEl = ext.elements?.find((el) => el.name === 'adec:decorative' || el.name === 'a16:decorative'); + if (decEl && (decEl.attributes?.['val'] === '1' || decEl.attributes?.['val'] === 1)) { + decorative = true; + break; + } + } + } + const { attributes: blipAttributes = {} } = blip; const rEmbed = blipAttributes['r:embed']; if (!rEmbed) { @@ -495,6 +537,9 @@ export function handleImageNode(node, params, isAnchor) { }, originalAttributes: node.attributes, rId: relAttributes['Id'], + lockAspectRatio, + decorative, + hyperlink, ...(order.length ? { drawingChildOrder: order } : {}), ...(originalChildren.length ? { originalDrawingChildren: originalChildren } : {}), ...(hasGrayscale ? { grayscale: true } : {}), 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 3736db8a41..1e579653f9 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 @@ -107,6 +107,20 @@ import { imagesSetPositionWrapper, imagesSetAnchorOptionsWrapper, imagesSetZOrderWrapper, + imagesScaleWrapper, + imagesSetLockAspectRatioWrapper, + imagesRotateWrapper, + imagesFlipWrapper, + imagesCropWrapper, + imagesResetCropWrapper, + imagesReplaceSourceWrapper, + imagesSetAltTextWrapper, + imagesSetDecorativeWrapper, + imagesSetNameWrapper, + imagesSetHyperlinkWrapper, + imagesInsertCaptionWrapper, + imagesUpdateCaptionWrapper, + imagesRemoveCaptionWrapper, } from '../plan-engine/images-wrappers.js'; import { hyperlinksWrapWrapper, @@ -2154,6 +2168,102 @@ function makeHyperlinkEditor( } as unknown as Editor; } +/** + * Image editor with resolve + schema mocks for caption operations. + * @param opts.withCaption Add a `Caption`-styled paragraph after the image paragraph. + * @param opts.docChanged Mock tr.docChanged state (default true). + * @param opts.imageId Override the default image id. + * @param opts.extraAttrs Extra attrs merged onto the image node. + */ +function makeCaptionImageEditor( + opts: { withCaption?: boolean; docChanged?: boolean; imageId?: string; extraAttrs?: Record } = {}, +): Editor { + const imgId = opts.imageId ?? (opts.withCaption ? 'img-cap' : 'img-1'); + const imageNode = createNode('image', [], { + attrs: { + sdImageId: imgId, + 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 }, + ...opts.extraAttrs, + }, + isInline: true, + isLeaf: true, + }); + + const imgParagraph = createNode('paragraph', [imageNode], { + attrs: { sdBlockId: 'p-img' }, + isBlock: true, + inlineContent: true, + }); + + const children: ProseMirrorNode[] = [imgParagraph]; + + if (opts.withCaption) { + const captionText = createNode('text', [], { text: 'Old caption' }); + const captionParagraph = createNode('paragraph', [captionText], { + attrs: { sdBlockId: 'p-caption', paragraphProperties: { styleId: 'Caption' } }, + isBlock: true, + inlineContent: true, + }); + children.push(captionParagraph); + } + + const doc = createNode('doc', children, { isBlock: false }); + + // Add resolve mock — image is always at position 1 (inside paragraph at 0). + (doc as unknown as Record).resolve = () => ({ + depth: 2, + before: () => 0, + node: (d: number) => (d === 2 ? imgParagraph : doc), + }); + + const dispatch = vi.fn(); + const docChanged = opts.docChanged ?? true; + const tr = { + insert: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + replaceWith: vi.fn().mockReturnThis(), + setNodeMarkup: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged, + steps: docChanged ? [{}] : [], + doc, + }; + + return { + state: { + doc, + tr, + schema: { + nodes: { + paragraph: { + create: vi.fn((attrs: Record, content: unknown) => + createNode('paragraph', content ? [content as ProseMirrorNode] : [], { + attrs, + isBlock: true, + inlineContent: true, + }), + ), + }, + }, + text: vi.fn((t: string) => createNode('text', [], { text: t })), + }, + }, + dispatch, + commands: { setImage: vi.fn(() => true) }, + schema: { marks: {} }, + options: {}, + on: () => {}, + } as unknown as Editor; +} + const mutationVectors: Partial> = { 'blocks.delete': { throwCase: () => { @@ -5028,6 +5138,235 @@ const mutationVectors: Partial> = { { changeMode: 'direct' }, ), }, + // SD-2100: Image geometry, content, semantic & caption operations + // ------------------------------------------------------------------------- + 'images.scale': { + throwCase: () => + imagesScaleWrapper(makeImageEditor(), { imageId: 'missing', factor: 1.5 }, { changeMode: 'direct' }), + failureCase: () => { + // factor=1 produces identical dimensions → explicit NO_OP pre-check + return imagesScaleWrapper(makeImageEditor(), { imageId: 'img-1', factor: 1 }, { changeMode: 'direct' }); + }, + applyCase: () => imagesScaleWrapper(makeImageEditor(), { imageId: 'img-1', factor: 1.5 }, { changeMode: 'direct' }), + }, + 'images.setLockAspectRatio': { + throwCase: () => + imagesSetLockAspectRatioWrapper( + makeImageEditor(), + { imageId: 'missing', locked: false }, + { changeMode: 'direct' }, + ), + failureCase: () => { + // Default lockAspectRatio is true → NO_OP + return imagesSetLockAspectRatioWrapper( + makeImageEditor(), + { imageId: 'img-1', locked: true }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetLockAspectRatioWrapper(makeImageEditor(), { imageId: 'img-1', locked: false }, { changeMode: 'direct' }), + }, + 'images.rotate': { + throwCase: () => + imagesRotateWrapper(makeImageEditor(), { imageId: 'missing', angle: 90 }, { changeMode: 'direct' }), + failureCase: () => { + // No rotation set, angle=0 → NO_OP + return imagesRotateWrapper(makeImageEditor(), { imageId: 'img-1', angle: 0 }, { changeMode: 'direct' }); + }, + applyCase: () => imagesRotateWrapper(makeImageEditor(), { imageId: 'img-1', angle: 90 }, { changeMode: 'direct' }), + }, + 'images.flip': { + throwCase: () => + imagesFlipWrapper(makeImageEditor(), { imageId: 'missing', horizontal: true }, { changeMode: 'direct' }), + failureCase: () => { + // No transformData, passing false for both axes matches defaults → NO_OP + return imagesFlipWrapper( + makeImageEditor(), + { imageId: 'img-1', horizontal: false, vertical: false }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesFlipWrapper(makeImageEditor(), { imageId: 'img-1', horizontal: true }, { changeMode: 'direct' }), + }, + 'images.crop': { + throwCase: () => + imagesCropWrapper( + makeImageEditor(), + { imageId: 'missing', crop: { left: 10, top: 10, right: 10, bottom: 10 } }, + { 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 imagesCropWrapper( + editor, + { imageId: 'img-1', crop: { left: 10, top: 5, right: 10, bottom: 5 } }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesCropWrapper( + makeImageEditor(), + { imageId: 'img-1', crop: { left: 10, top: 5, right: 10, bottom: 5 } }, + { changeMode: 'direct' }, + ), + }, + 'images.resetCrop': { + throwCase: () => imagesResetCropWrapper(makeImageEditor(), { imageId: 'missing' }, { changeMode: 'direct' }), + failureCase: () => { + // No crop set → NO_OP + return imagesResetCropWrapper(makeImageEditor(), { imageId: 'img-1' }, { changeMode: 'direct' }); + }, + applyCase: () => { + // Image with crop data + const editor = makeCaptionImageEditor({ + imageId: 'img-cropped', + extraAttrs: { + clipPath: 'inset(5% 10% 5% 10%)', + rawSrcRect: { l: '10000', t: '5000', r: '10000', b: '5000' }, + }, + }); + return imagesResetCropWrapper(editor, { imageId: 'img-cropped' }, { changeMode: 'direct' }); + }, + }, + 'images.replaceSource': { + throwCase: () => + imagesReplaceSourceWrapper( + makeImageEditor(), + { imageId: 'missing', src: 'data:image/png;base64,abc' }, + { 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 imagesReplaceSourceWrapper( + editor, + { imageId: 'img-1', src: 'data:image/png;base64,abc' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesReplaceSourceWrapper( + makeImageEditor(), + { imageId: 'img-1', src: 'data:image/png;base64,abc' }, + { changeMode: 'direct' }, + ), + }, + 'images.setAltText': { + throwCase: () => + imagesSetAltTextWrapper( + makeImageEditor(), + { imageId: 'missing', description: 'Alt text' }, + { changeMode: 'direct' }, + ), + failureCase: () => { + // Same description → NO_OP + const editor = makeCaptionImageEditor({ extraAttrs: { title: 'Already set' } }); + return imagesSetAltTextWrapper( + editor, + { imageId: 'img-1', description: 'Already set' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetAltTextWrapper( + makeImageEditor(), + { imageId: 'img-1', description: 'New alt text' }, + { changeMode: 'direct' }, + ), + }, + 'images.setDecorative': { + throwCase: () => + imagesSetDecorativeWrapper(makeImageEditor(), { imageId: 'missing', decorative: true }, { changeMode: 'direct' }), + failureCase: () => { + // Default decorative is false → NO_OP + return imagesSetDecorativeWrapper( + makeImageEditor(), + { imageId: 'img-1', decorative: false }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetDecorativeWrapper(makeImageEditor(), { imageId: 'img-1', decorative: true }, { changeMode: 'direct' }), + }, + 'images.setName': { + throwCase: () => + imagesSetNameWrapper(makeImageEditor(), { imageId: 'missing', name: 'MyImage' }, { changeMode: 'direct' }), + failureCase: () => { + // Same name as existing alt attr → NO_OP + return imagesSetNameWrapper( + makeImageEditor(), + { imageId: 'img-1', name: 'Test image' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => + imagesSetNameWrapper(makeImageEditor(), { imageId: 'img-1', name: 'NewName' }, { changeMode: 'direct' }), + }, + 'images.setHyperlink': { + throwCase: () => + imagesSetHyperlinkWrapper( + makeImageEditor(), + { imageId: 'missing', url: 'https://example.com' }, + { changeMode: 'direct' }, + ), + failureCase: () => { + // No hyperlink set, removing → NO_OP + return imagesSetHyperlinkWrapper(makeImageEditor(), { imageId: 'img-1', url: null }, { changeMode: 'direct' }); + }, + applyCase: () => + imagesSetHyperlinkWrapper( + makeImageEditor(), + { imageId: 'img-1', url: 'https://example.com' }, + { changeMode: 'direct' }, + ), + }, + 'images.insertCaption': { + throwCase: () => + imagesInsertCaptionWrapper(makeImageEditor(), { imageId: 'missing', text: 'Caption' }, { changeMode: 'direct' }), + failureCase: () => { + // Transaction produces no change → NO_OP + const editor = makeCaptionImageEditor({ docChanged: false }); + return imagesInsertCaptionWrapper(editor, { imageId: 'img-1', text: 'Caption' }, { changeMode: 'direct' }); + }, + applyCase: () => { + const editor = makeCaptionImageEditor(); + return imagesInsertCaptionWrapper(editor, { imageId: 'img-1', text: 'Caption text' }, { changeMode: 'direct' }); + }, + }, + 'images.updateCaption': { + throwCase: () => + imagesUpdateCaptionWrapper(makeImageEditor(), { imageId: 'missing', text: 'Updated' }, { changeMode: 'direct' }), + failureCase: () => { + // Transaction produces no change → NO_OP + const editor = makeCaptionImageEditor({ withCaption: true, docChanged: false, imageId: 'img-cap' }); + return imagesUpdateCaptionWrapper(editor, { imageId: 'img-cap', text: 'Updated' }, { changeMode: 'direct' }); + }, + applyCase: () => { + const editor = makeCaptionImageEditor({ withCaption: true, imageId: 'img-cap' }); + return imagesUpdateCaptionWrapper(editor, { imageId: 'img-cap', text: 'New caption' }, { changeMode: 'direct' }); + }, + }, + 'images.removeCaption': { + throwCase: () => imagesRemoveCaptionWrapper(makeImageEditor(), { imageId: 'missing' }, { changeMode: 'direct' }), + failureCase: () => { + // No caption → NO_OP + const editor = makeCaptionImageEditor(); + return imagesRemoveCaptionWrapper(editor, { imageId: 'img-1' }, { changeMode: 'direct' }); + }, + applyCase: () => { + const editor = makeCaptionImageEditor({ withCaption: true, imageId: 'img-cap' }); + return imagesRemoveCaptionWrapper(editor, { imageId: 'img-cap' }, { changeMode: 'direct' }); + }, + }, }; const dryRunVectors: Partial unknown>> = { @@ -6338,6 +6677,162 @@ const dryRunVectors: Partial unknown>> = { expect(dispatch).not.toHaveBeenCalled(); return result; }, + + // ------------------------------------------------------------------------- + // SD-2100: Image geometry, content, semantic & caption — dryRun vectors + // ------------------------------------------------------------------------- + 'images.scale': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesScaleWrapper( + editor, + { imageId: 'img-1', factor: 1.5 }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setLockAspectRatio': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetLockAspectRatioWrapper( + editor, + { imageId: 'img-1', locked: false }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.rotate': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesRotateWrapper(editor, { imageId: 'img-1', angle: 90 }, { changeMode: 'direct', dryRun: true }); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.flip': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesFlipWrapper( + editor, + { imageId: 'img-1', horizontal: true }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.crop': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesCropWrapper( + editor, + { imageId: 'img-1', crop: { left: 10, top: 5, right: 10, bottom: 5 } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.resetCrop': () => { + const editor = makeCaptionImageEditor({ + imageId: 'img-cropped-dr', + extraAttrs: { + clipPath: 'inset(5% 10% 5% 10%)', + rawSrcRect: { l: '10000', t: '5000', r: '10000', b: '5000' }, + }, + }); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesResetCropWrapper( + editor, + { imageId: 'img-cropped-dr' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.replaceSource': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesReplaceSourceWrapper( + editor, + { imageId: 'img-1', src: 'data:image/png;base64,abc' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setAltText': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetAltTextWrapper( + editor, + { imageId: 'img-1', description: 'New alt text' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setDecorative': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetDecorativeWrapper( + editor, + { imageId: 'img-1', decorative: true }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setName': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetNameWrapper( + editor, + { imageId: 'img-1', name: 'NewName' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.setHyperlink': () => { + const editor = makeImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesSetHyperlinkWrapper( + editor, + { imageId: 'img-1', url: 'https://example.com' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.insertCaption': () => { + const editor = makeCaptionImageEditor(); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesInsertCaptionWrapper( + editor, + { imageId: 'img-1', text: 'Caption' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.updateCaption': () => { + const editor = makeCaptionImageEditor({ withCaption: true, imageId: 'img-cap' }); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesUpdateCaptionWrapper( + editor, + { imageId: 'img-cap', text: 'New caption' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, + 'images.removeCaption': () => { + const editor = makeCaptionImageEditor({ withCaption: true, imageId: 'img-cap' }); + const dispatch = (editor as unknown as { dispatch: ReturnType }).dispatch; + const result = imagesRemoveCaptionWrapper(editor, { imageId: 'img-cap' }, { changeMode: 'direct', dryRun: true }); + expect(dispatch).not.toHaveBeenCalled(); + return result; + }, }; beforeEach(() => { 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 649e063ea8..431420927e 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -177,6 +177,20 @@ import { imagesSetPositionWrapper, imagesSetAnchorOptionsWrapper, imagesSetZOrderWrapper, + imagesScaleWrapper, + imagesSetLockAspectRatioWrapper, + imagesRotateWrapper, + imagesFlipWrapper, + imagesCropWrapper, + imagesResetCropWrapper, + imagesReplaceSourceWrapper, + imagesSetAltTextWrapper, + imagesSetDecorativeWrapper, + imagesSetNameWrapper, + imagesSetHyperlinkWrapper, + imagesInsertCaptionWrapper, + imagesUpdateCaptionWrapper, + imagesRemoveCaptionWrapper, } from './plan-engine/images-wrappers.js'; import { hyperlinksListWrapper, @@ -393,6 +407,24 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters setPosition: (input, options) => imagesSetPositionWrapper(editor, input, options), setAnchorOptions: (input, options) => imagesSetAnchorOptionsWrapper(editor, input, options), setZOrder: (input, options) => imagesSetZOrderWrapper(editor, input, options), + // SD-2100: Geometry + scale: (input, options) => imagesScaleWrapper(editor, input, options), + setLockAspectRatio: (input, options) => imagesSetLockAspectRatioWrapper(editor, input, options), + rotate: (input, options) => imagesRotateWrapper(editor, input, options), + flip: (input, options) => imagesFlipWrapper(editor, input, options), + crop: (input, options) => imagesCropWrapper(editor, input, options), + resetCrop: (input, options) => imagesResetCropWrapper(editor, input, options), + // SD-2100: Content + replaceSource: (input, options) => imagesReplaceSourceWrapper(editor, input, options), + // SD-2100: Semantic metadata + setAltText: (input, options) => imagesSetAltTextWrapper(editor, input, options), + setDecorative: (input, options) => imagesSetDecorativeWrapper(editor, input, options), + setName: (input, options) => imagesSetNameWrapper(editor, input, options), + setHyperlink: (input, options) => imagesSetHyperlinkWrapper(editor, input, options), + // SD-2100: Caption lifecycle + insertCaption: (input, options) => imagesInsertCaptionWrapper(editor, input, options), + updateCaption: (input, options) => imagesUpdateCaptionWrapper(editor, input, options), + removeCaption: (input, options) => imagesRemoveCaptionWrapper(editor, input, options), }, hyperlinks: { list: (query) => hyperlinksListWrapper(editor, query), 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 9e67f457eb..e142d5fede 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -130,6 +130,24 @@ const REQUIRED_COMMANDS: Partial= doc.content.size) return false; + const nextNode = doc.nodeAt(afterParentPos); + if (!nextNode || nextNode.type.name !== 'paragraph') return false; + return nextNode.attrs?.paragraphProperties?.styleId === 'Caption'; + } catch { + return false; + } +} + +function buildImageInfo( + attrs: ImageAttrs | undefined, + kind: 'block' | 'inline', + doc?: ProseMirrorNode, + pos?: number, +): ImageNodeInfo { const isFloating = Boolean(attrs?.isAnchor); const wrapObj = attrs?.wrap; + const td = attrs?.transformData; + + const transform: ImageNodeInfo['properties']['transform'] = + td && (td.rotation || td.verticalFlip || td.horizontalFlip) + ? { + rotation: td.rotation ?? undefined, + verticalFlip: td.verticalFlip ?? undefined, + horizontalFlip: td.horizontalFlip ?? undefined, + } + : null; const properties: ImageNodeInfo['properties'] = { src: attrs?.src ?? undefined, @@ -254,6 +307,14 @@ function buildImageInfo(attrs: ImageAttrs | undefined, kind: 'block' | 'inline') anchorData: attrs?.anchorData ?? null, marginOffset: attrs?.marginOffset ?? null, relativeHeight: attrs?.relativeHeight ?? null, + name: attrs?.alt ?? undefined, + description: attrs?.title ?? undefined, + transform, + crop: parseCropFromClipPath(attrs?.clipPath), + lockAspectRatio: attrs?.lockAspectRatio ?? true, + decorative: attrs?.decorative ?? false, + hyperlink: attrs?.hyperlink ?? null, + hasCaption: pos != null ? detectCaptionSibling(doc, pos) : false, }; return { @@ -480,7 +541,11 @@ function isInlineCandidate(candidate: BlockCandidate | InlineCandidate): candida * @returns Typed node information with properties populated from node attributes. * @throws {Error} If the node type is not implemented or the candidate kind mismatches. */ -export function mapNodeInfo(candidate: BlockCandidate | InlineCandidate, overrideType?: NodeType): NodeInfo { +export function mapNodeInfo( + candidate: BlockCandidate | InlineCandidate, + overrideType?: NodeType, + doc?: ProseMirrorNode, +): NodeInfo { const nodeType: NodeType = overrideType ?? candidate.nodeType; const kind = isInlineCandidate(candidate) ? 'inline' : 'block'; @@ -511,7 +576,7 @@ export function mapNodeInfo(candidate: BlockCandidate | InlineCandidate, overrid return mapTableCellNode(candidate as BlockCandidate); case 'image': { const attrs = candidate.node?.attrs as ImageAttrs | undefined; - return buildImageInfo(attrs, kind); + return buildImageInfo(attrs, kind, doc, candidate.pos); } case 'sdt': { const attrs = candidate.node?.attrs as StructuredContentBlockAttrs | undefined; diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-info-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/node-info-resolver.ts index 76ce6b349a..f522448776 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/node-info-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/node-info-resolver.ts @@ -23,7 +23,7 @@ export function resolveNodeInfoForAddress( if (address.kind === 'block') { const candidate = findBlockById(index, address); if (!candidate) return undefined; - return mapNodeInfo(candidate, address.nodeType); + return mapNodeInfo(candidate, address.nodeType, editor.state.doc); } const resolvedInlineIndex = inlineIndex ?? getInlineIndex(editor); 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 index dc76b9a282..0532521638 100644 --- 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 @@ -29,6 +29,20 @@ import type { ImageAddress, ImageWrapType, ImageCreateLocation, + ScaleInput, + SetLockAspectRatioInput, + RotateInput, + FlipInput, + CropInput, + ResetCropInput, + ReplaceSourceInput, + SetAltTextInput, + SetDecorativeInput, + SetNameInput, + SetHyperlinkInput, + InsertCaptionInput, + UpdateCaptionInput, + RemoveCaptionInput, } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import { @@ -79,7 +93,37 @@ function buildNoOpResult(message: string): ImagesMutationResult { return { success: false, failure: { code: 'NO_OP', message } }; } -function buildImageSummary(candidate: ImageCandidate): ImageSummary { +function parseCropFromClipPath(clipPath: string | undefined | null) { + if (!clipPath) return null; + const match = clipPath.match(/^inset\(\s*([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s*\)$/); + if (!match) return null; + return { + top: parseFloat(match[1]), + right: parseFloat(match[2]), + bottom: parseFloat(match[3]), + left: parseFloat(match[4]), + }; +} + +function buildTransformInfo(td: Record | undefined) { + if (!td) return null; + if (!td.rotation && !td.verticalFlip && !td.horizontalFlip) return null; + return { + rotation: (td.rotation as number) ?? undefined, + verticalFlip: (td.verticalFlip as boolean) ?? undefined, + horizontalFlip: (td.horizontalFlip as boolean) ?? undefined, + }; +} + +function hasCaptionSibling(editor: Editor, imagePos: number): boolean { + try { + return findCaptionParagraph(editor, imagePos) !== null; + } catch { + return false; + } +} + +function buildImageSummary(editor: Editor, candidate: ImageCandidate): ImageSummary { const attrs = candidate.node.attrs; return { sdImageId: candidate.sdImageId, @@ -96,6 +140,14 @@ function buildImageSummary(candidate: ImageCandidate): ImageSummary { anchorData: attrs.anchorData ?? null, marginOffset: attrs.marginOffset ?? null, relativeHeight: attrs.relativeHeight ?? null, + name: attrs.alt ?? undefined, + description: attrs.title ?? undefined, + transform: buildTransformInfo(attrs.transformData), + crop: parseCropFromClipPath(attrs.clipPath), + lockAspectRatio: attrs.lockAspectRatio ?? true, + decorative: attrs.decorative ?? false, + hyperlink: attrs.hyperlink ?? null, + hasCaption: hasCaptionSibling(editor, candidate.pos), }, }; } @@ -155,13 +207,13 @@ export function imagesListWrapper(editor: Editor, input: ImagesListInput): Image 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); + const items = allImages.slice(offset, offset + limit).map((c) => buildImageSummary(editor, c)); return { total: allImages.length, items }; } export function imagesGetWrapper(editor: Editor, input: ImagesGetInput): ImageSummary { const image = findImageById(editor, input.imageId); - return buildImageSummary(image); + return buildImageSummary(editor, image); } // --------------------------------------------------------------------------- @@ -770,3 +822,526 @@ export function imagesSetZOrderWrapper( const updated = findImageById(editor, input.imageId); return buildSuccessResult(updated); } + +// =========================================================================== +// SD-2100: Geometry +// =========================================================================== + +export function imagesScaleWrapper(editor: Editor, input: ScaleInput, options?: MutationOptions): ImagesMutationResult { + rejectTrackedMode('images.scale', options); + + const image = findImageById(editor, input.imageId); + const currentSize = image.node.attrs.size; + if (!isFinitePositive(currentSize?.width) || !isFinitePositive(currentSize?.height)) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Image has no explicit size; use setSize first.'); + } + + const newSize = { + width: Math.round(currentSize.width * input.factor), + height: Math.round(currentSize.height * input.factor), + }; + + if (newSize.width === currentSize.width && newSize.height === currentSize.height) { + return buildNoOpResult('Scale produced no size change.'); + } + + 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: newSize }); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Scale produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +export function imagesSetLockAspectRatioWrapper( + editor: Editor, + input: SetLockAspectRatioInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setLockAspectRatio', options); + + const image = findImageById(editor, input.imageId); + if ((image.node.attrs.lockAspectRatio ?? true) === input.locked) { + return buildNoOpResult(`lockAspectRatio is already ${input.locked}.`); + } + + 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, lockAspectRatio: input.locked }); + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Set lock aspect ratio produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +export function imagesRotateWrapper( + editor: Editor, + input: RotateInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.rotate', options); + + const image = findImageById(editor, input.imageId); + const currentRotation = image.node.attrs.transformData?.rotation ?? 0; + if (currentRotation === input.angle) { + return buildNoOpResult(`Rotation is already ${input.angle} degrees.`); + } + + 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, + transformData: { ...(node.attrs.transformData ?? {}), rotation: input.angle }, + }); + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Rotate produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +export function imagesFlipWrapper(editor: Editor, input: FlipInput, options?: MutationOptions): ImagesMutationResult { + rejectTrackedMode('images.flip', options); + + const image = findImageById(editor, input.imageId); + const current = image.node.attrs.transformData ?? {}; + const targetH = input.horizontal ?? current.horizontalFlip ?? false; + const targetV = input.vertical ?? current.verticalFlip ?? false; + + if (targetH === (current.horizontalFlip ?? false) && targetV === (current.verticalFlip ?? false)) { + return buildNoOpResult('Flip state is already as requested.'); + } + + 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, + transformData: { ...(node.attrs.transformData ?? {}), horizontalFlip: targetH, verticalFlip: targetV }, + }); + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Flip produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +/** Convert crop percentages (0–100) to CSS `inset(top% right% bottom% left%)`. */ +function cropToClipPath(crop: CropInput['crop']): string { + const { top = 0, right = 0, bottom = 0, left = 0 } = crop; + return `inset(${top}% ${right}% ${bottom}% ${left}%)`; +} + +/** Build a raw OOXML `a:srcRect` element. OOXML uses 0–100,000 scale. */ +function cropToRawSrcRect(crop: CropInput['crop']) { + const { top = 0, right = 0, bottom = 0, left = 0 } = crop; + return { + name: 'a:srcRect', + attributes: { + l: String(Math.round(left * 1000)), + t: String(Math.round(top * 1000)), + r: String(Math.round(right * 1000)), + b: String(Math.round(bottom * 1000)), + }, + }; +} + +export function imagesCropWrapper(editor: Editor, input: CropInput, options?: MutationOptions): ImagesMutationResult { + rejectTrackedMode('images.crop', options); + + const image = findImageById(editor, input.imageId); + const newClipPath = cropToClipPath(input.crop); + + if (image.node.attrs.clipPath === newClipPath) { + return buildNoOpResult('Crop values are already as requested.'); + } + + 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, + clipPath: newClipPath, + rawSrcRect: cropToRawSrcRect(input.crop), + }); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Crop produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +export function imagesResetCropWrapper( + editor: Editor, + input: ResetCropInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.resetCrop', options); + + const image = findImageById(editor, input.imageId); + if (!image.node.attrs.clipPath) { + return buildNoOpResult('Image has no crop to reset.'); + } + + 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, clipPath: null, rawSrcRect: null, shouldCover: false }); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Reset crop produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +// =========================================================================== +// SD-2100: Content replacement +// =========================================================================== + +export function imagesReplaceSourceWrapper( + editor: Editor, + input: ReplaceSourceInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.replaceSource', options); + + const isDataUri = input.src.startsWith('data:'); + const isInternalPath = input.src.startsWith('word/media/'); + if (!isDataUri && !isInternalPath) { + throw new DocumentApiAdapterError( + 'INVALID_INPUT', + 'External URLs are not supported in V1; provide a data URI or internal media path.', + ); + } + + const image = findImageById(editor, input.imageId); + + const newAttrs: Record = { + ...image.node.attrs, + src: input.src, + rId: null, + clipPath: null, + rawSrcRect: null, + shouldCover: false, + }; + + if (input.resetSize) { + if (!isDataUri) { + throw new DocumentApiAdapterError( + 'INVALID_INPUT', + 'Cannot determine intrinsic dimensions from internal path; set size explicitly via setSize after replacement.', + ); + } + const dims = readImageDimensionsFromDataUri(input.src); + if (!dims) { + throw new DocumentApiAdapterError('INVALID_INPUT', 'Could not determine intrinsic dimensions from data URI.'); + } + newAttrs.size = { width: dims.width, height: dims.height }; + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos } = image; + const tr = editor.state.tr; + tr.setNodeMarkup(pos, undefined, newAttrs); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Replace source produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +// =========================================================================== +// SD-2100: Semantic metadata +// =========================================================================== + +export function imagesSetAltTextWrapper( + editor: Editor, + input: SetAltTextInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setAltText', options); + + const image = findImageById(editor, input.imageId); + if (image.node.attrs.title === input.description) { + return buildNoOpResult('Alt text is already as requested.'); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + // Setting alt text clears decorative flag (Word behavior) + tr.setNodeMarkup(pos, undefined, { ...node.attrs, title: input.description, decorative: false }); + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Set alt text produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +export function imagesSetDecorativeWrapper( + editor: Editor, + input: SetDecorativeInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setDecorative', options); + + const image = findImageById(editor, input.imageId); + if ((image.node.attrs.decorative ?? false) === input.decorative) { + return buildNoOpResult(`Decorative is already ${input.decorative}.`); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const { pos, node } = image; + const tr = editor.state.tr; + // Decorative images have no alt text (Word behavior) + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + decorative: input.decorative, + title: input.decorative ? '' : node.attrs.title, + }); + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Set decorative produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +export function imagesSetNameWrapper( + editor: Editor, + input: SetNameInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setName', options); + + const image = findImageById(editor, input.imageId); + if (image.node.attrs.alt === input.name) { + return buildNoOpResult('Name is already as requested.'); + } + + 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, alt: input.name }); + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Set name produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +export function imagesSetHyperlinkWrapper( + editor: Editor, + input: SetHyperlinkInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.setHyperlink', options); + + const image = findImageById(editor, input.imageId); + const newValue = input.url === null ? null : { url: input.url, ...(input.tooltip ? { tooltip: input.tooltip } : {}) }; + const current = image.node.attrs.hyperlink ?? null; + + if ( + (current === null && newValue === null) || + (current !== null && newValue !== null && current.url === newValue.url && current.tooltip === newValue.tooltip) + ) { + return buildNoOpResult('Hyperlink is already as requested.'); + } + + 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, hyperlink: newValue }); + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Set hyperlink produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +// =========================================================================== +// SD-2100: Caption lifecycle +// =========================================================================== + +/** Find the caption paragraph immediately following the image's parent paragraph. */ +function findCaptionParagraph(editor: Editor, imagePos: number) { + const $pos = editor.state.doc.resolve(imagePos); + const parentDepth = $pos.depth - 1; + if (parentDepth < 0) return null; + + const parentPos = $pos.before(parentDepth + 1); + const parentNode = $pos.node(parentDepth + 1); + const afterParentPos = parentPos + parentNode.nodeSize; + + if (afterParentPos >= editor.state.doc.content.size) return null; + + const nextNode = editor.state.doc.nodeAt(afterParentPos); + if (!nextNode || nextNode.type.name !== 'paragraph') return null; + if (nextNode.attrs?.paragraphProperties?.styleId !== 'Caption') return null; + + return { pos: afterParentPos, node: nextNode }; +} + +/** Verify the image is the sole inline content of its parent paragraph. */ +function requireSoleImageInParagraph(editor: Editor, imagePos: number): void { + const $pos = editor.state.doc.resolve(imagePos); + const parentDepth = $pos.depth - 1; + if (parentDepth < 0) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + 'Caption operations require the image to be inside a paragraph.', + ); + } + const parentNode = $pos.node(parentDepth + 1); + let inlineContentCount = 0; + parentNode.forEach((child) => { + if (child.isInline) inlineContentCount++; + }); + if (inlineContentCount !== 1) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + 'Caption operations require the image to be the sole content of its paragraph.', + ); + } +} + +export function imagesInsertCaptionWrapper( + editor: Editor, + input: InsertCaptionInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.insertCaption', options); + + const image = findImageById(editor, input.imageId); + requireSoleImageInParagraph(editor, image.pos); + + const existing = findCaptionParagraph(editor, image.pos); + if (existing) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Image already has a caption; use updateCaption.'); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const $pos = editor.state.doc.resolve(image.pos); + const parentDepth = $pos.depth - 1; + const parentPos = $pos.before(parentDepth + 1); + const parentNode = $pos.node(parentDepth + 1); + const afterParentPos = parentPos + parentNode.nodeSize; + + const tr = editor.state.tr; + const captionPara = editor.state.schema.nodes.paragraph.create( + { paragraphProperties: { styleId: 'Caption' } }, + editor.state.schema.text(input.text), + ); + tr.insert(afterParentPos, captionPara); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Insert caption produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +export function imagesUpdateCaptionWrapper( + editor: Editor, + input: UpdateCaptionInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.updateCaption', options); + + const image = findImageById(editor, input.imageId); + requireSoleImageInParagraph(editor, image.pos); + + const caption = findCaptionParagraph(editor, image.pos); + if (!caption) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'No caption paragraph found; use insertCaption first.'); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const tr = editor.state.tr; + const textStart = caption.pos + 1; + const textEnd = caption.pos + caption.node.nodeSize - 1; + tr.replaceWith(textStart, textEnd, editor.state.schema.text(input.text)); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Update caption produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} + +export function imagesRemoveCaptionWrapper( + editor: Editor, + input: RemoveCaptionInput, + options?: MutationOptions, +): ImagesMutationResult { + rejectTrackedMode('images.removeCaption', options); + + const image = findImageById(editor, input.imageId); + requireSoleImageInParagraph(editor, image.pos); + + const caption = findCaptionParagraph(editor, image.pos); + if (!caption) { + return buildNoOpResult('No caption to remove.'); + } + + if (options?.dryRun) return buildSuccessResult(image); + + const receipt = executeDomainCommand(editor, () => { + const tr = editor.state.tr; + tr.delete(caption.pos, caption.pos + caption.node.nodeSize); + if (!tr.docChanged) return false; + editor.dispatch(tr); + return true; + }); + + if (receipt.steps[0]?.effect !== 'changed') return buildNoOpResult('Remove caption produced no change.'); + return buildSuccessResult(findImageById(editor, input.imageId)); +} diff --git a/packages/super-editor/src/extensions/image/image.js b/packages/super-editor/src/extensions/image/image.js index f180065cc8..2daa1c8a5c 100644 --- a/packages/super-editor/src/extensions/image/image.js +++ b/packages/super-editor/src/extensions/image/image.js @@ -323,6 +323,24 @@ export const Image = Node.create({ default: null, rendered: false, }, + + /** Whether aspect ratio is locked. Maps to OOXML a:picLocks/@noChangeAspect. */ + lockAspectRatio: { + default: true, + rendered: false, + }, + + /** Decorative image flag. Maps to OOXML adec:decorative. */ + decorative: { + default: false, + rendered: false, + }, + + /** Image hyperlink. Maps to OOXML pic:cNvPr > a:hlinkClick. */ + hyperlink: { + default: null, + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index bd6d69a848..8d9ddeb0d1 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -497,6 +497,14 @@ export interface ImageAttrs extends ShapeNodeAttributes { shouldCover?: boolean; /** @internal Clip-path value for srcRect image crops */ clipPath?: string; + /** @internal Raw a:srcRect element for lossless round-trip export */ + rawSrcRect?: Record | null; + /** Whether aspect ratio is locked. Maps to OOXML a:picLocks/@noChangeAspect. */ + lockAspectRatio?: boolean; + /** Decorative image flag. Maps to OOXML adec:decorative. */ + decorative?: boolean; + /** Image hyperlink. Maps to OOXML pic:cNvPr > a:hlinkClick. */ + hyperlink?: { url: string; tooltip?: string } | null; } // ============================================ diff --git a/tests/doc-api-stories/tests/images/all-commands.ts b/tests/doc-api-stories/tests/images/all-commands.ts index 7343131a99..bd953dcfae 100644 --- a/tests/doc-api-stories/tests/images/all-commands.ts +++ b/tests/doc-api-stories/tests/images/all-commands.ts @@ -47,6 +47,24 @@ const ALL_IMAGE_COMMAND_IDS = [ 'images.setPosition', 'images.setAnchorOptions', 'images.setZOrder', + // SD-2100: Geometry + 'images.scale', + 'images.setLockAspectRatio', + 'images.rotate', + 'images.flip', + 'images.crop', + 'images.resetCrop', + // SD-2100: Content + 'images.replaceSource', + // SD-2100: Semantic metadata + 'images.setAltText', + 'images.setDecorative', + 'images.setName', + 'images.setHyperlink', + // SD-2100: Caption lifecycle + 'images.insertCaption', + 'images.updateCaption', + 'images.removeCaption', ] as const; type ImageCommandId = (typeof ALL_IMAGE_COMMAND_IDS)[number]; @@ -415,6 +433,267 @@ describe('document-api story: all image commands', () => { ); }, }, + + // ----------------------------------------------------------------- + // SD-2100: Geometry + // ----------------------------------------------------------------- + + { + operationId: 'images.scale', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.scale', fixture); + return unwrap( + await api.doc.images.scale({ + sessionId, + imageId: f.imageId, + factor: 0.5, + }), + ); + }, + }, + { + operationId: 'images.setLockAspectRatio', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.setLockAspectRatio', fixture); + return unwrap( + await api.doc.images.setLockAspectRatio({ + sessionId, + imageId: f.imageId, + locked: true, + }), + ); + }, + }, + { + operationId: 'images.rotate', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.rotate', fixture); + return unwrap( + await api.doc.images.rotate({ + sessionId, + imageId: f.imageId, + angle: 90, + }), + ); + }, + }, + { + operationId: 'images.flip', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.flip', fixture); + return unwrap( + await api.doc.images.flip({ + sessionId, + imageId: f.imageId, + horizontal: true, + }), + ); + }, + }, + { + operationId: 'images.crop', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.crop', fixture); + return unwrap( + await api.doc.images.crop({ + sessionId, + imageId: f.imageId, + crop: { left: 10, top: 10, right: 10, bottom: 10 }, + }), + ); + }, + }, + { + operationId: 'images.resetCrop', + setup: 'inlineImage', + prepare: async (sessionId, fixture) => { + const f = requireFixture('images.resetCrop', fixture); + // Apply a crop first so resetCrop has something to clear. + try { + unwrap( + await api.doc.images.crop({ + sessionId, + imageId: f.imageId, + crop: { left: 5, top: 5, right: 5, bottom: 5 }, + }), + ); + } catch { + /* crop may not be supported yet — resetCrop should still work */ + } + }, + run: async (sessionId, fixture) => { + const f = requireFixture('images.resetCrop', fixture); + return unwrap( + await api.doc.images.resetCrop({ + sessionId, + imageId: f.imageId, + }), + ); + }, + }, + + // ----------------------------------------------------------------- + // SD-2100: Content + // ----------------------------------------------------------------- + + { + operationId: 'images.replaceSource', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.replaceSource', fixture); + const src = await imageDataUri(); + return unwrap( + await api.doc.images.replaceSource({ + sessionId, + imageId: f.imageId, + src, + }), + ); + }, + }, + + // ----------------------------------------------------------------- + // SD-2100: Semantic metadata + // ----------------------------------------------------------------- + + { + operationId: 'images.setAltText', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.setAltText', fixture); + return unwrap( + await api.doc.images.setAltText({ + sessionId, + imageId: f.imageId, + description: 'A test image showing a butterfly logo', + }), + ); + }, + }, + { + operationId: 'images.setDecorative', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.setDecorative', fixture); + return unwrap( + await api.doc.images.setDecorative({ + sessionId, + imageId: f.imageId, + decorative: true, + }), + ); + }, + }, + { + operationId: 'images.setName', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.setName', fixture); + return unwrap( + await api.doc.images.setName({ + sessionId, + imageId: f.imageId, + name: 'Picture 42', + }), + ); + }, + }, + { + operationId: 'images.setHyperlink', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.setHyperlink', fixture); + return unwrap( + await api.doc.images.setHyperlink({ + sessionId, + imageId: f.imageId, + url: 'https://example.com', + tooltip: 'Visit example', + }), + ); + }, + }, + + // ----------------------------------------------------------------- + // SD-2100: Caption lifecycle + // ----------------------------------------------------------------- + + { + operationId: 'images.insertCaption', + setup: 'inlineImage', + run: async (sessionId, fixture) => { + const f = requireFixture('images.insertCaption', fixture); + return unwrap( + await api.doc.images.insertCaption({ + sessionId, + imageId: f.imageId, + text: 'Figure 1: Test image caption', + }), + ); + }, + }, + { + operationId: 'images.updateCaption', + setup: 'inlineImage', + prepare: async (sessionId, fixture) => { + const f = requireFixture('images.updateCaption', fixture); + // Insert a caption first so updateCaption has something to modify. + try { + unwrap( + await api.doc.images.insertCaption({ + sessionId, + imageId: f.imageId, + text: 'Original caption', + }), + ); + } catch { + /* caption may already exist — fine */ + } + }, + run: async (sessionId, fixture) => { + const f = requireFixture('images.updateCaption', fixture); + return unwrap( + await api.doc.images.updateCaption({ + sessionId, + imageId: f.imageId, + text: 'Updated caption text', + }), + ); + }, + }, + { + operationId: 'images.removeCaption', + setup: 'inlineImage', + prepare: async (sessionId, fixture) => { + const f = requireFixture('images.removeCaption', fixture); + // Insert a caption first so removeCaption has something to remove. + try { + unwrap( + await api.doc.images.insertCaption({ + sessionId, + imageId: f.imageId, + text: 'Caption to be removed', + }), + ); + } catch { + /* caption may already exist — fine */ + } + }, + run: async (sessionId, fixture) => { + const f = requireFixture('images.removeCaption', fixture); + return unwrap( + await api.doc.images.removeCaption({ + sessionId, + imageId: f.imageId, + }), + ); + }, + }, ]; // -- coverage check -------------------------------------------------------- From 19219a8e8a24760482aac5e2bf17db29f7152534 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 4 Mar 2026 17:41:08 -0800 Subject: [PATCH 2/2] chore: review fixes --- .../wp/helpers/decode-image-node-helpers.js | 56 +++- .../helpers/decode-image-node-helpers.test.js | 109 +++++++ .../wp/helpers/encode-image-node-helpers.js | 12 +- .../helpers/encode-image-node-helpers.test.js | 152 ++++++++++ .../plan-engine/images-wrappers.test.ts | 269 ++++++++++++++++++ .../plan-engine/images-wrappers.ts | 8 +- pnpm-lock.yaml | 166 ----------- 7 files changed, 584 insertions(+), 188 deletions(-) create mode 100644 packages/super-editor/src/document-api-adapters/plan-engine/images-wrappers.test.ts 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 c3ae502480..9e834481c1 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 @@ -9,13 +9,35 @@ const DECORATIVE_EXT_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}'; const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/decorative'; const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'; +/** + * Resolve the hyperlink relationship rId for an image, if applicable. + * Called once so that both wp:docPr and pic:cNvPr share the same rId. + */ +function resolveHyperlinkRId(attrs, params) { + if (!attrs.hyperlink?.url || !params) return null; + return addHyperlinkRelationship(params, attrs.hyperlink.url); +} + +/** + * Build an `a:hlinkClick` element from attrs.hyperlink and a pre-resolved rId. + */ +function buildHlinkClickElement(attrs, hlinkRId) { + if (!hlinkRId) return null; + const hlinkAttrs = { 'r:id': hlinkRId }; + if (attrs.hyperlink?.tooltip) { + hlinkAttrs.tooltip = attrs.hyperlink.tooltip; + } + return { name: 'a:hlinkClick', attributes: hlinkAttrs }; +} + /** * Build the `wp:docPr` element with correct attribute mappings: * - `@name` ← attrs.alt (object name, wp:docPr/@name) * - `@descr` ← attrs.title (accessibility description, wp:docPr/@descr) — omitted when decorative + * - `a:hlinkClick` child when hyperlink is set (Word's canonical placement per §20.4.2.5) * - Decorative extension child when attrs.decorative is true */ -function buildDocPrElement(attrs, imageName) { +function buildDocPrElement(attrs, imageName, hlinkRId) { const docPrAttrs = { id: attrs.id || 0, name: attrs.alt || `Picture ${imageName}`, @@ -26,6 +48,11 @@ function buildDocPrElement(attrs, imageName) { } const children = []; + + // Emit a:hlinkClick in wp:docPr — Word's canonical placement (§20.4.2.5). + const hlinkEl = buildHlinkClickElement(attrs, hlinkRId); + if (hlinkEl) children.push(hlinkEl); + if (attrs.decorative) { children.push({ name: 'a:extLst', @@ -54,20 +81,14 @@ function buildDocPrElement(attrs, imageName) { /** * Build the `pic:nvPicPr` element with: * - `pic:cNvPr/@name` ← attrs.alt (object name, mirrors wp:docPr/@name) - * - `a:hlinkClick` child when attrs.hyperlink is set + * - `a:hlinkClick` child when hyperlink is set (mirrors wp:docPr for compatibility) * - `a:picLocks/@noChangeAspect` ← dynamic from attrs.lockAspectRatio */ -function buildNvPicPrElement(attrs, imageName, params) { +function buildNvPicPrElement(attrs, imageName, hlinkRId) { // --- pic:cNvPr children (hyperlink) --- const cNvPrChildren = []; - if (attrs.hyperlink?.url) { - const hlinkRId = addHyperlinkRelationship(params, attrs.hyperlink.url); - const hlinkAttrs = { 'r:id': hlinkRId }; - if (attrs.hyperlink.tooltip) { - hlinkAttrs.tooltip = attrs.hyperlink.tooltip; - } - cNvPrChildren.push({ name: 'a:hlinkClick', attributes: hlinkAttrs }); - } + const hlinkEl = buildHlinkClickElement(attrs, hlinkRId); + if (hlinkEl) cNvPrChildren.push(hlinkEl); return { name: 'pic:nvPicPr', @@ -86,7 +107,9 @@ function buildNvPicPrElement(attrs, imageName, params) { { name: 'a:picLocks', attributes: { - noChangeAspect: attrs.lockAspectRatio === false ? 0 : 1, + // Per OOXML §20.1.2.2.31, noChangeAspect defaults to false (unlocked). + // Only emit "1" when explicitly locked; omit when false/undefined to preserve round-trip fidelity. + ...(attrs.lockAspectRatio ? { noChangeAspect: 1 } : {}), noChangeArrowheads: 1, }, }, @@ -256,6 +279,9 @@ export const translateImageNode = (params) => { const drawingXmlns = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const pictureXmlns = 'http://schemas.openxmlformats.org/drawingml/2006/picture'; + // Resolve hyperlink relationship once; shared by wp:docPr and pic:cNvPr. + const hlinkRId = resolveHyperlinkRId(attrs, params); + return { attributes: inlineAttrs, elements: [ @@ -270,7 +296,7 @@ export const translateImageNode = (params) => { name: 'wp:effectExtent', attributes: effectExtentAttrs, }, - buildDocPrElement(attrs, imageName), + buildDocPrElement(attrs, imageName, hlinkRId), { name: 'wp:cNvGraphicFramePr', elements: [ @@ -278,7 +304,7 @@ export const translateImageNode = (params) => { name: 'a:graphicFrameLocks', attributes: { 'xmlns:a': drawingXmlns, - noChangeAspect: attrs.lockAspectRatio === false ? 0 : 1, + ...(attrs.lockAspectRatio ? { noChangeAspect: 1 } : {}), }, }, ], @@ -295,7 +321,7 @@ export const translateImageNode = (params) => { name: 'pic:pic', attributes: { 'xmlns:pic': pictureXmlns }, elements: [ - buildNvPicPrElement(attrs, imageName, params), + buildNvPicPrElement(attrs, imageName, hlinkRId), { name: 'pic:blipFill', elements: [ diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index a6c0a36b1a..6e45927df2 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -200,6 +200,115 @@ describe('translateImageNode', () => { const hlinkClick = cNvPr?.elements?.find((e) => e.name === 'a:hlinkClick'); expect(hlinkClick).toBeUndefined(); }); + + it('should emit a:hlinkClick in wp:docPr (Word canonical placement)', () => { + baseParams.node.attrs.hyperlink = { url: 'https://example.com', tooltip: 'Visit' }; + + const result = translateImageNode(baseParams); + + const docPr = result.elements.find((e) => e.name === 'wp:docPr'); + const hlinkClick = docPr.elements?.find((e) => e.name === 'a:hlinkClick'); + expect(hlinkClick).toBeDefined(); + expect(hlinkClick.attributes['r:id']).toBeDefined(); + expect(hlinkClick.attributes.tooltip).toBe('Visit'); + }); + + it('should use same rId for a:hlinkClick in both wp:docPr and pic:cNvPr', () => { + baseParams.node.attrs.hyperlink = { url: 'https://example.com' }; + + const result = translateImageNode(baseParams); + + const docPr = result.elements.find((e) => e.name === 'wp:docPr'); + const docPrHlink = docPr.elements?.find((e) => e.name === 'a:hlinkClick'); + + const graphic = result.elements.find((e) => e.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + const nvPicPr = picPic.elements.find((e) => e.name === 'pic:nvPicPr'); + const cNvPr = nvPicPr.elements.find((e) => e.name === 'pic:cNvPr'); + const cNvPrHlink = cNvPr.elements?.find((e) => e.name === 'a:hlinkClick'); + + expect(docPrHlink).toBeDefined(); + expect(cNvPrHlink).toBeDefined(); + // Both should reference the exact same relationship ID + expect(docPrHlink.attributes['r:id']).toBe(cNvPrHlink.attributes['r:id']); + // Only one hyperlink relationship should be created + const hlinkRels = baseParams.relationships.filter( + (r) => r.attributes.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + ); + expect(hlinkRels).toHaveLength(1); + }); + + it('should not emit a:hlinkClick in wp:docPr when no hyperlink', () => { + const result = translateImageNode(baseParams); + + const docPr = result.elements.find((e) => e.name === 'wp:docPr'); + const hlinkClick = docPr?.elements?.find((e) => e.name === 'a:hlinkClick'); + expect(hlinkClick).toBeUndefined(); + }); + + describe('noChangeAspect export', () => { + it('should emit noChangeAspect=1 in a:picLocks when lockAspectRatio is true', () => { + baseParams.node.attrs.lockAspectRatio = true; + + const result = translateImageNode(baseParams); + + const graphic = result.elements.find((e) => e.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + const nvPicPr = picPic.elements.find((e) => e.name === 'pic:nvPicPr'); + const cNvPicPr = nvPicPr.elements.find((e) => e.name === 'pic:cNvPicPr'); + const picLocks = cNvPicPr.elements.find((e) => e.name === 'a:picLocks'); + expect(picLocks.attributes.noChangeAspect).toBe(1); + }); + + it('should NOT emit noChangeAspect in a:picLocks when lockAspectRatio is false', () => { + baseParams.node.attrs.lockAspectRatio = false; + + const result = translateImageNode(baseParams); + + const graphic = result.elements.find((e) => e.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + const nvPicPr = picPic.elements.find((e) => e.name === 'pic:nvPicPr'); + const cNvPicPr = nvPicPr.elements.find((e) => e.name === 'pic:cNvPicPr'); + const picLocks = cNvPicPr.elements.find((e) => e.name === 'a:picLocks'); + expect(picLocks.attributes.noChangeAspect).toBeUndefined(); + // noChangeArrowheads should still be present + expect(picLocks.attributes.noChangeArrowheads).toBe(1); + }); + + it('should NOT emit noChangeAspect in a:picLocks when lockAspectRatio is undefined', () => { + // lockAspectRatio not set at all + delete baseParams.node.attrs.lockAspectRatio; + + const result = translateImageNode(baseParams); + + const graphic = result.elements.find((e) => e.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + const nvPicPr = picPic.elements.find((e) => e.name === 'pic:nvPicPr'); + const cNvPicPr = nvPicPr.elements.find((e) => e.name === 'pic:cNvPicPr'); + const picLocks = cNvPicPr.elements.find((e) => e.name === 'a:picLocks'); + expect(picLocks.attributes.noChangeAspect).toBeUndefined(); + }); + + it('should NOT emit noChangeAspect in a:graphicFrameLocks when lockAspectRatio is false', () => { + baseParams.node.attrs.lockAspectRatio = false; + + const result = translateImageNode(baseParams); + + const framePr = result.elements.find((e) => e.name === 'wp:cNvGraphicFramePr'); + const frameLocks = framePr.elements.find((e) => e.name === 'a:graphicFrameLocks'); + expect(frameLocks.attributes.noChangeAspect).toBeUndefined(); + }); + + it('should emit noChangeAspect=1 in a:graphicFrameLocks when lockAspectRatio is true', () => { + baseParams.node.attrs.lockAspectRatio = true; + + const result = translateImageNode(baseParams); + + const framePr = result.elements.find((e) => e.name === 'wp:cNvGraphicFramePr'); + const frameLocks = framePr.elements.find((e) => e.name === 'a:graphicFrameLocks'); + expect(frameLocks.attributes.noChangeAspect).toBe(1); + }); + }); }); describe('translateVectorShape', () => { 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 b89dd81a3a..20825402b2 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 @@ -370,14 +370,18 @@ export function handleImageNode(node, params, isAnchor) { const nvPicPr = picture.elements.find((el) => el.name === 'pic:nvPicPr'); const cNvPicPr = nvPicPr?.elements?.find((el) => el.name === 'pic:cNvPicPr'); const picLocks = cNvPicPr?.elements?.find((el) => el.name === 'a:picLocks'); - // Default true (Word's default). Only false when a:picLocks is present but noChangeAspect is '0' or absent. + // Per OOXML §20.1.2.2.31, noChangeAspect defaults to false when not specified. + // When a:picLocks is absent entirely, there is no lock → false. const lockAspectRatio = picLocks ? picLocks.attributes?.['noChangeAspect'] === '1' || picLocks.attributes?.['noChangeAspect'] === 1 - : true; + : false; - // Parse image hyperlink from pic:cNvPr > a:hlinkClick + // Parse image hyperlink from pic:cNvPr > a:hlinkClick, falling back to + // wp:docPr > a:hlinkClick (Word's canonical placement per §20.4.2.5). const cNvPr = nvPicPr?.elements?.find((el) => el.name === 'pic:cNvPr'); - const hlinkClick = cNvPr?.elements?.find((el) => el.name === 'a:hlinkClick'); + const hlinkClick = + cNvPr?.elements?.find((el) => el.name === 'a:hlinkClick') || + docPr?.elements?.find((el) => el.name === 'a:hlinkClick'); let hyperlink = null; if (hlinkClick?.attributes?.['r:id']) { const hlinkRId = hlinkClick.attributes['r:id']; 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 335adb47a6..a07ef52706 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 @@ -1101,6 +1101,158 @@ describe('handleImageNode', () => { expect(result).not.toBeNull(); expect(result.attrs.grayscale).toBeUndefined(); }); + + describe('lockAspectRatio / noChangeAspect import defaults', () => { + it('defaults lockAspectRatio to false when a:picLocks element is absent', () => { + const node = makeNode(); + const graphic = node.elements.find((el) => el.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + picPic.elements = [ + { + name: 'pic:nvPicPr', + elements: [ + { name: 'pic:cNvPr', attributes: { id: '1', name: 'Pic' } }, + { name: 'pic:cNvPicPr', elements: [] }, + ], + }, + ...(picPic.elements || []), + ]; + + const result = handleImageNode(node, makeParams(), false); + + expect(result.attrs.lockAspectRatio).toBe(false); + }); + + it('sets lockAspectRatio to true when noChangeAspect="1"', () => { + const node = makeNode(); + const graphic = node.elements.find((el) => el.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + picPic.elements = [ + { + name: 'pic:nvPicPr', + elements: [ + { name: 'pic:cNvPr', attributes: { id: '1', name: 'Pic' } }, + { + name: 'pic:cNvPicPr', + elements: [{ name: 'a:picLocks', attributes: { noChangeAspect: '1' } }], + }, + ], + }, + ...(picPic.elements || []), + ]; + + const result = handleImageNode(node, makeParams(), false); + + expect(result.attrs.lockAspectRatio).toBe(true); + }); + + it('sets lockAspectRatio to false when a:picLocks exists but noChangeAspect is absent', () => { + const node = makeNode(); + const graphic = node.elements.find((el) => el.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + picPic.elements = [ + { + name: 'pic:nvPicPr', + elements: [ + { name: 'pic:cNvPr', attributes: { id: '1', name: 'Pic' } }, + { + name: 'pic:cNvPicPr', + elements: [{ name: 'a:picLocks', attributes: { noChangeArrowheads: '1' } }], + }, + ], + }, + ...(picPic.elements || []), + ]; + + const result = handleImageNode(node, makeParams(), false); + + expect(result.attrs.lockAspectRatio).toBe(false); + }); + }); + + describe('hyperlink import from wp:docPr fallback', () => { + it('reads a:hlinkClick from wp:docPr when pic:cNvPr has none', () => { + const hlinkRId = 'rIdHlink1'; + const node = makeNode(); + const docPr = node.elements.find((el) => el.name === 'wp:docPr'); + docPr.elements = [{ name: 'a:hlinkClick', attributes: { 'r:id': hlinkRId, tooltip: 'Click me' } }]; + const graphic = node.elements.find((el) => el.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + picPic.elements = [ + { + name: 'pic:nvPicPr', + elements: [ + { name: 'pic:cNvPr', attributes: { id: '1', name: 'Pic' } }, + { name: 'pic:cNvPicPr', elements: [] }, + ], + }, + ...(picPic.elements || []), + ]; + + const params = makeParams(); + params.docx['word/_rels/document.xml.rels'].elements[0].elements.push({ + name: 'Relationship', + attributes: { + Id: hlinkRId, + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + Target: 'https://example.com', + TargetMode: 'External', + }, + }); + + const result = handleImageNode(node, params, false); + + expect(result.attrs.hyperlink).toEqual({ url: 'https://example.com', tooltip: 'Click me' }); + }); + + it('prefers pic:cNvPr a:hlinkClick over wp:docPr a:hlinkClick', () => { + const node = makeNode(); + const docPr = node.elements.find((el) => el.name === 'wp:docPr'); + docPr.elements = [{ name: 'a:hlinkClick', attributes: { 'r:id': 'rIdDocPr', tooltip: 'DocPr link' } }]; + const graphic = node.elements.find((el) => el.name === 'a:graphic'); + const picPic = graphic.elements[0].elements[0]; + picPic.elements = [ + { + name: 'pic:nvPicPr', + elements: [ + { + name: 'pic:cNvPr', + attributes: { id: '1', name: 'Pic' }, + elements: [{ name: 'a:hlinkClick', attributes: { 'r:id': 'rIdCNvPr', tooltip: 'CNvPr link' } }], + }, + { name: 'pic:cNvPicPr', elements: [] }, + ], + }, + ...(picPic.elements || []), + ]; + + const params = makeParams(); + params.docx['word/_rels/document.xml.rels'].elements[0].elements.push( + { + name: 'Relationship', + attributes: { + Id: 'rIdCNvPr', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + Target: 'https://cNvPr.example.com', + TargetMode: 'External', + }, + }, + { + name: 'Relationship', + attributes: { + Id: 'rIdDocPr', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + Target: 'https://docPr.example.com', + TargetMode: 'External', + }, + }, + ); + + const result = handleImageNode(node, params, false); + + expect(result.attrs.hyperlink).toEqual({ url: 'https://cNvPr.example.com', tooltip: 'CNvPr link' }); + }); + }); }); describe('getVectorShape', () => { diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/images-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/images-wrappers.test.ts new file mode 100644 index 0000000000..598a66bb00 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/images-wrappers.test.ts @@ -0,0 +1,269 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { registerBuiltInExecutors } from './register-executors.js'; +import { imagesScaleWrapper, imagesReplaceSourceWrapper, imagesSetAltTextWrapper } from './images-wrappers.js'; + +// Ensure the domain.command executor is registered for executeDomainCommand +registerBuiltInExecutors(); + +// --------------------------------------------------------------------------- +// Mock node builder +// --------------------------------------------------------------------------- + +type NodeOptions = { + attrs?: Record; + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; + nodeSize?: number; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const attrs = options.attrs ?? {}; + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + + const node = { + type: { name: typeName }, + attrs, + text: isText ? text : undefined, + content: { size: contentSize }, + nodeSize, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + function walk(childNodes: ProseMirrorNode[], baseOffset: number) { + let offset = baseOffset; + for (const child of childNodes) { + callback(child, offset); + const grandchildren = (child as any)._children; + if (grandchildren?.length) { + walk(grandchildren, offset + 1); + } + offset += child.nodeSize; + } + } + walk(children, 0); + }, + } as unknown as ProseMirrorNode; + + (node as any)._children = children; + return node; +} + +// --------------------------------------------------------------------------- +// Mock editor builder +// --------------------------------------------------------------------------- + +function createImageNode(attrs: Record = {}): ProseMirrorNode { + return createNode('image', [], { + attrs: { + sdImageId: 'img-1', + src: 'data:image/png;base64,ABC', + isAnchor: false, + size: { width: 200, height: 100 }, + wrap: { type: 'Inline' }, + ...attrs, + }, + isInline: true, + isLeaf: true, + }); +} + +function makeImageEditor(imageAttrs: Record = {}): { + editor: Editor; + dispatch: ReturnType; + setNodeMarkup: ReturnType; + capturedAttrs: () => Record | undefined; +} { + const imageNode = createImageNode(imageAttrs); + const paragraph = createNode('paragraph', [imageNode], { + attrs: { paraId: 'p1', sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const setNodeMarkup = vi.fn(); + let lastAttrs: Record | undefined; + + const dispatch = vi.fn(); + const tr = { + setNodeMarkup: (...args: unknown[]) => { + setNodeMarkup(...args); + lastAttrs = args[2] as Record; + (tr as any).docChanged = true; + return tr; + }, + delete: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: false, + }; + + const editor = { + state: { + doc, + get tr() { + (tr as any).docChanged = false; + return tr; + }, + }, + dispatch, + commands: {}, + helpers: {}, + } as unknown as Editor; + + return { + editor, + dispatch, + setNodeMarkup, + capturedAttrs: () => lastAttrs, + }; +} + +// --------------------------------------------------------------------------- +// Regression: images.scale must not produce zero dimensions +// --------------------------------------------------------------------------- + +describe('imagesScaleWrapper', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('floors dimensions to 1px when a very small factor would round to zero', () => { + const { editor } = makeImageEditor({ size: { width: 3, height: 2 } }); + const result = imagesScaleWrapper(editor, { imageId: 'img-1', factor: 0.001 }); + + // Should succeed (not produce 0-dimension) and floor to 1×1 + expect(result.success).toBe(true); + }); + + it('floors a single axis to 1px when only one dimension would round to zero', () => { + // width=100 * 0.004 = 0.4 → round = 0 → max(1,0) = 1 + // height=200 * 0.004 = 0.8 → round = 1 → 1 + const { editor, capturedAttrs } = makeImageEditor({ size: { width: 100, height: 200 } }); + const result = imagesScaleWrapper(editor, { imageId: 'img-1', factor: 0.004 }); + + expect(result.success).toBe(true); + const newSize = capturedAttrs()?.size as { width: number; height: number }; + expect(newSize.width).toBeGreaterThanOrEqual(1); + expect(newSize.height).toBeGreaterThanOrEqual(1); + }); + + it('scales normally for reasonable factors', () => { + const { editor, capturedAttrs } = makeImageEditor({ size: { width: 200, height: 100 } }); + const result = imagesScaleWrapper(editor, { imageId: 'img-1', factor: 2 }); + + expect(result.success).toBe(true); + expect(capturedAttrs()?.size).toEqual({ width: 400, height: 200 }); + }); +}); + +// --------------------------------------------------------------------------- +// Regression: images.replaceSource must clear originalSrc/originalExtension +// --------------------------------------------------------------------------- + +describe('imagesReplaceSourceWrapper', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('clears originalSrc and originalExtension so export uses the new source', () => { + const { editor, capturedAttrs } = makeImageEditor({ + originalSrc: 'word/media/image1.emf', + originalExtension: '.emf', + }); + + imagesReplaceSourceWrapper(editor, { + imageId: 'img-1', + src: 'data:image/png;base64,NEWDATA', + }); + + const attrs = capturedAttrs()!; + expect(attrs.originalSrc).toBeNull(); + expect(attrs.originalExtension).toBeNull(); + expect(attrs.src).toBe('data:image/png;base64,NEWDATA'); + expect(attrs.rId).toBeNull(); + }); + + it('clears originalSrc even when replacing with an internal media path', () => { + const { editor, capturedAttrs } = makeImageEditor({ + originalSrc: 'word/media/image1.wmf', + originalExtension: '.wmf', + }); + + imagesReplaceSourceWrapper(editor, { + imageId: 'img-1', + src: 'word/media/image2.png', + }); + + const attrs = capturedAttrs()!; + expect(attrs.originalSrc).toBeNull(); + expect(attrs.originalExtension).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Regression: images.setAltText must not no-op when decorative needs clearing +// --------------------------------------------------------------------------- + +describe('imagesSetAltTextWrapper', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('clears decorative flag even when title already matches the description', () => { + // Image is decorative with title = '' (empty). Setting alt text to '' should + // still clear decorative, not return NO_OP. + const { editor, capturedAttrs } = makeImageEditor({ + title: '', + decorative: true, + }); + + const result = imagesSetAltTextWrapper(editor, { imageId: 'img-1', description: '' }); + + expect(result.success).toBe(true); + const attrs = capturedAttrs()!; + expect(attrs.decorative).toBe(false); + }); + + it('clears decorative flag when setting non-empty description on a decorative image', () => { + const { editor, capturedAttrs } = makeImageEditor({ + title: '', + decorative: true, + }); + + const result = imagesSetAltTextWrapper(editor, { imageId: 'img-1', description: 'A photo of a sunset' }); + + expect(result.success).toBe(true); + const attrs = capturedAttrs()!; + expect(attrs.title).toBe('A photo of a sunset'); + expect(attrs.decorative).toBe(false); + }); + + it('returns no-op when title matches and image is not decorative', () => { + const { editor } = makeImageEditor({ + title: 'Already set', + decorative: false, + }); + + const result = imagesSetAltTextWrapper(editor, { imageId: 'img-1', description: 'Already set' }); + + expect(result.success).toBe(false); + expect((result as any).failure?.code).toBe('NO_OP'); + }); +}); 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 index 0532521638..35b72e1c71 100644 --- 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 @@ -837,8 +837,8 @@ export function imagesScaleWrapper(editor: Editor, input: ScaleInput, options?: } const newSize = { - width: Math.round(currentSize.width * input.factor), - height: Math.round(currentSize.height * input.factor), + width: Math.max(1, Math.round(currentSize.width * input.factor)), + height: Math.max(1, Math.round(currentSize.height * input.factor)), }; if (newSize.width === currentSize.width && newSize.height === currentSize.height) { @@ -1047,6 +1047,8 @@ export function imagesReplaceSourceWrapper( ...image.node.attrs, src: input.src, rId: null, + originalSrc: null, + originalExtension: null, clipPath: null, rawSrcRect: null, shouldCover: false, @@ -1093,7 +1095,7 @@ export function imagesSetAltTextWrapper( rejectTrackedMode('images.setAltText', options); const image = findImageById(editor, input.imageId); - if (image.node.attrs.title === input.description) { + if (image.node.attrs.title === input.description && !image.node.attrs.decorative) { return buildNoOpResult('Alt text is already as requested.'); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a6b03b744..12b1a2f414 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,66 +565,6 @@ importers: specifier: ^14.2.0 version: 14.2.5 - examples/llm-mvp: - dependencies: - '@superdoc-dev/sdk': - specifier: workspace:* - version: link:../../packages/sdk/langs/node - openai: - specifier: ^4.77.0 - version: 4.104.0(ws@8.19.0)(zod@3.24.0) - devDependencies: - tsx: - specifier: ^4.19.0 - version: 4.21.0 - - examples/llm-sdk-test: - dependencies: - '@anthropic-ai/sdk': - specifier: ^0.78.0 - version: 0.78.0(zod@4.3.6) - '@superdoc-dev/sdk': - specifier: workspace:* - version: link:../../packages/sdk/langs/node - '@superdoc/document-api': - specifier: workspace:* - version: link:../../packages/document-api - '@superdoc/super-editor': - specifier: workspace:* - version: link:../../packages/super-editor - openai: - specifier: ^4.77.0 - version: 4.104.0(ws@8.19.0)(zod@3.24.0) - superdoc: - specifier: workspace:* - version: link:../../packages/superdoc - devDependencies: - tsx: - specifier: ^4.19.0 - version: 4.21.0 - - examples/llm-tools-test: - dependencies: - '@anthropic-ai/sdk': - specifier: ^0.78.0 - version: 0.78.0(zod@4.3.6) - '@superdoc-dev/sdk': - specifier: workspace:* - version: link:../../packages/sdk/langs/node - '@superdoc/document-api': - specifier: workspace:* - version: link:../../packages/document-api - '@superdoc/super-editor': - specifier: workspace:* - version: link:../../packages/super-editor - superdoc: - specifier: workspace:* - version: link:../../packages/superdoc - devDependencies: - tsx: - specifier: ^4.19.0 - version: 4.21.0 - packages/ai: devDependencies: '@types/node': @@ -1588,15 +1528,6 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@anthropic-ai/sdk@0.78.0': - resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==} - hasBin: true - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - '@ark/schema@0.55.0': resolution: {integrity: sha512-IlSIc0FmLKTDGr4I/FzNHauMn0MADA6bCjT1wauu4k6MyxhC1R9gz0olNpIRvK7lGGDwtc/VO0RUDNvVQW5WFg==} @@ -4203,12 +4134,6 @@ packages: '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} - '@types/node-fetch@2.6.13': - resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} - - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@22.19.2': resolution: {integrity: sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==} @@ -4714,10 +4639,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - agentkeepalive@4.6.0: - resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} - engines: {node: '>= 8.0.0'} - aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} @@ -6500,10 +6421,6 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - formdata-node@4.4.1: - resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} - engines: {node: '>= 12.20'} - formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -6993,9 +6910,6 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - humanize-ms@1.2.1: - resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - ico-endec@0.1.6: resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==} @@ -7532,10 +7446,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-to-ts@3.1.1: - resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} - engines: {node: '>=16'} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -8874,18 +8784,6 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} - openai@4.104.0: - resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^3.23.8 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -10624,9 +10522,6 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-algebra@2.0.0: - resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} - ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -10790,9 +10685,6 @@ packages: underscore@1.13.7: resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -11201,10 +11093,6 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} - web-streams-polyfill@4.0.0-beta.3: - resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} - engines: {node: '>= 14'} - web-worker@1.2.0: resolution: {integrity: sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==} @@ -11501,12 +11389,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@anthropic-ai/sdk@0.78.0(zod@4.3.6)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 4.3.6 - '@ark/schema@0.55.0': dependencies: '@ark/util': 0.55.0 @@ -15034,15 +14916,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/node-fetch@2.6.13': - dependencies: - '@types/node': 22.19.8 - form-data: 4.0.5 - - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - '@types/node@22.19.2': dependencies: undici-types: 6.21.0 @@ -15725,10 +15598,6 @@ snapshots: agent-base@7.1.4: {} - agentkeepalive@4.6.0: - dependencies: - humanize-ms: 1.2.1 - aggregate-error@3.1.0: dependencies: clean-stack: 2.2.0 @@ -17958,11 +17827,6 @@ snapshots: format@0.2.2: {} - formdata-node@4.4.1: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 4.0.0-beta.3 - formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -18707,10 +18571,6 @@ snapshots: human-signals@8.0.1: {} - humanize-ms@1.2.1: - dependencies: - ms: 2.1.3 - ico-endec@0.1.6: {} iconv-lite@0.4.24: @@ -19219,11 +19079,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema-to-ts@3.1.1: - dependencies: - '@babel/runtime': 7.28.6 - ts-algebra: 2.0.0 - json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -20893,21 +20748,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@4.104.0(ws@8.19.0)(zod@3.24.0): - dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 - optionalDependencies: - ws: 8.19.0 - zod: 3.24.0 - transitivePeerDependencies: - - encoding - openapi-types@12.1.3: {} optionator@0.9.4: @@ -23198,8 +23038,6 @@ snapshots: trough@2.2.0: {} - ts-algebra@2.0.0: {} - ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -23374,8 +23212,6 @@ snapshots: underscore@1.13.7: {} - undici-types@5.26.5: {} - undici-types@6.21.0: {} undici@7.20.0: {} @@ -24014,8 +23850,6 @@ snapshots: web-streams-polyfill@3.3.3: {} - web-streams-polyfill@4.0.0-beta.3: {} - web-worker@1.2.0: {} webidl-conversions@3.0.1: {}