From ed68f54be183bc642460aeef5f253526f0c6e8f2 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Sat, 23 May 2026 22:34:17 +0300 Subject: [PATCH 1/6] fix: improve vml watermarks rendering --- .../handle-shape-text-watermark-import.js | 94 +++++++++++++-- ...handle-shape-text-watermark-import.test.js | 112 ++++++++++++++++++ 2 files changed, 196 insertions(+), 10 deletions(-) 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..ebefcc831f 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 @@ -82,7 +82,7 @@ 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'; @@ -107,7 +107,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), 0.5, 0, 1); const sanitizedRotation = sanitizeNumeric(rotation, 0, -360, 360); const svgResult = generateTextWatermarkSVG({ @@ -243,6 +243,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(/&#x([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 +297,24 @@ 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); +} + /** * 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 @@ -274,9 +327,7 @@ function generateTextWatermarkSVG({ text, width, height, rotation, fill, textSty // 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 +341,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 = resolveRenderedTextWatermarkOpacity(sanitizeNumeric(fill?.opacity, 0.5, 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); @@ -323,8 +374,10 @@ function generateTextWatermarkSVG({ text, width, height, rotation, fill, textSty dominant-baseline="middle" font-family="${fontFamily}" font-size="${sanitizedFontSize}px" + textLength="${sanitizedWidth}" + lengthAdjust="spacingAndGlyphs" fill="${color}" - opacity="${opacity}" + fill-opacity="${opacity}" transform="rotate(${sanitizedRotation} ${centerX} ${centerY})">${escapeXml(text)} `; @@ -335,6 +388,25 @@ function generateTextWatermarkSVG({ text, width, height, rotation, fill, textSty }; } +function resolveRenderedTextWatermarkOpacity(opacity) { + return sanitizeNumeric(opacity * 0.5, 0.25, 0, 1); +} + +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 +430,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..0fd6a26fec 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 @@ -480,6 +480,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 +610,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 = decodeURIComponent(result.attrs.src.replace('data:image/svg+xml,', '')); + expect(decodedSvg).toContain('font-family="Calibri, Arial, sans-serif"'); + }); + it('should handle single quotes in font-family', () => { const pict = { elements: [ @@ -734,6 +803,49 @@ 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 = decodeURIComponent(result.attrs.src.replace('data:image/svg+xml,', '')); + + 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.25"'); + expect(result.attrs.textWatermarkData.fill.color).toBe('silver'); + expect(result.attrs.textWatermarkData.fill.opacity).toBe(0.5); + }); + }); + describe('Textpath properties', () => { it('should extract textpath boolean properties', () => { const pict = { From 8e977ed1138e7aaa1d840b4a9a2425c0cb1cb5e4 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Sat, 23 May 2026 22:56:30 +0300 Subject: [PATCH 2/6] fix: improve watermark positioning --- .../handle-shape-text-watermark-import.js | 28 ++++++++++++++++++- ...handle-shape-text-watermark-import.test.js | 10 ++++--- 2 files changed, 33 insertions(+), 5 deletions(-) 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 ebefcc831f..9fd60a5af0 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 @@ -17,6 +17,11 @@ * @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; + export function handleShapeTextWatermarkImport({ pict }) { const shape = pict.elements?.find((el) => el.name === 'v:shape'); if (!shape) return null; @@ -126,6 +131,14 @@ export function handleShapeTextWatermarkImport({ pict }) { }); const svgDataUri = svgResult.dataUri; + const centerOffsetTop = getTextWatermarkCenterOffset({ + hPosition, + vPosition, + hRelativeTo, + vRelativeTo, + height: heightPx, + rotation: sanitizedRotation, + }); // Return as an image node (so it uses the Image extension for rendering) // but preserve all VML attributes for export round-trip @@ -171,7 +184,7 @@ export function handleShapeTextWatermarkImport({ pict }) { // 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), + top: vPosition === 'center' && vRelativeTo === 'margin' ? centerOffsetTop : convertToPixels(position.marginTop), }, // Store text watermark specific data for export textWatermarkData: { @@ -315,6 +328,19 @@ function parseVmlOpacity(value) { 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 + ); +} + /** * 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 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 0fd6a26fec..104ec891dd 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 @@ -232,8 +232,8 @@ 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 + // For non-rotated center-aligned watermarks relative to margin, both 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); }); @@ -841,6 +841,7 @@ describe('handleShapeTextWatermarkImport', () => { expect(decodedSvg).toContain('font-size="268.7253333333333px"'); expect(decodedSvg).toContain('fill="#C0C0C0"'); expect(decodedSvg).toContain('fill-opacity="0.25"'); + 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); }); @@ -987,9 +988,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 From ae699223ba5bb7ff0c18548b80b7aa09c4f2211f Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Sat, 23 May 2026 23:21:14 +0300 Subject: [PATCH 3/6] fix: review commen and svg registration error --- .../src/editors/v1/core/helpers/base64.js | 50 ++++++++++++++++++ .../v1/core/helpers/superdocClipboardSlice.js | 52 +------------------ .../handle-shape-text-watermark-import.js | 41 +++++++++------ ...handle-shape-text-watermark-import.test.js | 46 ++++++++++++++-- .../imageHelpers/imageRegistrationPlugin.js | 4 ++ .../imageRegistrationPlugin.test.js | 9 ++++ 6 files changed, 130 insertions(+), 72 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/helpers/base64.js 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/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 9fd60a5af0..6b852278e3 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). * @@ -93,6 +95,7 @@ export function handleShapeTextWatermarkImport({ pict }) { // Extract other textpath attributes const fitshape = textpathAttrs.fitshape || 't'; + const shouldFitShape = fitshape === 't'; const trim = textpathAttrs.trim || 't'; const textpathOn = textpathAttrs.on || 't'; @@ -128,6 +131,7 @@ export function handleShapeTextWatermarkImport({ pict }) { fontFamily, fontSize, }, + fitShape: shouldFitShape, }); const svgDataUri = svgResult.dataUri; @@ -209,7 +213,7 @@ export function handleShapeTextWatermarkImport({ pict }) { }, textpath: { on: textpathOn === 't', - fitshape: fitshape === 't', + fitshape: shouldFitShape, trim: trim === 't', textpathok: textpathok === 't', }, @@ -232,7 +236,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'; } @@ -348,7 +352,7 @@ function getTextWatermarkCenterOffset({ hPosition, vPosition, hRelativeTo, vRela * @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) @@ -391,24 +395,27 @@ 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)} -`; + ${escapeXml(text)} + `; return { - dataUri: `data:image/svg+xml,${encodeURIComponent(svg)}`, + dataUri: `data:image/svg+xml;base64,${encodeUtf8Base64(svg)}`, svgWidth, svgHeight, }; 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 104ec891dd..37952584d8 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', () => { @@ -642,7 +650,7 @@ describe('handleShapeTextWatermarkImport', () => { const result = handleShapeTextWatermarkImport({ params: {}, pict }); expect(result.attrs.textWatermarkData.textStyle.fontFamily).toBe('Calibri'); - const decodedSvg = decodeURIComponent(result.attrs.src.replace('data:image/svg+xml,', '')); + const decodedSvg = decodeSvgDataUri(result.attrs.src); expect(decodedSvg).toContain('font-family="Calibri, Arial, sans-serif"'); }); @@ -834,7 +842,7 @@ describe('handleShapeTextWatermarkImport', () => { }; const result = handleShapeTextWatermarkImport({ params: {}, pict }); - const decodedSvg = decodeURIComponent(result.attrs.src.replace('data:image/svg+xml,', '')); + const decodedSvg = decodeSvgDataUri(result.attrs.src); expect(decodedSvg).toMatch(/textLength="639\.866666666666[67]"/); expect(decodedSvg).toContain('lengthAdjust="spacingAndGlyphs"'); @@ -921,6 +929,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', () => { @@ -1312,7 +1350,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 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( From 0f65b04a371abebe2af1f501ef291aec5ee53dcf Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 25 May 2026 17:03:12 +0300 Subject: [PATCH 4/6] fix: improve opacity and positioning --- .../painters/dom/src/index.test.ts | 101 +++++++++++++++++ .../painters/dom/src/renderer.ts | 38 ++++++- .../HeaderFooterSessionManager.ts | 18 +++ .../tests/HeaderFooterSessionManager.test.ts | 8 +- .../handle-shape-text-watermark-import.js | 105 ++++++++++++++---- ...handle-shape-text-watermark-import.test.js | 82 ++++++++++++-- .../src/editors/v1/extensions/image/image.js | 8 ++ .../v1/extensions/types/node-attributes.ts | 18 +++ .../editors/v1/tests/extensions/image.test.js | 24 ++++ 9 files changed, 367 insertions(+), 35 deletions(-) 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/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/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 6b852278e3..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 @@ -23,6 +23,8 @@ import { encodeUtf8Base64 } from '../../../../../../helpers/base64.js'; 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'); @@ -60,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'; @@ -75,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 @@ -115,7 +121,7 @@ export function handleShapeTextWatermarkImport({ pict }) { const heightPx = convertToPixels(height); // Sanitize numeric values before use - const sanitizedOpacity = sanitizeNumeric(parseVmlOpacity(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({ @@ -143,6 +149,27 @@ export function handleShapeTextWatermarkImport({ pict }) { 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 @@ -173,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' ? centerOffsetTop : convertToPixels(position.marginTop), - }, + marginOffset, // Store text watermark specific data for export textWatermarkData: { text: watermarkText, @@ -345,6 +362,56 @@ function getTextWatermarkCenterOffset({ hPosition, vPosition, hRelativeTo, vRela ); } +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 @@ -372,7 +439,7 @@ function generateTextWatermarkSVG({ text, width, height, rotation, fill, textSty // Sanitize all values from untrusted input const color = normalizeVmlColor(sanitizeColor(fill?.color, 'silver')); - const opacity = resolveRenderedTextWatermarkOpacity(sanitizeNumeric(fill?.opacity, 0.5, 0, 1)); + 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); @@ -421,10 +488,6 @@ function generateTextWatermarkSVG({ text, width, height, rotation, fill, textSty }; } -function resolveRenderedTextWatermarkOpacity(opacity) { - return sanitizeNumeric(opacity * 0.5, 0.25, 0, 1); -} - function resolveSvgFontFamily(fontFamily) { if (!fontFamily || typeof fontFamily !== 'string') { return 'Arial, sans-serif'; 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 37952584d8..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 @@ -218,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: [ { @@ -240,10 +240,10 @@ describe('handleShapeTextWatermarkImport', () => { const result = handleShapeTextWatermarkImport({ params: {}, pict }); - // For non-rotated center-aligned watermarks relative to margin, both 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', () => { @@ -335,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: [ @@ -848,11 +882,41 @@ describe('handleShapeTextWatermarkImport', () => { expect(decodedSvg).toContain('lengthAdjust="spacingAndGlyphs"'); expect(decodedSvg).toContain('font-size="268.7253333333333px"'); expect(decodedSvg).toContain('fill="#C0C0C0"'); - expect(decodedSvg).toContain('fill-opacity="0.25"'); + 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', () => { @@ -1295,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', () => { @@ -1392,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/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 }, From a0023a9fb15c467b5edf6e19dfec77fe381abea7 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 25 May 2026 17:47:41 +0300 Subject: [PATCH 5/6] fix: failing test --- .../core/presentation-editor/tests/PresentationEditor.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' }, ]); From 967cf1c8969604dcd1c185b2f15b39042747ebe8 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 25 May 2026 19:36:26 -0300 Subject: [PATCH 6/6] test(super-editor): cover Word-native VML watermark opacity cases --- ...handle-shape-text-watermark-import.test.js | 59 ++++++++++++++++++- .../sd-3265-vml-text-watermark-solid.spec.ts | 28 +++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 tests/behavior/tests/importing/sd-3265-vml-text-watermark-solid.spec.ts 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 47ca9c8007..e4f630450a 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 @@ -555,6 +555,39 @@ describe('handleShapeTextWatermarkImport', () => { expect(percentResult.attrs.textWatermarkData.fill.opacity).toBe(0.5); expect(fixedResult.attrs.textWatermarkData.fill.opacity).toBe(0.5); }); + + it('should preserve explicit opacity="0" instead of coercing to default', () => { + const pict = { + elements: [ + { + name: 'v:shape', + attributes: { + style: 'width:100pt', + }, + elements: [ + { + name: 'v:textpath', + attributes: { + string: 'INVISIBLE', + }, + }, + { + name: 'v:fill', + attributes: { + opacity: '0', + }, + }, + ], + }, + ], + }; + + const result = handleShapeTextWatermarkImport({ params: {}, pict }); + const decodedSvg = decodeSvgDataUri(result.attrs.src); + + expect(result.attrs.textWatermarkData.fill.opacity).toBe(0); + expect(decodedSvg).toContain('fill-opacity="0"'); + }); }); describe('Stroke properties', () => { @@ -888,14 +921,30 @@ describe('handleShapeTextWatermarkImport', () => { expect(result.attrs.textWatermarkData.fill.opacity).toBe(0.5); }); - it('renders missing VML opacity as fully opaque', () => { + it('renders a Word-native no-fill t136 watermark as fully opaque', () => { const pict = { elements: [ + { + name: 'v:shapetype', + attributes: { id: '_x0000_t136', 'o:spt': '136', adj: '10800' }, + elements: [ + { + name: 'v:path', + attributes: { textpathok: 't' }, + }, + { + name: 'v:textpath', + attributes: { on: 't', fitshape: 't' }, + }, + ], + }, { name: 'v:shape', attributes: { - style: 'width:300pt;height:80pt;rotation:315', - fillcolor: 'red', + id: '_x0000_s1025', + type: '#_x0000_t136', + style: 'margin-left:1in;margin-top:337.5pt;width:468pt;height:117pt;rotation:315', + fillcolor: 'silver', stroked: 'f', }, elements: [ @@ -904,6 +953,8 @@ describe('handleShapeTextWatermarkImport', () => { attributes: { string: 'SOLID', style: 'font-family:"Calibri";font-size:1pt', + trim: 't', + fitpath: 't', }, }, ], @@ -916,6 +967,8 @@ describe('handleShapeTextWatermarkImport', () => { expect(result.attrs.textWatermarkData.fill.opacity).toBe(1); expect(decodedSvg).toContain('fill-opacity="1"'); + expect(decodedSvg).toContain('textLength='); + expect(decodedSvg).toContain('lengthAdjust="spacingAndGlyphs"'); }); }); diff --git a/tests/behavior/tests/importing/sd-3265-vml-text-watermark-solid.spec.ts b/tests/behavior/tests/importing/sd-3265-vml-text-watermark-solid.spec.ts new file mode 100644 index 0000000000..b9da806ac8 --- /dev/null +++ b/tests/behavior/tests/importing/sd-3265-vml-text-watermark-solid.spec.ts @@ -0,0 +1,28 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/rendering/sd-3265-vml-text-watermark-solid.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test('imports a Word-native solid VML watermark with full opacity (SD-3265)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + const watermark = superdoc.page.locator('.superdoc-page img[src^="data:image/svg+xml"]').first(); + await expect(watermark).toBeVisible(); + + const src = await watermark.getAttribute('src'); + expect(src).toBeTruthy(); + const svg = src!.startsWith('data:image/svg+xml;base64,') + ? Buffer.from(src!.slice('data:image/svg+xml;base64,'.length), 'base64').toString('utf8') + : decodeURIComponent(src!.replace('data:image/svg+xml;utf8,', '')); + + expect(svg).toContain('fill-opacity="1"'); + expect(svg).toContain('SOLID'); +});