diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts
index dfb4f2470a..cb06daf793 100644
--- a/packages/layout-engine/painters/dom/src/index.test.ts
+++ b/packages/layout-engine/painters/dom/src/index.test.ts
@@ -4434,6 +4434,107 @@ describe('DomPainter', () => {
expect(watermarkText?.getAttribute('lengthAdjust')).toBe('spacingAndGlyphs');
});
+ it('renders VML text watermark images behind page content and dims them outside header edit mode', () => {
+ const watermarkBlock: FlowBlock = {
+ kind: 'image',
+ id: 'header-vml-text-watermark',
+ src: 'data:image/svg+xml;base64,PHN2Zy8+',
+ width: 200,
+ height: 100,
+ anchor: {
+ isAnchored: true,
+ hRelativeFrom: 'page',
+ vRelativeFrom: 'page',
+ },
+ attrs: { vmlTextWatermark: true },
+ };
+ const watermarkMeasure: Measure = {
+ kind: 'image',
+ width: 200,
+ height: 100,
+ };
+ const watermarkFragment = {
+ kind: 'image' as const,
+ blockId: 'header-vml-text-watermark',
+ x: 40,
+ y: 180,
+ width: 200,
+ height: 100,
+ isAnchored: true,
+ behindDoc: false,
+ };
+
+ const painter = createTestPainter({
+ blocks: [block, watermarkBlock],
+ measures: [measure, watermarkMeasure],
+ headerProvider: () => ({
+ fragments: [watermarkFragment],
+ height: 40,
+ offset: 20,
+ }),
+ });
+
+ painter.paint({ ...layout, pages: [{ ...layout.pages[0], number: 1 }] }, mount);
+
+ const pageEl = mount.querySelector('.superdoc-page') as HTMLElement;
+ const headerEl = mount.querySelector('.superdoc-page-header') as HTMLElement | null;
+ const behindDocWatermark = pageEl.querySelector(
+ '[data-behind-doc-section="header"][data-block-id="header-vml-text-watermark"]',
+ ) as HTMLElement | null;
+ const watermarkInHeader = headerEl?.querySelector('[data-block-id="header-vml-text-watermark"]');
+
+ expect(behindDocWatermark).toBeTruthy();
+ expect(watermarkInHeader).toBeNull();
+ expect(behindDocWatermark?.dataset.vmlTextWatermark).toBe('true');
+ expect(behindDocWatermark?.style.opacity).toBe('0.5');
+ });
+
+ it('renders active header VML text watermarks at full preview opacity', () => {
+ const watermarkBlock: FlowBlock = {
+ kind: 'image',
+ id: 'active-header-vml-text-watermark',
+ src: 'data:image/svg+xml;base64,PHN2Zy8+',
+ width: 200,
+ height: 100,
+ attrs: { vmlTextWatermark: true },
+ };
+ const watermarkMeasure: Measure = {
+ kind: 'image',
+ width: 200,
+ height: 100,
+ };
+ const watermarkFragment = {
+ kind: 'image' as const,
+ blockId: 'active-header-vml-text-watermark',
+ x: 40,
+ y: 180,
+ width: 200,
+ height: 100,
+ isAnchored: true,
+ behindDoc: true,
+ };
+
+ const painter = createTestPainter({
+ blocks: [block, watermarkBlock],
+ measures: [measure, watermarkMeasure],
+ headerProvider: () => ({
+ fragments: [watermarkFragment],
+ height: 40,
+ offset: 20,
+ isActiveHeaderFooter: true,
+ }),
+ });
+
+ painter.paint({ ...layout, pages: [{ ...layout.pages[0], number: 1 }] }, mount);
+
+ const activeWatermark = mount.querySelector(
+ '[data-behind-doc-section="header"][data-block-id="active-header-vml-text-watermark"]',
+ ) as HTMLElement | null;
+
+ expect(activeWatermark).toBeTruthy();
+ expect(activeWatermark?.style.opacity).toBe('1');
+ });
+
it('keeps non-WordArt page-relative header media in the header container', () => {
const headerImageBlock: FlowBlock = {
kind: 'image',
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index f89b43b9d1..23ab6fa786 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -138,6 +138,9 @@ import {
} from './features/inline-direction/index.js';
import { convertOmmlToMathml } from './features/math/index.js';
+const ACTIVE_HEADER_FOOTER_WATERMARK_PREVIEW_OPACITY = '1';
+const INACTIVE_HEADER_FOOTER_WATERMARK_PREVIEW_OPACITY = '0.5';
+
/**
* Minimal type for WordParagraphLayoutOutput marker data used in rendering.
* Extracted to avoid dependency on @superdoc/word-layout package.
@@ -279,6 +282,8 @@ export type PageDecorationPayload = {
contentWidth?: number;
headerFooterRefId?: string;
sectionType?: string;
+ /** True while this rendered header/footer story is the active editing surface. */
+ isActiveHeaderFooter?: boolean;
box?: { x: number; y: number; width: number; height: number };
hitRegion?: { x: number; y: number; width: number; height: number };
};
@@ -2720,6 +2725,7 @@ export class DomPainter {
betweenBorderFlags.get(originalIndex),
resolvedItem,
);
+ this.applyHeaderFooterTextWatermarkPreviewOpacity(fragEl, data.isActiveHeaderFooter === true);
const isPageRelative = this.isPageRelativeAnchoredFragment(fragment, resolvedItem);
let pageY: number;
@@ -2751,6 +2757,7 @@ export class DomPainter {
betweenBorderFlags.get(originalIndex),
resolvedItem,
);
+ this.applyHeaderFooterTextWatermarkPreviewOpacity(fragEl, data.isActiveHeaderFooter === true);
const isPageRelative = this.isPageRelativeAnchoredFragment(fragment, resolvedItem);
if (isPageRelative && kind === 'footer') {
@@ -3896,6 +3903,9 @@ export class DomPainter {
}
this.applySdtDataset(fragmentEl, block.attrs?.sdt);
this.applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt);
+ if (this.isVmlTextWatermarkImage(block)) {
+ fragmentEl.dataset.vmlTextWatermark = 'true';
+ }
// Add block ID for PM transaction targeting
if (block.id) {
@@ -7258,16 +7268,24 @@ export class DomPainter {
private shouldRenderBehindPageContent(
fragment: ImageFragment | DrawingFragment,
section: 'header' | 'footer',
- resolvedItem?: ResolvedDrawingItem,
+ resolvedItem?: ResolvedImageItem | ResolvedDrawingItem,
): boolean {
if (fragment.behindDoc === true || (fragment.behindDoc == null && 'zIndex' in fragment && fragment.zIndex === 0)) {
return true;
}
- return section === 'header' && fragment.kind === 'drawing' && this.isHeaderWordArtWatermark(resolvedItem?.block);
+ if (section !== 'header') {
+ return false;
+ }
+
+ if (fragment.kind === 'drawing') {
+ return this.isHeaderWordArtWatermark(resolvedItem?.block);
+ }
+
+ return this.isVmlTextWatermarkImage(resolvedItem?.block);
}
- private isHeaderWordArtWatermark(block: DrawingBlock | undefined): boolean {
+ private isHeaderWordArtWatermark(block: FlowBlock | undefined): block is DrawingBlock {
if (!block || block.kind !== 'drawing' || block.drawingKind !== 'vectorShape') {
return false;
}
@@ -7288,6 +7306,20 @@ export class DomPainter {
);
}
+ private isVmlTextWatermarkImage(block: FlowBlock | undefined): block is ImageBlock {
+ return block?.kind === 'image' && block.attrs?.vmlTextWatermark === true;
+ }
+
+ private applyHeaderFooterTextWatermarkPreviewOpacity(el: HTMLElement, isActiveHeaderFooter: boolean): void {
+ if (el.dataset.vmlTextWatermark !== 'true') {
+ return;
+ }
+
+ el.style.opacity = isActiveHeaderFooter
+ ? ACTIVE_HEADER_FOOTER_WATERMARK_PREVIEW_OPACITY
+ : INACTIVE_HEADER_FOOTER_WATERMARK_PREVIEW_OPACITY;
+ }
+
/**
* Only anchored images and drawings participate in explicit wrapper stacking.
* Inline media intentionally rely on DOM order to preserve legacy paint order.
diff --git a/packages/super-editor/src/editors/v1/core/helpers/base64.js b/packages/super-editor/src/editors/v1/core/helpers/base64.js
new file mode 100644
index 0000000000..c275e97e41
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/helpers/base64.js
@@ -0,0 +1,50 @@
+/** Latin-1 / "binary" string -> base64 (browser `btoa`, else Node `Buffer`). */
+function binaryStringToBase64(binary) {
+ if (typeof globalThis.btoa === 'function') {
+ return globalThis.btoa(binary);
+ }
+ if (typeof Buffer !== 'undefined') {
+ return Buffer.from(binary, 'latin1').toString('base64');
+ }
+ throw new Error('[base64] encode requires btoa (browser) or Buffer (Node)');
+}
+
+/** base64 -> Latin-1 / "binary" string (browser `atob`, else Node `Buffer`). */
+function base64ToBinaryString(b64) {
+ if (typeof globalThis.atob === 'function') {
+ return globalThis.atob(b64);
+ }
+ if (typeof Buffer !== 'undefined') {
+ return Buffer.from(b64, 'base64').toString('latin1');
+ }
+ throw new Error('[base64] decode requires atob (browser) or Buffer (Node)');
+}
+
+/**
+ * UTF-8 string -> base64. Same idea as `btoa(unescape(encodeURIComponent(s)))` without `unescape`.
+ * @param {string} input
+ */
+export function encodeUtf8Base64(input) {
+ const binary = encodeURIComponent(input).replace(/%([0-9A-F]{2})/g, (_, hex) =>
+ String.fromCharCode(parseInt(hex, 16)),
+ );
+ return binaryStringToBase64(binary);
+}
+
+/**
+ * base64 -> UTF-8 string. Decodes bytes then UTF-8 via percent-encoding.
+ * @param {string} b64
+ */
+export function decodeUtf8Base64(b64) {
+ if (!b64) return '';
+ try {
+ const bin = base64ToBinaryString(b64);
+ let pct = '';
+ for (let i = 0; i < bin.length; i += 1) {
+ pct += `%${bin.charCodeAt(i).toString(16).padStart(2, '0')}`;
+ }
+ return decodeURIComponent(pct);
+ } catch {
+ return '';
+ }
+}
diff --git a/packages/super-editor/src/editors/v1/core/helpers/superdocClipboardSlice.js b/packages/super-editor/src/editors/v1/core/helpers/superdocClipboardSlice.js
index 762d5af6e1..ef66dd27dd 100644
--- a/packages/super-editor/src/editors/v1/core/helpers/superdocClipboardSlice.js
+++ b/packages/super-editor/src/editors/v1/core/helpers/superdocClipboardSlice.js
@@ -3,6 +3,7 @@
* in Node (tests) uses `Buffer` for base64 when `btoa`/`atob` are missing.
*/
import { getSectPrColumns } from '../super-converter/section-properties.js';
+import { encodeUtf8Base64, decodeUtf8Base64 } from './base64.js';
export const SUPERDOC_SLICE_MIME = 'application/x-superdoc-slice';
/** JSON map of package-relative image path → display URL (data URL, https, or blob URL). */
@@ -193,57 +194,6 @@ export function applySuperdocClipboardMedia(editor, clipboardData, sliceJson = n
return outSlice;
}
-/** Latin-1 / “binary” string → base64 (browser `btoa`, else Node `Buffer`). */
-function binaryStringToBase64(binary) {
- if (typeof globalThis.btoa === 'function') {
- return globalThis.btoa(binary);
- }
- if (typeof Buffer !== 'undefined') {
- return Buffer.from(binary, 'latin1').toString('base64');
- }
- throw new Error('[superdocClipboardSlice] base64 encode requires btoa (browser) or Buffer (Node)');
-}
-
-/** base64 → Latin-1 / “binary” string (browser `atob`, else Node `Buffer`). */
-function base64ToBinaryString(b64) {
- if (typeof globalThis.atob === 'function') {
- return globalThis.atob(b64);
- }
- if (typeof Buffer !== 'undefined') {
- return Buffer.from(b64, 'base64').toString('latin1');
- }
- throw new Error('[superdocClipboardSlice] base64 decode requires atob (browser) or Buffer (Node)');
-}
-
-/**
- * UTF-8 string → base64. Same idea as `btoa(unescape(encodeURIComponent(s)))` without `unescape`.
- * @param {string} input
- */
-function encodeUtf8Base64(input) {
- const binary = encodeURIComponent(input).replace(/%([0-9A-F]{2})/g, (_, hex) =>
- String.fromCharCode(parseInt(hex, 16)),
- );
- return binaryStringToBase64(binary);
-}
-
-/**
- * base64 → UTF-8 string. Decodes bytes then UTF-8 via percent-encoding.
- * @param {string} b64
- */
-function decodeUtf8Base64(b64) {
- if (!b64) return '';
- try {
- const bin = base64ToBinaryString(b64);
- let pct = '';
- for (let i = 0; i < bin.length; i += 1) {
- pct += `%${bin.charCodeAt(i).toString(16).padStart(2, '0')}`;
- }
- return decodeURIComponent(pct);
- } catch {
- return '';
- }
-}
-
export function bodySectPrShouldEmbed(bodySectPr) {
if (!bodySectPr || typeof bodySectPr !== 'object') return false;
const cols = getSectPrColumns(bodySectPr);
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts
index 85c963a92a..15a1b3052a 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts
@@ -1234,6 +1234,8 @@ export class HeaderFooterSessionManager {
this.#emitModeChanged();
this.#emitEditingContext(editor);
this.#deps?.notifyInputBridgeTargetChanged();
+ this.#deps?.setPendingDocChange();
+ this.#deps?.scheduleRerender();
return editor;
} catch (error) {
console.error('[HeaderFooterSessionManager] Unexpected error in enterMode:', error);
@@ -1363,6 +1365,18 @@ export class HeaderFooterSessionManager {
this.#syncActiveBorder();
}
+ #isActiveDecoration(kind: 'header' | 'footer', headerFooterRefId: string | undefined, pageNumber: number): boolean {
+ if (this.#session.mode !== kind) {
+ return false;
+ }
+
+ if (headerFooterRefId && this.#session.headerFooterRefId) {
+ return headerFooterRefId === this.#session.headerFooterRefId;
+ }
+
+ return this.#session.pageNumber === pageNumber;
+ }
+
#emitEditingContext(editor: Editor): void {
this.#callbacks.onEditingContext?.({
kind: this.#session.mode,
@@ -2444,6 +2458,7 @@ export class HeaderFooterSessionManager {
const layoutMinY = rIdLayout.layout.minY ?? 0;
const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY);
const normalizedItems = normalizeDecorationItems(alignedItems, layoutMinY);
+ const isActiveHeaderFooter = this.#isActiveDecoration(kind, sectionRId, pageNumber);
return {
fragments: normalizedFragments,
@@ -2455,6 +2470,7 @@ export class HeaderFooterSessionManager {
contentWidth: effectiveWidth,
headerFooterRefId: sectionRId,
sectionType: headerFooterType,
+ isActiveHeaderFooter,
minY: layoutMinY,
box: { x: box.x, y: metrics.offset, width: effectiveWidth, height: metrics.containerHeight },
hitRegion: { x: box.x, y: metrics.offset, width: effectiveWidth, height: metrics.containerHeight },
@@ -2508,6 +2524,7 @@ export class HeaderFooterSessionManager {
const layoutMinY = variant.layout.minY ?? 0;
const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY);
const normalizedItems = normalizeDecorationItems(alignedVariantItems, layoutMinY);
+ const isActiveHeaderFooter = this.#isActiveDecoration(kind, finalHeaderId, pageNumber);
return {
fragments: normalizedFragments,
@@ -2519,6 +2536,7 @@ export class HeaderFooterSessionManager {
contentWidth: box.width,
headerFooterRefId: finalHeaderId,
sectionType: headerFooterType,
+ isActiveHeaderFooter,
minY: layoutMinY,
box: { x: box.x, y: metrics.offset, width: box.width, height: metrics.containerHeight },
hitRegion: { x: box.x, y: metrics.offset, width: box.width, height: metrics.containerHeight },
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts
index 4c447fceb7..dc4d22c059 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts
@@ -350,6 +350,8 @@ describe('HeaderFooterSessionManager', () => {
defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
});
+ const scheduleRerender = vi.fn();
+ const setPendingDocChange = vi.fn();
manager.setDependencies({
getLayoutOptions: vi.fn(() => ({ zoom: 1 })),
getPageElement: vi.fn(() => pageElement),
@@ -359,8 +361,8 @@ describe('HeaderFooterSessionManager', () => {
isViewLocked: vi.fn(() => false),
getBodyPageHeight: vi.fn(() => 800),
notifyInputBridgeTargetChanged: vi.fn(),
- scheduleRerender: vi.fn(),
- setPendingDocChange: vi.fn(),
+ scheduleRerender,
+ setPendingDocChange,
getBodyPageCount: vi.fn(() => 3),
getStorySessionManager: vi.fn(() => ({ activate, exit })),
});
@@ -408,6 +410,8 @@ describe('HeaderFooterSessionManager', () => {
}),
}),
);
+ expect(setPendingDocChange).toHaveBeenCalledTimes(1);
+ expect(scheduleRerender).toHaveBeenCalledTimes(1);
});
it('enters header edit mode in suggesting mode and enables tracked changes', async () => {
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
index d37adab113..dd75db1090 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts
@@ -2857,7 +2857,7 @@ describe('PresentationEditor', () => {
y: 120,
},
];
- mockIncrementalLayout.mockResolvedValueOnce(layoutResult);
+ mockIncrementalLayout.mockResolvedValue(layoutResult);
bookmarkResolverMocks.findAllBookmarksInDocument.mockReturnValueOnce([
{ name: 'body-bm', bookmarkId: '7', storyKey: 'body' },
]);
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.js
index 7eb8aa6617..c762dce10c 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.js
@@ -1,3 +1,5 @@
+import { encodeUtf8Base64 } from '../../../../../../helpers/base64.js';
+
/**
* Handles VML shape elements with v:textpath (text watermarks).
*
@@ -17,6 +19,13 @@
* @param {Object} options
* @returns {Object|null}
*/
+// Word positions centered, rotated VML WordArt below the shape's geometric center.
+const ROTATED_CENTERED_WATERMARK_TOP_OFFSET_RATIO = 0.25;
+// Guard against malformed VML height values when calculating the WordArt offset.
+const MAX_ROTATED_CENTERED_WATERMARK_OFFSET_HEIGHT_PX = 10000;
+const DEFAULT_VML_TEXT_WATERMARK_OPACITY = 1;
+const DEFAULT_VML_TEXT_WATERMARK_ALIGNMENT = 'center';
+
export function handleShapeTextWatermarkImport({ pict }) {
const shape = pict.elements?.find((el) => el.name === 'v:shape');
if (!shape) return null;
@@ -53,8 +62,12 @@ export function handleShapeTextWatermarkImport({ pict }) {
const rotation = parseFloat(styleObj.rotation) || 0;
// Extract positioning attributes
- const hPosition = styleObj['mso-position-horizontal'] || 'center';
- const vPosition = styleObj['mso-position-vertical'] || 'center';
+ const explicitHPosition = styleObj['mso-position-horizontal'];
+ const explicitVPosition = styleObj['mso-position-vertical'];
+ const hasExplicitMarginLeft = styleObj['margin-left'] != null;
+ const hasExplicitMarginTop = styleObj['margin-top'] != null;
+ const hPosition = explicitHPosition || (hasExplicitMarginLeft ? undefined : DEFAULT_VML_TEXT_WATERMARK_ALIGNMENT);
+ const vPosition = explicitVPosition || (hasExplicitMarginTop ? undefined : DEFAULT_VML_TEXT_WATERMARK_ALIGNMENT);
const hRelativeTo = styleObj['mso-position-horizontal-relative'] || 'margin';
const vRelativeTo = styleObj['mso-position-vertical-relative'] || 'margin';
@@ -68,7 +81,7 @@ export function handleShapeTextWatermarkImport({ pict }) {
const rawFillColor2 = fillAttrs.color2 || '#3f3f3f';
const fillColor = sanitizeColor(rawFillColor, 'silver');
const fillColor2 = sanitizeColor(rawFillColor2, '#3f3f3f');
- const opacity = fillAttrs.opacity || '0.5';
+ const opacity = fillAttrs.opacity ?? String(DEFAULT_VML_TEXT_WATERMARK_OPACITY);
const fillType = fillAttrs.type || 'solid';
// Extract stroke properties
@@ -82,12 +95,13 @@ export function handleShapeTextWatermarkImport({ pict }) {
// Extract text formatting from textpath style
const textpathStyle = textpathAttrs.style || '';
const textStyleObj = parseVmlStyle(textpathStyle);
- const rawFontFamily = textStyleObj['font-family']?.replace(/['"]/g, '');
+ const rawFontFamily = decodeXmlEntities(textStyleObj['font-family'] || '').replace(/['"]/g, '');
const fontFamily = sanitizeFontFamily(rawFontFamily);
const fontSize = textStyleObj['font-size'] || '1pt';
// Extract other textpath attributes
const fitshape = textpathAttrs.fitshape || 't';
+ const shouldFitShape = fitshape === 't';
const trim = textpathAttrs.trim || 't';
const textpathOn = textpathAttrs.on || 't';
@@ -107,7 +121,7 @@ export function handleShapeTextWatermarkImport({ pict }) {
const heightPx = convertToPixels(height);
// Sanitize numeric values before use
- const sanitizedOpacity = sanitizeNumeric(parseFloat(opacity), 0.5, 0, 1);
+ const sanitizedOpacity = sanitizeNumeric(parseVmlOpacity(opacity), DEFAULT_VML_TEXT_WATERMARK_OPACITY, 0, 1);
const sanitizedRotation = sanitizeNumeric(rotation, 0, -360, 360);
const svgResult = generateTextWatermarkSVG({
@@ -123,9 +137,39 @@ export function handleShapeTextWatermarkImport({ pict }) {
fontFamily,
fontSize,
},
+ fitShape: shouldFitShape,
});
const svgDataUri = svgResult.dataUri;
+ const centerOffsetTop = getTextWatermarkCenterOffset({
+ hPosition,
+ vPosition,
+ hRelativeTo,
+ vRelativeTo,
+ height: heightPx,
+ rotation: sanitizedRotation,
+ });
+ const marginOffset = resolveTextWatermarkMarginOffset({
+ hPosition,
+ vPosition,
+ hRelativeTo,
+ vRelativeTo,
+ marginLeft: convertToPixels(position.marginLeft),
+ marginTop: convertToPixels(position.marginTop),
+ width: widthPx,
+ height: heightPx,
+ svgWidth: svgResult.svgWidth,
+ svgHeight: svgResult.svgHeight,
+ centerOffsetTop,
+ rotation: sanitizedRotation,
+ });
+
+ const anchorData = {
+ hRelativeFrom: hRelativeTo,
+ vRelativeFrom: vRelativeTo,
+ };
+ if (hPosition) anchorData.alignH = hPosition;
+ if (vPosition) anchorData.alignV = vPosition;
// Return as an image node (so it uses the Image extension for rendering)
// but preserve all VML attributes for export round-trip
@@ -156,23 +200,13 @@ export function handleShapeTextWatermarkImport({ pict }) {
behindDoc: true,
},
},
- anchorData: {
- hRelativeFrom: hRelativeTo,
- vRelativeFrom: vRelativeTo,
- alignH: hPosition,
- alignV: vPosition,
- },
+ anchorData,
// Size - use rotated bounding box dimensions to prevent clipping
size: {
width: svgResult.svgWidth,
height: svgResult.svgHeight,
},
- marginOffset: {
- // For center-aligned watermarks relative to margin, Word's margin values
- // are not suitable for browser rendering. Set to 0 to let center alignment work.
- horizontal: hPosition === 'center' && hRelativeTo === 'margin' ? 0 : convertToPixels(position.marginLeft),
- top: vPosition === 'center' && vRelativeTo === 'margin' ? 0 : convertToPixels(position.marginTop),
- },
+ marginOffset,
// Store text watermark specific data for export
textWatermarkData: {
text: watermarkText,
@@ -196,7 +230,7 @@ export function handleShapeTextWatermarkImport({ pict }) {
},
textpath: {
on: textpathOn === 't',
- fitshape: fitshape === 't',
+ fitshape: shouldFitShape,
trim: trim === 't',
textpathok: textpathok === 't',
},
@@ -219,7 +253,7 @@ function sanitizeFontFamily(fontFamily) {
}
// Only allow alphanumeric, spaces, hyphens, and commas (for font lists)
// This prevents injection via quotes, angle brackets, parentheses, etc.
- const sanitized = fontFamily.replace(/[^a-zA-Z0-9\s,\-]/g, '').trim();
+ const sanitized = fontFamily.replace(/[^a-zA-Z0-9\s,-]/g, '').trim();
return sanitized || 'Arial';
}
@@ -243,6 +277,41 @@ function sanitizeColor(color, defaultColor = 'silver') {
return sanitized || defaultColor;
}
+function normalizeVmlColor(color) {
+ const namedColors = {
+ black: '#000000',
+ blue: '#0000FF',
+ gray: '#808080',
+ green: '#008000',
+ lime: '#00FF00',
+ red: '#FF0000',
+ silver: '#C0C0C0',
+ white: '#FFFFFF',
+ yellow: '#FFFF00',
+ };
+
+ const key = typeof color === 'string' ? color.trim().toLowerCase() : '';
+ return namedColors[key] || color;
+}
+
+function decodeXmlEntities(value) {
+ return value
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/g, '&')
+ .replace(/(\d+);/g, (_, code) => decodeCodePoint(Number(code)))
+ .replace(/([0-9a-fA-F]+);/g, (_, code) => decodeCodePoint(Number.parseInt(code, 16)));
+}
+
+function decodeCodePoint(codePoint) {
+ if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
+ return '';
+ }
+ return String.fromCodePoint(codePoint);
+}
+
/**
* Validate and sanitize numeric value.
* @param {number|string} value - Numeric value
@@ -262,6 +331,87 @@ function sanitizeNumeric(value, defaultValue, min = -Infinity, max = Infinity) {
return Math.max(min, Math.min(max, num));
}
+function parseVmlOpacity(value) {
+ if (typeof value === 'number') {
+ return value;
+ }
+ if (!value || typeof value !== 'string') {
+ return NaN;
+ }
+
+ const normalized = value.trim().toLowerCase();
+ if (normalized.endsWith('%')) {
+ return Number.parseFloat(normalized.slice(0, -1)) / 100;
+ }
+ if (normalized.endsWith('f')) {
+ return Number.parseInt(normalized.slice(0, -1), 10) / 65536;
+ }
+ return Number.parseFloat(normalized);
+}
+
+function getTextWatermarkCenterOffset({ hPosition, vPosition, hRelativeTo, vRelativeTo, height, rotation }) {
+ const isCenteredMarginWatermark =
+ hPosition === 'center' && vPosition === 'center' && hRelativeTo === 'margin' && vRelativeTo === 'margin';
+ if (!isCenteredMarginWatermark || rotation === 0) {
+ return 0;
+ }
+
+ return (
+ sanitizeNumeric(height, 0, 0, MAX_ROTATED_CENTERED_WATERMARK_OFFSET_HEIGHT_PX) *
+ ROTATED_CENTERED_WATERMARK_TOP_OFFSET_RATIO
+ );
+}
+
+function resolveTextWatermarkMarginOffset({
+ hPosition,
+ vPosition,
+ hRelativeTo,
+ vRelativeTo,
+ marginLeft,
+ marginTop,
+ width,
+ height,
+ svgWidth,
+ svgHeight,
+ centerOffsetTop,
+ rotation,
+}) {
+ const isCenteredHorizontally = hPosition === 'center' && hRelativeTo === 'margin';
+ const isCenteredVertically = vPosition === 'center' && vRelativeTo === 'margin';
+
+ return {
+ // For explicitly centered margin watermarks, Word's margin values are not
+ // browser offsets. Let layout center horizontally and apply only the known
+ // rotated WordArt top correction vertically.
+ horizontal: isCenteredHorizontally
+ ? 0
+ : getAbsoluteShapeOffset({
+ position: hPosition,
+ margin: marginLeft,
+ shapeSize: width,
+ svgSize: svgWidth,
+ rotation,
+ }),
+ top: isCenteredVertically
+ ? centerOffsetTop
+ : getAbsoluteShapeOffset({
+ position: vPosition,
+ margin: marginTop,
+ shapeSize: height,
+ svgSize: svgHeight,
+ rotation,
+ }),
+ };
+}
+
+function getAbsoluteShapeOffset({ position, margin, shapeSize, svgSize, rotation }) {
+ if (position || rotation === 0) {
+ return margin;
+ }
+
+ return margin + shapeSize / 2 - svgSize / 2;
+}
+
/**
* Generate an SVG data URI for a text watermark with rotation.
* Rotation must be baked into the SVG since the layout engine doesn't support
@@ -269,14 +419,12 @@ function sanitizeNumeric(value, defaultValue, min = -Infinity, max = Infinity) {
* @param {Object} options - Watermark options
* @returns {Object} Object with dataUri, svgWidth, and svgHeight
*/
-function generateTextWatermarkSVG({ text, width, height, rotation, fill, textStyle }) {
+function generateTextWatermarkSVG({ text, width, height, rotation, fill, textStyle, fitShape }) {
// Word watermarks don't use font-size literally - they scale text to fill available space
// Word VML typically specifies font-size:1pt, but this is just a scaling hint
// The actual rendered size depends on the watermark dimensions (width/height)
- let fontSize = height * 0.9; // The value of 0.9 was determined by me by visual comparison.
- // It seems to be close to correct for text without rotation and slightly too low for text
- // with rotation.
+ let fontSize = height * 1.12;
// Alternative: if explicit font size is given and not the typical 1pt, respect it
// Only override if it's not the typical Word watermark 1pt
if (textStyle?.fontSize && textStyle.fontSize.trim() !== '1pt') {
@@ -290,9 +438,9 @@ function generateTextWatermarkSVG({ text, width, height, rotation, fill, textSty
fontSize = Math.max(fontSize, 48); // Minimum visible size
// Sanitize all values from untrusted input
- const color = sanitizeColor(fill?.color, 'silver');
- const opacity = sanitizeNumeric(fill?.opacity, 0.5, 0, 1);
- const fontFamily = sanitizeFontFamily(textStyle?.fontFamily);
+ const color = normalizeVmlColor(sanitizeColor(fill?.color, 'silver'));
+ const opacity = sanitizeNumeric(fill?.opacity, DEFAULT_VML_TEXT_WATERMARK_OPACITY, 0, 1);
+ const fontFamily = resolveSvgFontFamily(sanitizeFontFamily(textStyle?.fontFamily));
const sanitizedRotation = sanitizeNumeric(rotation, 0, -360, 360);
const sanitizedWidth = sanitizeNumeric(width, 100, 1, 10000);
const sanitizedHeight = sanitizeNumeric(height, 100, 1, 10000);
@@ -314,27 +462,47 @@ function generateTextWatermarkSVG({ text, width, height, rotation, fill, textSty
// Center the rotation in the larger SVG canvas
const centerX = svgWidth / 2;
const centerY = svgHeight / 2;
+ const textAttributes = [
+ `x="${centerX}"`,
+ `y="${centerY}"`,
+ 'text-anchor="middle"',
+ 'dominant-baseline="middle"',
+ `font-family="${fontFamily}"`,
+ `font-size="${sanitizedFontSize}px"`,
+ ...(fitShape ? [`textLength="${sanitizedWidth}"`, 'lengthAdjust="spacingAndGlyphs"'] : []),
+ `fill="${color}"`,
+ `fill-opacity="${opacity}"`,
+ `transform="rotate(${sanitizedRotation} ${centerX} ${centerY})"`,
+ ]
+ .map((attribute) => ` ${attribute}`)
+ .join('\n');
const svg = ``;
+ ${escapeXml(text)}
+ `;
return {
- dataUri: `data:image/svg+xml,${encodeURIComponent(svg)}`,
+ dataUri: `data:image/svg+xml;base64,${encodeUtf8Base64(svg)}`,
svgWidth,
svgHeight,
};
}
+function resolveSvgFontFamily(fontFamily) {
+ if (!fontFamily || typeof fontFamily !== 'string') {
+ return 'Arial, sans-serif';
+ }
+
+ const normalized = fontFamily.trim();
+ if (normalized.includes(',')) {
+ return normalized;
+ }
+
+ const serifFonts = new Set(['cambria', 'constantia', 'georgia', 'times new roman', 'times']);
+ const generic = serifFonts.has(normalized.toLowerCase()) ? 'serif' : 'Arial, sans-serif';
+ return `${normalized}, ${generic}`;
+}
+
/**
* Escape XML special characters.
* @param {string} text
@@ -358,7 +526,9 @@ function parseVmlStyle(style) {
const result = {};
if (!style) return result;
- const declarations = style.split(';').filter((s) => s.trim());
+ const declarations = decodeXmlEntities(style)
+ .split(';')
+ .filter((s) => s.trim());
for (const decl of declarations) {
const colonIndex = decl.indexOf(':');
if (colonIndex === -1) continue;
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.test.js
index 71066b7203..47ca9c8007 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pict/helpers/handle-shape-text-watermark-import.test.js
@@ -1,6 +1,14 @@
import { describe, it, expect } from 'vitest';
import { handleShapeTextWatermarkImport } from './handle-shape-text-watermark-import';
+const decodeSvgDataUri = (src) => {
+ const [meta = '', payload = ''] = src.split(',');
+ if (meta.endsWith(';base64')) {
+ return Buffer.from(payload, 'base64').toString('utf8');
+ }
+ return decodeURIComponent(payload);
+};
+
describe('handleShapeTextWatermarkImport', () => {
describe('Basic text watermark import', () => {
it('should import a basic text watermark with v:textpath', () => {
@@ -70,7 +78,7 @@ describe('handleShapeTextWatermarkImport', () => {
expect(result.type).toBe('image');
expect(result.attrs.vmlTextWatermark).toBe(true);
expect(result.attrs.textWatermarkData.text).toBe('DRAFT MARK');
- expect(result.attrs.src).toContain('data:image/svg+xml');
+ expect(result.attrs.src).toContain('data:image/svg+xml;base64,');
});
it('should extract text from string attribute', () => {
@@ -210,7 +218,7 @@ describe('handleShapeTextWatermarkImport', () => {
expect(result.attrs.transformData).toBeUndefined();
});
- it('should parse margin offsets from style', () => {
+ it('should preserve explicit margin offsets when alignment is omitted', () => {
const pict = {
elements: [
{
@@ -232,10 +240,10 @@ describe('handleShapeTextWatermarkImport', () => {
const result = handleShapeTextWatermarkImport({ params: {}, pict });
- // For center-aligned watermarks relative to margin, both horizontal and vertical
- // margin offsets are set to 0 to let center alignment work properly in the browser
- expect(result.attrs.marginOffset.horizontal).toBe(0);
- expect(result.attrs.marginOffset.top).toBe(0);
+ expect(result.attrs.marginOffset.horizontal).toBeCloseTo(0.07, 1);
+ expect(result.attrs.marginOffset.top).toBeCloseTo(420.9, 1);
+ expect(result.attrs.anchorData.alignH).toBeUndefined();
+ expect(result.attrs.anchorData.alignV).toBeUndefined();
});
it('should parse positioning from style', () => {
@@ -327,6 +335,40 @@ describe('handleShapeTextWatermarkImport', () => {
expect(result.attrs.anchorData.alignV).toBe('top');
});
+ it('should preserve the absolute VML shape center for rotated watermarks without explicit alignment', () => {
+ const pict = {
+ elements: [
+ {
+ name: 'v:shape',
+ attributes: {
+ style:
+ 'position:absolute;margin-left:1in;margin-top:337.5pt;width:468pt;height:117pt;rotation:315;mso-position-horizontal-relative:margin;mso-position-vertical-relative:margin',
+ fillcolor: 'silver',
+ stroked: 'f',
+ },
+ elements: [
+ {
+ name: 'v:textpath',
+ attributes: {
+ string: 'SOLID',
+ style: 'font-family:"Calibri";font-size:1pt',
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = handleShapeTextWatermarkImport({ params: {}, pict });
+
+ expect(result.attrs.anchorData).toEqual({
+ hRelativeFrom: 'margin',
+ vRelativeFrom: 'margin',
+ });
+ expect(result.attrs.marginOffset.horizontal).toBeCloseTo(64.8, 1);
+ expect(result.attrs.marginOffset.top).toBeCloseTo(224.7, 1);
+ });
+
it('should preserve margin offsets for right-aligned watermarks', () => {
const pict = {
elements: [
@@ -480,6 +522,39 @@ describe('handleShapeTextWatermarkImport', () => {
expect(result.attrs.textWatermarkData.fill.color).toBe('silver');
});
+
+ it('should parse VML percentage and fixed-point opacity values', () => {
+ const buildPict = (opacity) => ({
+ elements: [
+ {
+ name: 'v:shape',
+ attributes: {
+ style: 'width:100pt',
+ },
+ elements: [
+ {
+ name: 'v:textpath',
+ attributes: {
+ string: 'OPACITY',
+ },
+ },
+ {
+ name: 'v:fill',
+ attributes: {
+ opacity,
+ },
+ },
+ ],
+ },
+ ],
+ });
+
+ const percentResult = handleShapeTextWatermarkImport({ params: {}, pict: buildPict('50%') });
+ const fixedResult = handleShapeTextWatermarkImport({ params: {}, pict: buildPict('32768f') });
+
+ expect(percentResult.attrs.textWatermarkData.fill.opacity).toBe(0.5);
+ expect(fixedResult.attrs.textWatermarkData.fill.opacity).toBe(0.5);
+ });
});
describe('Stroke properties', () => {
@@ -577,6 +652,42 @@ describe('handleShapeTextWatermarkImport', () => {
expect(result.attrs.textWatermarkData.textStyle.fontSize).toBe('1pt');
});
+ it('should decode XML entities in textpath font family', () => {
+ const pict = {
+ elements: [
+ {
+ name: 'v:shape',
+ attributes: {
+ style: 'width:479.9pt;height:179.95pt;rotation:315',
+ fillcolor: 'silver',
+ stroked: 'f',
+ },
+ elements: [
+ {
+ name: 'v:fill',
+ attributes: {
+ opacity: '.5',
+ },
+ },
+ {
+ name: 'v:textpath',
+ attributes: {
+ string: 'EXAMPLE',
+ style: 'font-family:"Calibri";font-size:1pt',
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = handleShapeTextWatermarkImport({ params: {}, pict });
+
+ expect(result.attrs.textWatermarkData.textStyle.fontFamily).toBe('Calibri');
+ const decodedSvg = decodeSvgDataUri(result.attrs.src);
+ expect(decodedSvg).toContain('font-family="Calibri, Arial, sans-serif"');
+ });
+
it('should handle single quotes in font-family', () => {
const pict = {
elements: [
@@ -734,6 +845,80 @@ describe('handleShapeTextWatermarkImport', () => {
});
});
+ describe('SVG rendering data', () => {
+ it('fits text to the VML shape width and normalizes named fill colors in the generated SVG', () => {
+ const pict = {
+ elements: [
+ {
+ name: 'v:shape',
+ attributes: {
+ style: 'width:479.9pt;height:179.95pt;rotation:315',
+ fillcolor: 'silver',
+ stroked: 'f',
+ },
+ elements: [
+ {
+ name: 'v:fill',
+ attributes: {
+ opacity: '.5',
+ },
+ },
+ {
+ name: 'v:textpath',
+ attributes: {
+ string: 'EXAMPLE',
+ style: 'font-family:"Calibri";font-size:1pt',
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = handleShapeTextWatermarkImport({ params: {}, pict });
+ const decodedSvg = decodeSvgDataUri(result.attrs.src);
+
+ expect(decodedSvg).toMatch(/textLength="639\.866666666666[67]"/);
+ expect(decodedSvg).toContain('lengthAdjust="spacingAndGlyphs"');
+ expect(decodedSvg).toContain('font-size="268.7253333333333px"');
+ expect(decodedSvg).toContain('fill="#C0C0C0"');
+ expect(decodedSvg).toContain('fill-opacity="0.5"');
+ expect(result.attrs.marginOffset.top).toBeCloseTo(59.98, 1);
+ expect(result.attrs.textWatermarkData.fill.color).toBe('silver');
+ expect(result.attrs.textWatermarkData.fill.opacity).toBe(0.5);
+ });
+
+ it('renders missing VML opacity as fully opaque', () => {
+ const pict = {
+ elements: [
+ {
+ name: 'v:shape',
+ attributes: {
+ style: 'width:300pt;height:80pt;rotation:315',
+ fillcolor: 'red',
+ stroked: 'f',
+ },
+ elements: [
+ {
+ name: 'v:textpath',
+ attributes: {
+ string: 'SOLID',
+ style: 'font-family:"Calibri";font-size:1pt',
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = handleShapeTextWatermarkImport({ params: {}, pict });
+ const decodedSvg = decodeSvgDataUri(result.attrs.src);
+
+ expect(result.attrs.textWatermarkData.fill.opacity).toBe(1);
+ expect(decodedSvg).toContain('fill-opacity="1"');
+ });
+ });
+
describe('Textpath properties', () => {
it('should extract textpath boolean properties', () => {
const pict = {
@@ -808,6 +993,36 @@ describe('handleShapeTextWatermarkImport', () => {
expect(result.attrs.textWatermarkData.textpath.fitshape).toBe(false);
expect(result.attrs.textWatermarkData.textpath.textpathok).toBe(false);
});
+
+ it('should not stretch SVG text when fitshape is false', () => {
+ const pict = {
+ elements: [
+ {
+ name: 'v:shape',
+ attributes: {
+ style: 'width:300pt;height:80pt;rotation:315',
+ },
+ elements: [
+ {
+ name: 'v:textpath',
+ attributes: {
+ fitshape: 'f',
+ string: 'NATURAL WIDTH',
+ style: 'font-family:"Calibri";font-size:1pt',
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = handleShapeTextWatermarkImport({ params: {}, pict });
+ const decodedSvg = decodeSvgDataUri(result.attrs.src);
+
+ expect(result.attrs.textWatermarkData.textpath.fitshape).toBe(false);
+ expect(decodedSvg).not.toContain('textLength=');
+ expect(decodedSvg).not.toContain('lengthAdjust=');
+ });
});
describe('Edge cases', () => {
@@ -875,9 +1090,10 @@ describe('handleShapeTextWatermarkImport', () => {
// Should handle 345 degree rotation (15 degrees clockwise from horizontal)
expect(result.attrs.textWatermarkData.rotation).toBe(345);
- // For center-aligned watermarks, margins should be 0
+ // For center-aligned rotated text watermarks, horizontal stays centered and
+ // vertical gets a WordArt-specific correction from the original VML height.
expect(result.attrs.marginOffset.horizontal).toBe(0);
- expect(result.attrs.marginOffset.top).toBe(0);
+ expect(result.attrs.marginOffset.top).toBeCloseTo(28.2, 1);
// Verify rotated bounding box is calculated correctly with 10% padding
// Original: 481.8pt × 84.65pt ≈ 642.4px × 112.9px
@@ -1143,8 +1359,8 @@ describe('handleShapeTextWatermarkImport', () => {
// NaN and Infinity should be replaced with defaults
// rotation: NaN becomes default 0
expect(result.attrs.textWatermarkData.rotation).toBe(0);
- // opacity: Infinity becomes default 0.5
- expect(result.attrs.textWatermarkData.fill.opacity).toBe(0.5);
+ // opacity: Infinity becomes default 1
+ expect(result.attrs.textWatermarkData.fill.opacity).toBe(1);
});
it('should sanitize extreme dimension values', () => {
@@ -1198,7 +1414,7 @@ describe('handleShapeTextWatermarkImport', () => {
const result = handleShapeTextWatermarkImport({ params: {}, pict });
// XML special characters should be escaped in the SVG output
- const decodedSrc = decodeURIComponent(result.attrs.src.replace('data:image/svg+xml,', ''));
+ const decodedSrc = decodeSvgDataUri(result.attrs.src);
expect(decodedSrc).toContain('<');
expect(decodedSrc).toContain('>');
// But the actual text should be preserved
@@ -1240,7 +1456,7 @@ describe('handleShapeTextWatermarkImport', () => {
// Should use defaults for empty values
expect(result.attrs.textWatermarkData.fill.color).toBe('silver');
expect(result.attrs.textWatermarkData.textStyle.fontFamily).toBe('Arial');
- expect(result.attrs.textWatermarkData.fill.opacity).toBe(0.5);
+ expect(result.attrs.textWatermarkData.fill.opacity).toBe(1);
});
});
});
diff --git a/packages/super-editor/src/editors/v1/extensions/image/image.js b/packages/super-editor/src/editors/v1/extensions/image/image.js
index 8f675b753a..c43fd9d1d5 100644
--- a/packages/super-editor/src/editors/v1/extensions/image/image.js
+++ b/packages/super-editor/src/editors/v1/extensions/image/image.js
@@ -173,8 +173,16 @@ export const Image = Node.create({
isAnchor: { rendered: false },
vmlWatermark: { rendered: false },
+ vmlTextWatermark: { rendered: false },
+ textWatermarkData: { rendered: false },
+ vmlStyle: { rendered: false },
vmlAttributes: { rendered: false },
vmlImagedata: { rendered: false },
+ vmlTextpathAttributes: { rendered: false },
+ vmlPathAttributes: { rendered: false },
+ vmlFillAttributes: { rendered: false },
+ vmlStrokeAttributes: { rendered: false },
+ vmlWrapAttributes: { rendered: false },
/**
* @category Attribute
diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js
index 5db1c2fdca..9eede1f8fd 100644
--- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js
+++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.js
@@ -25,6 +25,10 @@ export const needsImageRegistration = (node) => {
const src = node.attrs?.src;
if (typeof src !== 'string' || src.length === 0) return false;
+ // Imported VML text watermarks are already self-contained SVG previews with
+ // preserved VML attrs for export. Re-registering them strips that metadata.
+ if (node.attrs?.vmlTextWatermark) return false;
+
// Already registered in DOCX media folder
if (src.startsWith('word/media')) return false;
diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.test.js
index 9869e796bf..ff7aa4de85 100644
--- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.test.js
+++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/imageRegistrationPlugin.test.js
@@ -49,6 +49,15 @@ describe('needsImageRegistration', () => {
expect(needsImageRegistration(node)).toBe(true);
});
+ it('skips imported VML text watermark previews', () => {
+ const node = createImageNode({
+ src: 'data:image/svg+xml;base64,PHN2Zy8+',
+ vmlTextWatermark: true,
+ vmlTextpathAttributes: { string: 'SAMPLE' },
+ });
+ expect(needsImageRegistration(node)).toBe(false);
+ });
+
it('requires registration for relative paths (headless needs media path + rId)', () => {
expect(needsImageRegistration(createImageNode({ src: '/images/photo.png' }))).toBe(true);
expect(needsImageRegistration(createImageNode({ src: '/public/images/extensions/image-landscape.png' }))).toBe(
diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts
index 48528bd9a5..e791e51fda 100644
--- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts
+++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts
@@ -488,6 +488,24 @@ export interface ImageAttrs extends ShapeNodeAttributes {
anchorData?: Record | null;
/** @internal Whether image is anchored */
isAnchor?: boolean;
+ /** @internal Whether this image is a generated preview for a VML text watermark. */
+ vmlTextWatermark?: boolean;
+ /** @internal Parsed VML text watermark data used for preview and round-trip export. */
+ textWatermarkData?: Record;
+ /** @internal Raw VML style string from a text watermark shape. */
+ vmlStyle?: string;
+ /** @internal Raw VML shape attributes. */
+ vmlAttributes?: Record;
+ /** @internal Raw VML textpath attributes. */
+ vmlTextpathAttributes?: Record;
+ /** @internal Raw VML path attributes. */
+ vmlPathAttributes?: Record;
+ /** @internal Raw VML fill attributes. */
+ vmlFillAttributes?: Record;
+ /** @internal Raw VML stroke attributes. */
+ vmlStrokeAttributes?: Record;
+ /** @internal Raw VML wrap attributes. */
+ vmlWrapAttributes?: Record;
/** @internal Simple positioning flag */
simplePos?: boolean;
/** @internal File extension */
diff --git a/packages/super-editor/src/editors/v1/tests/extensions/image.test.js b/packages/super-editor/src/editors/v1/tests/extensions/image.test.js
index c462a0c67b..e9ea0b62f4 100644
--- a/packages/super-editor/src/editors/v1/tests/extensions/image.test.js
+++ b/packages/super-editor/src/editors/v1/tests/extensions/image.test.js
@@ -172,6 +172,30 @@ describe('Image Extension DOM rendering', () => {
});
describe('editor integration', () => {
+ it('preserves VML text watermark metadata when creating image nodes', () => {
+ const watermarkData = {
+ text: 'SAMPLE',
+ fill: { color: 'silver', opacity: 1 },
+ };
+ const imageNode = imageType.create({
+ src: 'data:image/svg+xml;base64,PHN2Zy8+',
+ vmlWatermark: true,
+ vmlTextWatermark: true,
+ textWatermarkData: watermarkData,
+ vmlStyle: 'width:300pt;height:80pt;rotation:315',
+ vmlTextpathAttributes: { string: 'SAMPLE' },
+ vmlFillAttributes: { opacity: '1' },
+ vmlWrapAttributes: { anchorx: 'margin', anchory: 'margin' },
+ });
+
+ expect(imageNode.attrs.vmlTextWatermark).toBe(true);
+ expect(imageNode.attrs.textWatermarkData).toEqual(watermarkData);
+ expect(imageNode.attrs.vmlStyle).toBe('width:300pt;height:80pt;rotation:315');
+ expect(imageNode.attrs.vmlTextpathAttributes).toEqual({ string: 'SAMPLE' });
+ expect(imageNode.attrs.vmlFillAttributes).toEqual({ opacity: '1' });
+ expect(imageNode.attrs.vmlWrapAttributes).toEqual({ anchorx: 'margin', anchory: 'margin' });
+ });
+
it('renders anchored rotation margins in the live DOM', () => {
const {
schema: { nodes },