diff --git a/src/uix/soma/components/words/engine/engine.test.ts b/src/uix/soma/components/words/engine/engine.test.ts
index d5814631c..9ac7c3e47 100644
--- a/src/uix/soma/components/words/engine/engine.test.ts
+++ b/src/uix/soma/components/words/engine/engine.test.ts
@@ -892,6 +892,45 @@ describe('Words engine', () => {
});
});
+ it('parses a block-level markdown image', () => {
+ const md = '# Title\n\n\n\nAfter.';
+ const doc = parseWordsMarkdown(md);
+ expect(doc.children).toHaveLength(3);
+ expect(doc.children[0]).toMatchObject({ type: 'heading' });
+ expect(doc.children[1]).toMatchObject({
+ type: 'image',
+ src: 'https://example.com/cat.jpg',
+ alt: 'A cat',
+ caption: 'Cute kitten'
+ });
+ expect(doc.children[2]).toMatchObject({ type: 'paragraph' });
+ });
+
+ it('round-trips an image through markdown', () => {
+ const original = parseWordsMarkdown('');
+ const back = parseWordsMarkdown(serializeWordsMarkdown(original));
+ expect(back.children[0]).toMatchObject({
+ type: 'image',
+ src: 'https://example.com/x.png',
+ alt: 'alt'
+ });
+ });
+
+ it('round-trips an image through HTML', () => {
+ const html = '
cap';
+ const doc = parseWordsHtml(html);
+ expect(doc.children[0]).toMatchObject({
+ type: 'image',
+ src: 'https://example.com/x.png',
+ alt: 'alt',
+ caption: 'cap'
+ });
+ const out = serializeWordsHtml(doc);
+ expect(out).toContain('cap');
+ });
+
it('edits table rows and columns from the active cell', () => {
let state = createWordsState(
documentWithChildren([
diff --git a/src/uix/soma/components/words/engine/normalize.ts b/src/uix/soma/components/words/engine/normalize.ts
index 80ae05688..47fd1c10a 100644
--- a/src/uix/soma/components/words/engine/normalize.ts
+++ b/src/uix/soma/components/words/engine/normalize.ts
@@ -30,6 +30,7 @@ import {
type WordsTableCell,
type WordsTableRow
} from './document';
+import { createImage, isWordsImageAlign } from '../extensions/image';
export type WordsNormalizeReason =
| 'invalid-document'
@@ -143,6 +144,35 @@ function normalizeBlock(
compact: source.compact === true
});
}
+ case 'image': {
+ const src = typeof source.src === 'string' ? source.src : '';
+ if (!src) {
+ issues.push({ reason: 'invalid-block', path, value: 'image missing src' });
+ return undefined;
+ }
+ const sanitized = sanitizeWordsUrl(src);
+ if (!sanitized) {
+ issues.push({ reason: 'unsafe-url', path, value: src });
+ return undefined;
+ }
+ return createImage(sanitized, {
+ alt: typeof source.alt === 'string' ? source.alt : undefined,
+ caption: typeof source.caption === 'string' ? source.caption : undefined,
+ width:
+ typeof source.width === 'number' && Number.isFinite(source.width)
+ ? source.width
+ : undefined,
+ height:
+ typeof source.height === 'number' && Number.isFinite(source.height)
+ ? source.height
+ : undefined,
+ align: isWordsImageAlign(source.align) ? source.align : undefined,
+ status:
+ source.status === 'pending' || source.status === 'error'
+ ? source.status
+ : undefined
+ });
+ }
default:
issues.push({ reason: 'invalid-block', path, value: source.type });
return undefined;
diff --git a/src/uix/soma/components/words/extensions/image/image-extension.ts b/src/uix/soma/components/words/extensions/image/image-extension.ts
index 7ddb77100..e3d960bed 100644
--- a/src/uix/soma/components/words/extensions/image/image-extension.ts
+++ b/src/uix/soma/components/words/extensions/image/image-extension.ts
@@ -24,7 +24,9 @@ import { createImage } from './factories';
type ErasedFactory = (...args: ReadonlyArray) => WordsExtensionNode;
const imageFactory = ((...args: ReadonlyArray) =>
- createImage(...(args as Parameters))) as ErasedFactory;
+ createImage(
+ ...(args as Parameters)
+ )) as unknown as ErasedFactory;
export const imageExtension: WordsExtension = {
name: 'image',