From e7aa8bef207e603256decbc2f3bc423e73602b7e Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sun, 15 Feb 2026 20:45:48 -0300 Subject: [PATCH 1/4] fix(converter): add preset color and hairline stroke for text boxes (SD-1331) Text box borders were invisible because: 1. Color extraction only handled a:schemeClr and a:srgbClr but not a:prstClr (preset colors like "black"). DOCX text boxes commonly use preset colors, so extraction fell through returning null. 2. Stroke width w="0" was treated as invisible, but in OOXML it means "hairline" (~0.75pt in Word). Adds getPresetColor() with all ECMA-376 preset colors and refactors into shared extractColorFromSolidFill() helper. --- .../wp/helpers/vector-shape-helpers.js | 360 ++++++++++++------ .../wp/helpers/vector-shape-helpers.test.js | 120 +++++- 2 files changed, 372 insertions(+), 108 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js index 61d44924cf..59b209d3f8 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js @@ -1,3 +1,226 @@ +/** + * Converts a preset color name (a:prstClr) to its hex value. + * Per ECMA-376 Part 1, Section 20.1.10.47 (ST_PresetColorVal). + * @param {string} name - The preset color name (e.g., 'black', 'white', 'red') + * @returns {string|null} Hex color value, or null if not recognized + */ +export function getPresetColor(name) { + const colors = { + aliceBlue: '#f0f8ff', + antiqueWhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + black: '#000000', + blanchedAlmond: '#ffebcd', + blue: '#0000ff', + blueViolet: '#8a2be2', + brown: '#a52a2a', + burlyWood: '#deb887', + cadetBlue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerBlue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + cyan: '#00ffff', + dkBlue: '#00008b', + dkCyan: '#008b8b', + dkGoldenrod: '#b8860b', + dkGray: '#a9a9a9', + dkGreen: '#006400', + dkKhaki: '#bdb76b', + dkMagenta: '#8b008b', + dkOliveGreen: '#556b2f', + dkOrange: '#ff8c00', + dkOrchid: '#9932cc', + dkRed: '#8b0000', + dkSalmon: '#e9967a', + dkSeaGreen: '#8fbc8f', + dkSlateBlue: '#483d8b', + dkSlateGray: '#2f4f4f', + dkTurquoise: '#00ced1', + dkViolet: '#9400d3', + deepPink: '#ff1493', + deepSkyBlue: '#00bfff', + dimGray: '#696969', + dodgerBlue: '#1e90ff', + firebrick: '#b22222', + floralWhite: '#fffaf0', + forestGreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostWhite: '#f8f8ff', + gold: '#ffd700', + goldenrod: '#daa520', + gray: '#808080', + green: '#008000', + greenYellow: '#adff2f', + honeydew: '#f0fff0', + hotPink: '#ff69b4', + indianRed: '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavender: '#e6e6fa', + lavenderBlush: '#fff0f5', + lawnGreen: '#7cfc00', + lemonChiffon: '#fffacd', + ltBlue: '#add8e6', + ltCoral: '#f08080', + ltCyan: '#e0ffff', + ltGoldenrodYellow: '#fafad2', + ltGray: '#d3d3d3', + ltGreen: '#90ee90', + ltPink: '#ffb6c1', + ltSalmon: '#ffa07a', + ltSeaGreen: '#20b2aa', + ltSkyBlue: '#87cefa', + ltSlateGray: '#778899', + ltSteelBlue: '#b0c4de', + ltYellow: '#ffffe0', + lime: '#00ff00', + limeGreen: '#32cd32', + linen: '#faf0e6', + magenta: '#ff00ff', + maroon: '#800000', + medAquamarine: '#66cdaa', + medBlue: '#0000cd', + medOrchid: '#ba55d3', + medPurple: '#9370db', + medSeaGreen: '#3cb371', + medSlateBlue: '#7b68ee', + medSpringGreen: '#00fa9a', + medTurquoise: '#48d1cc', + medVioletRed: '#c71585', + midnightBlue: '#191970', + mintCream: '#f5fffa', + mistyRose: '#ffe4e1', + moccasin: '#ffe4b5', + navajoWhite: '#ffdead', + navy: '#000080', + oldLace: '#fdf5e6', + olive: '#808000', + oliveDrab: '#6b8e23', + orange: '#ffa500', + orangeRed: '#ff4500', + orchid: '#da70d6', + paleGoldenrod: '#eee8aa', + paleGreen: '#98fb98', + paleTurquoise: '#afeeee', + paleVioletRed: '#db7093', + papayaWhip: '#ffefd5', + peachPuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderBlue: '#b0e0e6', + purple: '#800080', + red: '#ff0000', + rosyBrown: '#bc8f8f', + royalBlue: '#4169e1', + saddleBrown: '#8b4513', + salmon: '#fa8072', + sandyBrown: '#f4a460', + seaGreen: '#2e8b57', + seaShell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyBlue: '#87ceeb', + slateBlue: '#6a5acd', + slateGray: '#708090', + snow: '#fffafa', + springGreen: '#00ff7f', + steelBlue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + white: '#ffffff', + whiteSmoke: '#f5f5f5', + yellow: '#ffff00', + yellowGreen: '#9acd32', + }; + return colors[name] ?? null; +} + +/** + * Extracts color and alpha from a color element (a:schemeClr, a:srgbClr, or a:prstClr). + * Consolidates shared modifier/alpha logic used by both fill and stroke extraction. + * @param {Object} solidFill - The a:solidFill element + * @returns {{ color: string, alpha: number|null }|null} Color and optional alpha, or null if no color found + */ +function extractColorFromSolidFill(solidFill) { + if (!solidFill?.elements) return null; + + const schemeClr = solidFill.elements.find((el) => el.name === 'a:schemeClr'); + if (schemeClr) { + const themeName = schemeClr.attributes?.['val']; + let color = getThemeColor(themeName); + let alpha = null; + + const modifiers = schemeClr.elements || []; + modifiers.forEach((mod) => { + if (mod.name === 'a:shade') { + color = applyColorModifier(color, 'shade', mod.attributes['val']); + } else if (mod.name === 'a:tint') { + color = applyColorModifier(color, 'tint', mod.attributes['val']); + } else if (mod.name === 'a:lumMod') { + color = applyColorModifier(color, 'lumMod', mod.attributes['val']); + } else if (mod.name === 'a:lumOff') { + color = applyColorModifier(color, 'lumOff', mod.attributes['val']); + } else if (mod.name === 'a:alpha') { + alpha = parseInt(mod.attributes['val']) / 100000; + } + }); + + return { color, alpha }; + } + + const srgbClr = solidFill.elements.find((el) => el.name === 'a:srgbClr'); + if (srgbClr) { + let alpha = null; + const alphaEl = srgbClr.elements?.find((el) => el.name === 'a:alpha'); + if (alphaEl) { + alpha = parseInt(alphaEl.attributes?.['val'] || '100000', 10) / 100000; + } + return { color: '#' + srgbClr.attributes?.['val'], alpha }; + } + + const prstClr = solidFill.elements.find((el) => el.name === 'a:prstClr'); + if (prstClr) { + const presetName = prstClr.attributes?.['val']; + let color = getPresetColor(presetName); + if (!color) return null; + let alpha = null; + + const modifiers = prstClr.elements || []; + modifiers.forEach((mod) => { + if (mod.name === 'a:shade') { + color = applyColorModifier(color, 'shade', mod.attributes['val']); + } else if (mod.name === 'a:tint') { + color = applyColorModifier(color, 'tint', mod.attributes['val']); + } else if (mod.name === 'a:lumMod') { + color = applyColorModifier(color, 'lumMod', mod.attributes['val']); + } else if (mod.name === 'a:lumOff') { + color = applyColorModifier(color, 'lumOff', mod.attributes['val']); + } else if (mod.name === 'a:alpha') { + alpha = parseInt(mod.attributes['val']) / 100000; + } + }); + + return { color, alpha }; + } + + return null; +} + /** * Converts a theme color name to its corresponding hex color value. * Uses the default Office theme color palette. @@ -72,18 +295,27 @@ export function applyColorModifier(hexColor, modifier, value) { /** * Extracts the stroke width from a shape's properties (spPr). + * In OOXML, a:ln w="0" means "hairline" (thinnest visible line), not invisible. + * Word renders hairline strokes at approximately 0.75px. * @param {Object} spPr - The shape properties element * @returns {number} The stroke width in pixels, or 1 if not found */ export function extractStrokeWidth(spPr) { const ln = spPr?.elements?.find((el) => el.name === 'a:ln'); - const w = ln?.attributes?.['w']; - if (!w) return 1; + if (!ln) return 1; + + const w = ln.attributes?.['w']; + if (w == null) return 1; // Convert EMUs to pixels for stroke width using 72 DPI to match Word's rendering // Word appears to use 72 DPI for stroke widths rather than the standard 96 DPI // This gives us: 19050 EMUs * 72 / 914400 = 1.5 pixels (renders closer to 1px in browsers) const emu = typeof w === 'string' ? parseFloat(w) : w; + + // w="0" in OOXML means "hairline" — the thinnest visible stroke. + // Word renders this as roughly 0.75pt (~1px). Use 0.75 as minimum. + if (emu === 0) return 0.75; + const STROKE_DPI = 72; return (emu * STROKE_DPI) / 914400; } @@ -133,29 +365,8 @@ export function extractStrokeColor(spPr, style) { const solidFill = ln.elements?.find((el) => el.name === 'a:solidFill'); if (solidFill) { - const schemeClr = solidFill.elements?.find((el) => el.name === 'a:schemeClr'); - - if (schemeClr) { - const themeName = schemeClr.attributes?.['val']; - let color = getThemeColor(themeName); - - const modifiers = schemeClr.elements || []; - modifiers.forEach((mod) => { - if (mod.name === 'a:shade') { - color = applyColorModifier(color, 'shade', mod.attributes['val']); - } else if (mod.name === 'a:tint') { - color = applyColorModifier(color, 'tint', mod.attributes['val']); - } else if (mod.name === 'a:lumMod') { - color = applyColorModifier(color, 'lumMod', mod.attributes['val']); - } - }); - return color; - } - - const srgbClr = solidFill.elements?.find((el) => el.name === 'a:srgbClr'); - if (srgbClr) { - return '#' + srgbClr.attributes?.['val']; - } + const result = extractColorFromSolidFill(solidFill); + if (result) return result.color; } } @@ -177,29 +388,11 @@ export function extractStrokeColor(spPr, style) { return null; } - const schemeClr = lnRef.elements?.find((el) => el.name === 'a:schemeClr'); - if (!schemeClr) { - // No schemeClr in lnRef - return null rather than default black - return null; - } - - const themeName = schemeClr.attributes?.['val']; - let color = getThemeColor(themeName); - - const modifiers = schemeClr.elements || []; - modifiers.forEach((mod) => { - if (mod.name === 'a:shade') { - color = applyColorModifier(color, 'shade', mod.attributes['val']); - } else if (mod.name === 'a:tint') { - color = applyColorModifier(color, 'tint', mod.attributes['val']); - } else if (mod.name === 'a:lumMod') { - color = applyColorModifier(color, 'lumMod', mod.attributes['val']); - } else if (mod.name === 'a:lumOff') { - color = applyColorModifier(color, 'lumOff', mod.attributes['val']); - } - }); + // Try extracting color from the lnRef element using the shared helper + const lnRefResult = extractColorFromSolidFill(lnRef); + if (lnRefResult) return lnRefResult.color; - return color; + return null; } /** @@ -217,48 +410,12 @@ export function extractFillColor(spPr, style) { const solidFill = spPr?.elements?.find((el) => el.name === 'a:solidFill'); if (solidFill) { - const schemeClr = solidFill.elements?.find((el) => el.name === 'a:schemeClr'); - - if (schemeClr) { - const themeName = schemeClr.attributes?.['val']; - let color = getThemeColor(themeName); - let alpha = null; - - const modifiers = schemeClr.elements || []; - modifiers.forEach((mod) => { - if (mod.name === 'a:shade') { - color = applyColorModifier(color, 'shade', mod.attributes['val']); - } else if (mod.name === 'a:tint') { - color = applyColorModifier(color, 'tint', mod.attributes['val']); - } else if (mod.name === 'a:lumMod') { - color = applyColorModifier(color, 'lumMod', mod.attributes['val']); - } else if (mod.name === 'a:lumOff') { - color = applyColorModifier(color, 'lumOff', mod.attributes['val']); - } else if (mod.name === 'a:alpha') { - alpha = parseInt(mod.attributes['val']) / 100000; - } - }); - - // Return object with alpha if present, otherwise just the color string - if (alpha !== null && alpha < 1) { - return { type: 'solidWithAlpha', color, alpha }; + const result = extractColorFromSolidFill(solidFill); + if (result) { + if (result.alpha !== null && result.alpha < 1) { + return { type: 'solidWithAlpha', color: result.color, alpha: result.alpha }; } - return color; - } - - const srgbClr = solidFill.elements?.find((el) => el.name === 'a:srgbClr'); - if (srgbClr) { - let alpha = null; - const alphaEl = srgbClr.elements?.find((el) => el.name === 'a:alpha'); - if (alphaEl) { - alpha = parseInt(alphaEl.attributes?.['val'] || '100000', 10) / 100000; - } - - const color = '#' + srgbClr.attributes?.['val']; - if (alpha !== null && alpha < 1) { - return { type: 'solidWithAlpha', color, alpha }; - } - return color; + return result.color; } } @@ -291,27 +448,16 @@ export function extractFillColor(spPr, style) { return null; } - const schemeClr = fillRef.elements?.find((el) => el.name === 'a:schemeClr'); - if (!schemeClr) { - // No schemeClr in fillRef - return transparent rather than default blue - return null; - } - - const themeName = schemeClr.attributes?.['val']; - let color = getThemeColor(themeName); - - const modifiers = schemeClr.elements || []; - modifiers.forEach((mod) => { - if (mod.name === 'a:shade') { - color = applyColorModifier(color, 'shade', mod.attributes['val']); - } else if (mod.name === 'a:tint') { - color = applyColorModifier(color, 'tint', mod.attributes['val']); - } else if (mod.name === 'a:lumMod') { - color = applyColorModifier(color, 'lumMod', mod.attributes['val']); + // Try extracting color from the fillRef element using the shared helper + const fillRefResult = extractColorFromSolidFill(fillRef); + if (fillRefResult) { + if (fillRefResult.alpha !== null && fillRefResult.alpha < 1) { + return { type: 'solidWithAlpha', color: fillRefResult.color, alpha: fillRefResult.alpha }; } - }); + return fillRefResult.color; + } - return color; + return null; } /** diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js index 3f625bc752..cfd3f3198d 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getThemeColor, + getPresetColor, applyColorModifier, extractStrokeWidth, extractStrokeColor, @@ -23,6 +24,21 @@ describe('getThemeColor', () => { }); }); +describe('getPresetColor', () => { + it('returns correct color for common preset color names', () => { + expect(getPresetColor('black')).toBe('#000000'); + expect(getPresetColor('white')).toBe('#ffffff'); + expect(getPresetColor('red')).toBe('#ff0000'); + expect(getPresetColor('blue')).toBe('#0000ff'); + expect(getPresetColor('green')).toBe('#008000'); + expect(getPresetColor('yellow')).toBe('#ffff00'); + }); + + it('returns null for unknown preset color name', () => { + expect(getPresetColor('unknownColor')).toBeNull(); + }); +}); + describe('applyColorModifier', () => { it('applies shade modifier', () => { expect(applyColorModifier('#70ad47', 'shade', '50000')).toBe('#385724'); @@ -63,10 +79,32 @@ describe('extractStrokeWidth', () => { expect(extractStrokeWidth(spPr)).toBe(2); }); - it('returns default 1 when not found', () => { + it('returns default 1 when no a:ln element found', () => { expect(extractStrokeWidth({ elements: [] })).toBe(1); expect(extractStrokeWidth(null)).toBe(1); }); + + it('returns default 1 when a:ln has no w attribute', () => { + const spPr = { + elements: [{ name: 'a:ln', attributes: {} }], + }; + expect(extractStrokeWidth(spPr)).toBe(1); + }); + + it('returns hairline width (0.75) for w="0"', () => { + // In OOXML, w="0" means hairline (thinnest visible stroke), not invisible + const spPr = { + elements: [{ name: 'a:ln', attributes: { w: '0' } }], + }; + expect(extractStrokeWidth(spPr)).toBe(0.75); + }); + + it('returns hairline width (0.75) for w=0 (numeric)', () => { + const spPr = { + elements: [{ name: 'a:ln', attributes: { w: 0 } }], + }; + expect(extractStrokeWidth(spPr)).toBe(0.75); + }); }); describe('extractStrokeColor', () => { @@ -125,6 +163,50 @@ describe('extractStrokeColor', () => { expect(extractStrokeColor(spPr, null)).toBe('#ff0000'); }); + it('extracts preset color from prstClr (e.g., black)', () => { + // Text boxes commonly use for stroke + const spPr = { + elements: [ + { + name: 'a:ln', + attributes: { w: '0' }, + elements: [ + { + name: 'a:solidFill', + elements: [{ name: 'a:prstClr', attributes: { val: 'black' } }], + }, + ], + }, + ], + }; + + expect(extractStrokeColor(spPr, null)).toBe('#000000'); + }); + + it('extracts preset color with modifiers from prstClr', () => { + const spPr = { + elements: [ + { + name: 'a:ln', + elements: [ + { + name: 'a:solidFill', + elements: [ + { + name: 'a:prstClr', + attributes: { val: 'white' }, + elements: [{ name: 'a:shade', attributes: { val: '50000' } }], + }, + ], + }, + ], + }, + ], + }; + + expect(extractStrokeColor(spPr, null)).toBe('#808080'); + }); + it('falls back to style when spPr has no stroke', () => { const spPr = { elements: [] }; const style = { @@ -221,6 +303,42 @@ describe('extractFillColor', () => { expect(extractFillColor(spPr, null)).toBe('#00ff00'); }); + it('extracts preset color from prstClr (e.g., white)', () => { + const spPr = { + elements: [ + { + name: 'a:solidFill', + elements: [{ name: 'a:prstClr', attributes: { val: 'white' } }], + }, + ], + }; + + expect(extractFillColor(spPr, null)).toBe('#ffffff'); + }); + + it('extracts preset color with alpha from prstClr', () => { + const spPr = { + elements: [ + { + name: 'a:solidFill', + elements: [ + { + name: 'a:prstClr', + attributes: { val: 'red' }, + elements: [{ name: 'a:alpha', attributes: { val: '50000' } }], + }, + ], + }, + ], + }; + + expect(extractFillColor(spPr, null)).toEqual({ + type: 'solidWithAlpha', + color: '#ff0000', + alpha: 0.5, + }); + }); + it('returns placeholder for unsupported fills', () => { // Gradient fills now return a gradient object const gradientResult = extractFillColor({ elements: [{ name: 'a:gradFill' }] }, null); From 78b6d9a6efdf81801acc1f3d6e3afc78c1f88f5a Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sun, 15 Feb 2026 21:25:29 -0300 Subject: [PATCH 2/4] fix(layout): create fragment for page-relative anchored drawings (SD-1838) Page-relative anchored drawings (move object with text = OFF) were invisible because the main layout loop only checked preRegisteredPositions for image blocks, not drawing blocks. The drawing block path called layoutDrawingBlock() which returned early for anchored items, resulting in no fragment being created. Adds the same preRegisteredPositions check that exists for image blocks to the drawing block path, creating a DrawingFragment with the pre-computed position. --- .../layout-engine/src/index.test.ts | 102 ++++++++++++++++++ .../layout-engine/layout-engine/src/index.ts | 39 +++++++ 2 files changed, 141 insertions(+) diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 3390f22819..2df8df11b6 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -3256,6 +3256,108 @@ describe('requirePageBoundary edge cases', () => { expect(fragment.x).toBeGreaterThanOrEqual(DEFAULT_OPTIONS.margins!.left + 5); expect(fragment.y).toBeGreaterThanOrEqual(DEFAULT_OPTIONS.margins!.top + 3); }); + + it('creates fragment for page-relative anchored drawing (SD-1838)', () => { + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: 'para-anchor', + runs: [], + }; + const drawingBlock: FlowBlock = { + kind: 'drawing', + id: 'drawing-page-relative', + drawingKind: 'vectorShape', + geometry: { width: 100, height: 60, rotation: 0 }, + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'page', + alignH: 'left', + offsetH: 10, + offsetV: 80, + }, + wrap: { + type: 'None', + }, + }; + const paragraphMeasure = makeMeasure([20]); + const drawingMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 100, + height: 60, + scale: 1, + naturalWidth: 100, + naturalHeight: 60, + geometry: { width: 100, height: 60, rotation: 0, flipH: false, flipV: false }, + }; + const layout = layoutDocument( + [paragraphBlock, drawingBlock], + [paragraphMeasure, drawingMeasure], + DEFAULT_OPTIONS, + ); + const fragment = layout.pages[0].fragments.find( + (frag) => frag.blockId === 'drawing-page-relative', + ) as DrawingFragment; + expect(fragment).toBeTruthy(); + expect(fragment.kind).toBe('drawing'); + expect(fragment.isAnchored).toBe(true); + expect(fragment.y).toBe(80); // offsetV from page top + expect(fragment.width).toBe(100); + expect(fragment.height).toBe(60); + }); + + it('creates fragment for margin-relative anchored drawing with wrapNone', () => { + const paragraphBlock: FlowBlock = { + kind: 'paragraph', + id: 'para-anchor-2', + runs: [], + }; + const drawingBlock: FlowBlock = { + kind: 'drawing', + id: 'drawing-margin-relative', + drawingKind: 'vectorShape', + geometry: { width: 80, height: 40, rotation: 0 }, + anchor: { + isAnchored: true, + hRelativeFrom: 'margin', + vRelativeFrom: 'margin', + alignH: 'left', + alignV: 'top', + offsetH: 0, + offsetV: 15, + }, + wrap: { + type: 'None', + }, + }; + const paragraphMeasure = makeMeasure([20]); + const drawingMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 80, + height: 40, + scale: 1, + naturalWidth: 80, + naturalHeight: 40, + geometry: { width: 80, height: 40, rotation: 0, flipH: false, flipV: false }, + }; + const layout = layoutDocument( + [paragraphBlock, drawingBlock], + [paragraphMeasure, drawingMeasure], + DEFAULT_OPTIONS, + ); + const fragment = layout.pages[0].fragments.find( + (frag) => frag.blockId === 'drawing-margin-relative', + ) as DrawingFragment; + expect(fragment).toBeTruthy(); + expect(fragment.kind).toBe('drawing'); + expect(fragment.isAnchored).toBe(true); + // margin-relative, alignV='top', offsetV=15: contentTop + 15 + expect(fragment.y).toBe(DEFAULT_OPTIONS.margins!.top + 15); + expect(fragment.width).toBe(80); + expect(fragment.height).toBe(40); + }); }); describe('anchored images bounds and zIndex', () => { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 11f54aa8e3..0f4d352487 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -2003,6 +2003,45 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (measure.kind !== 'drawing') { throw new Error(`layoutDocument: expected drawing measure for block ${block.id}`); } + + // Check if this is a pre-registered page-relative anchor + const preRegPos = preRegisteredPositions.get(block.id); + if ( + preRegPos && + Number.isFinite(preRegPos.anchorX) && + Number.isFinite(preRegPos.anchorY) && + Number.isFinite(preRegPos.pageNumber) + ) { + // Use pre-computed position for page-relative anchored drawings + const state = paginator.ensurePage(); + const drawBlock = block as DrawingBlock; + const drawMeasure = measure as DrawingMeasure; + + const fragment: DrawingFragment = { + kind: 'drawing', + blockId: drawBlock.id, + drawingKind: drawBlock.drawingKind, + x: preRegPos.anchorX, + y: preRegPos.anchorY, + width: drawMeasure.width, + height: drawMeasure.height, + geometry: drawMeasure.geometry, + scale: drawMeasure.scale, + isAnchored: true, + behindDoc: drawBlock.anchor?.behindDoc === true, + zIndex: getFragmentZIndex(drawBlock), + drawingContentId: drawBlock.drawingContentId, + }; + + const attrs = drawBlock.attrs as Record | undefined; + if (attrs?.pmStart != null) fragment.pmStart = attrs.pmStart as number; + if (attrs?.pmEnd != null) fragment.pmEnd = attrs.pmEnd as number; + + state.page.fragments.push(fragment); + placedAnchoredIds.add(drawBlock.id); + continue; + } + layoutDrawingBlock({ block: block as DrawingBlock, measure: measure as DrawingMeasure, From ba514d3049de9adb727c28c530ecd89ea7ca475b Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 17 Feb 2026 17:29:36 -0300 Subject: [PATCH 3/4] refactor(converter): address review feedback on color extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename extractColorFromSolidFill → extractColorFromElement (it also handles lnRef/fillRef, not just solidFill) - Extract applyModifiersAndAlpha() helper to deduplicate modifier logic across schemeClr, srgbClr, and prstClr branches - Apply shade/tint/lumMod/lumOff modifiers to srgbClr (previously only alpha was handled, inconsistent with the other two color types) --- .../wp/helpers/vector-shape-helpers.js | 103 ++++++++---------- .../wp/helpers/vector-shape-helpers.test.js | 24 ++++ 2 files changed, 70 insertions(+), 57 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js index 59b209d3f8..013340c935 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js @@ -151,71 +151,60 @@ export function getPresetColor(name) { } /** - * Extracts color and alpha from a color element (a:schemeClr, a:srgbClr, or a:prstClr). - * Consolidates shared modifier/alpha logic used by both fill and stroke extraction. - * @param {Object} solidFill - The a:solidFill element + * Applies color modifiers (shade, tint, lumMod, lumOff) and extracts alpha from + * a color element's child modifier elements. + * @param {string} color - The base hex color + * @param {Array} elements - Child elements of the color node (e.g., a:shade, a:alpha) + * @returns {{ color: string, alpha: number|null }} + */ +function applyModifiersAndAlpha(color, elements) { + let alpha = null; + const modifiers = elements || []; + modifiers.forEach((mod) => { + if (mod.name === 'a:shade') { + color = applyColorModifier(color, 'shade', mod.attributes['val']); + } else if (mod.name === 'a:tint') { + color = applyColorModifier(color, 'tint', mod.attributes['val']); + } else if (mod.name === 'a:lumMod') { + color = applyColorModifier(color, 'lumMod', mod.attributes['val']); + } else if (mod.name === 'a:lumOff') { + color = applyColorModifier(color, 'lumOff', mod.attributes['val']); + } else if (mod.name === 'a:alpha') { + alpha = parseInt(mod.attributes['val']) / 100000; + } + }); + return { color, alpha }; +} + +/** + * Extracts color and alpha from an element containing a color child + * (a:schemeClr, a:srgbClr, or a:prstClr). Works with a:solidFill, style + * reference elements (a:lnRef, a:fillRef), or any parent that hosts a color child. + * @param {Object} element - The parent element (e.g., a:solidFill, a:lnRef, a:fillRef) * @returns {{ color: string, alpha: number|null }|null} Color and optional alpha, or null if no color found */ -function extractColorFromSolidFill(solidFill) { - if (!solidFill?.elements) return null; +function extractColorFromElement(element) { + if (!element?.elements) return null; - const schemeClr = solidFill.elements.find((el) => el.name === 'a:schemeClr'); + const schemeClr = element.elements.find((el) => el.name === 'a:schemeClr'); if (schemeClr) { const themeName = schemeClr.attributes?.['val']; - let color = getThemeColor(themeName); - let alpha = null; - - const modifiers = schemeClr.elements || []; - modifiers.forEach((mod) => { - if (mod.name === 'a:shade') { - color = applyColorModifier(color, 'shade', mod.attributes['val']); - } else if (mod.name === 'a:tint') { - color = applyColorModifier(color, 'tint', mod.attributes['val']); - } else if (mod.name === 'a:lumMod') { - color = applyColorModifier(color, 'lumMod', mod.attributes['val']); - } else if (mod.name === 'a:lumOff') { - color = applyColorModifier(color, 'lumOff', mod.attributes['val']); - } else if (mod.name === 'a:alpha') { - alpha = parseInt(mod.attributes['val']) / 100000; - } - }); - - return { color, alpha }; + const baseColor = getThemeColor(themeName); + return applyModifiersAndAlpha(baseColor, schemeClr.elements); } - const srgbClr = solidFill.elements.find((el) => el.name === 'a:srgbClr'); + const srgbClr = element.elements.find((el) => el.name === 'a:srgbClr'); if (srgbClr) { - let alpha = null; - const alphaEl = srgbClr.elements?.find((el) => el.name === 'a:alpha'); - if (alphaEl) { - alpha = parseInt(alphaEl.attributes?.['val'] || '100000', 10) / 100000; - } - return { color: '#' + srgbClr.attributes?.['val'], alpha }; + const baseColor = '#' + srgbClr.attributes?.['val']; + return applyModifiersAndAlpha(baseColor, srgbClr.elements); } - const prstClr = solidFill.elements.find((el) => el.name === 'a:prstClr'); + const prstClr = element.elements.find((el) => el.name === 'a:prstClr'); if (prstClr) { const presetName = prstClr.attributes?.['val']; - let color = getPresetColor(presetName); - if (!color) return null; - let alpha = null; - - const modifiers = prstClr.elements || []; - modifiers.forEach((mod) => { - if (mod.name === 'a:shade') { - color = applyColorModifier(color, 'shade', mod.attributes['val']); - } else if (mod.name === 'a:tint') { - color = applyColorModifier(color, 'tint', mod.attributes['val']); - } else if (mod.name === 'a:lumMod') { - color = applyColorModifier(color, 'lumMod', mod.attributes['val']); - } else if (mod.name === 'a:lumOff') { - color = applyColorModifier(color, 'lumOff', mod.attributes['val']); - } else if (mod.name === 'a:alpha') { - alpha = parseInt(mod.attributes['val']) / 100000; - } - }); - - return { color, alpha }; + const baseColor = getPresetColor(presetName); + if (!baseColor) return null; + return applyModifiersAndAlpha(baseColor, prstClr.elements); } return null; @@ -365,7 +354,7 @@ export function extractStrokeColor(spPr, style) { const solidFill = ln.elements?.find((el) => el.name === 'a:solidFill'); if (solidFill) { - const result = extractColorFromSolidFill(solidFill); + const result = extractColorFromElement(solidFill); if (result) return result.color; } } @@ -389,7 +378,7 @@ export function extractStrokeColor(spPr, style) { } // Try extracting color from the lnRef element using the shared helper - const lnRefResult = extractColorFromSolidFill(lnRef); + const lnRefResult = extractColorFromElement(lnRef); if (lnRefResult) return lnRefResult.color; return null; @@ -410,7 +399,7 @@ export function extractFillColor(spPr, style) { const solidFill = spPr?.elements?.find((el) => el.name === 'a:solidFill'); if (solidFill) { - const result = extractColorFromSolidFill(solidFill); + const result = extractColorFromElement(solidFill); if (result) { if (result.alpha !== null && result.alpha < 1) { return { type: 'solidWithAlpha', color: result.color, alpha: result.alpha }; @@ -449,7 +438,7 @@ export function extractFillColor(spPr, style) { } // Try extracting color from the fillRef element using the shared helper - const fillRefResult = extractColorFromSolidFill(fillRef); + const fillRefResult = extractColorFromElement(fillRef); if (fillRefResult) { if (fillRefResult.alpha !== null && fillRefResult.alpha < 1) { return { type: 'solidWithAlpha', color: fillRefResult.color, alpha: fillRefResult.alpha }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js index cfd3f3198d..f183eb8557 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js @@ -207,6 +207,30 @@ describe('extractStrokeColor', () => { expect(extractStrokeColor(spPr, null)).toBe('#808080'); }); + it('applies shade modifier to srgbClr stroke color', () => { + const spPr = { + elements: [ + { + name: 'a:ln', + elements: [ + { + name: 'a:solidFill', + elements: [ + { + name: 'a:srgbClr', + attributes: { val: 'FFFFFF' }, + elements: [{ name: 'a:shade', attributes: { val: '50000' } }], + }, + ], + }, + ], + }, + ], + }; + + expect(extractStrokeColor(spPr, null)).toBe('#808080'); + }); + it('falls back to style when spPr has no stroke', () => { const spPr = { elements: [] }; const style = { From c00ec8522ae9f716f8ed4880e2f1ae40ab258235 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 17 Feb 2026 13:24:58 -0800 Subject: [PATCH 4/4] fix(layout-engine,painter-dom): honor pre-registered anchor page placement and clipPath invalidation --- .../layout-engine/src/index.test.ts | 159 ++++++++++++++++++ .../layout-engine/layout-engine/src/index.ts | 4 +- .../layout-engine/src/paginator.ts | 5 + .../painters/dom/src/renderer.ts | 80 ++++++++- .../wp/helpers/vector-shape-helpers.test.js | 82 +++++++++ 5 files changed, 327 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 2df8df11b6..2ac733e793 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -3307,6 +3307,94 @@ describe('requirePageBoundary edge cases', () => { expect(fragment.height).toBe(60); }); + it('emits pre-registered page-relative drawings on their stored page after pagination advances', () => { + const firstPageParagraph: FlowBlock = { + kind: 'paragraph', + id: 'para-page-1', + runs: [], + }; + const forcedBreak: FlowBlock = { + kind: 'pageBreak', + id: 'pb-before-drawing', + }; + const secondPageParagraph: FlowBlock = { + kind: 'paragraph', + id: 'para-page-2', + runs: [], + }; + const drawingBlock: FlowBlock = { + kind: 'drawing', + id: 'drawing-pre-reg-page', + drawingKind: 'vectorShape', + geometry: { width: 120, height: 120, rotation: 0 }, + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'page', + alignH: 'left', + alignV: 'top', + offsetH: 0, + offsetV: 0, + }, + wrap: { + type: 'Square', + wrapText: 'right', + distLeft: 0, + distRight: 10, + }, + }; + const paragraphMeasure = makeMeasure([20]); + const drawingMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 120, + height: 120, + scale: 1, + naturalWidth: 120, + naturalHeight: 120, + geometry: { width: 120, height: 120, rotation: 0, flipH: false, flipV: false }, + }; + + const remeasureParagraph: NonNullable = (_block, _maxWidth) => { + return makeMeasure([20]); + }; + + const layout = layoutDocument( + [firstPageParagraph, forcedBreak, drawingBlock, secondPageParagraph], + [paragraphMeasure, { kind: 'pageBreak' }, drawingMeasure, paragraphMeasure], + { + ...DEFAULT_OPTIONS, + remeasureParagraph, + }, + ); + + expect(layout.pages).toHaveLength(2); + + const page1 = layout.pages[0]; + const page2 = layout.pages[1]; + + const wrappedPara = page1.fragments.find( + (fragment) => fragment.kind === 'para' && fragment.blockId === 'para-page-1', + ) as ParaFragment; + expect(wrappedPara).toBeTruthy(); + expect(wrappedPara.x).toBeGreaterThan(DEFAULT_OPTIONS.margins!.left); + + const page2Para = page2.fragments.find( + (fragment) => fragment.kind === 'para' && fragment.blockId === 'para-page-2', + ) as ParaFragment; + expect(page2Para).toBeTruthy(); + + const drawingOnPage1 = page1.fragments.find( + (fragment) => fragment.kind === 'drawing' && fragment.blockId === 'drawing-pre-reg-page', + ); + const drawingOnPage2 = page2.fragments.find( + (fragment) => fragment.kind === 'drawing' && fragment.blockId === 'drawing-pre-reg-page', + ); + + expect(drawingOnPage1).toBeTruthy(); + expect(drawingOnPage2).toBeUndefined(); + }); + it('creates fragment for margin-relative anchored drawing with wrapNone', () => { const paragraphBlock: FlowBlock = { kind: 'paragraph', @@ -3387,6 +3475,77 @@ describe('requirePageBoundary edge cases', () => { // behindDoc → zIndex 0 expect(img.zIndex).toBe(0); }); + + it('emits pre-registered page-relative images on their stored page after pagination advances', () => { + const firstPageParagraph: FlowBlock = { + kind: 'paragraph', + id: 'para-page-1', + runs: [], + }; + const forcedBreak: FlowBlock = { + kind: 'pageBreak', + id: 'pb-before-image', + }; + const secondPageParagraph: FlowBlock = { + kind: 'paragraph', + id: 'para-page-2', + runs: [], + }; + const imageBlock: ImageBlock = { + kind: 'image', + id: 'img-pre-reg-page', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'page', + alignH: 'left', + alignV: 'top', + offsetH: 0, + offsetV: 0, + }, + wrap: { + type: 'Square', + wrapText: 'right', + distLeft: 0, + distRight: 10, + }, + }; + const paragraphMeasure = makeMeasure([20]); + const imageMeasure: ImageMeasure = { + kind: 'image', + width: 120, + height: 120, + }; + + const remeasureParagraph: NonNullable = (_block, _maxWidth) => { + return makeMeasure([20]); + }; + + const layout = layoutDocument( + [firstPageParagraph, forcedBreak, imageBlock, secondPageParagraph], + [paragraphMeasure, { kind: 'pageBreak' }, imageMeasure, paragraphMeasure], + { + ...DEFAULT_OPTIONS, + remeasureParagraph, + }, + ); + + expect(layout.pages).toHaveLength(2); + + const page1 = layout.pages[0]; + const page2 = layout.pages[1]; + + const imageOnPage1 = page1.fragments.find( + (fragment) => fragment.kind === 'image' && fragment.blockId === 'img-pre-reg-page', + ); + const imageOnPage2 = page2.fragments.find( + (fragment) => fragment.kind === 'image' && fragment.blockId === 'img-pre-reg-page', + ); + + expect(imageOnPage1).toBeTruthy(); + expect(imageOnPage2).toBeUndefined(); + }); }); describe('tables in columns/pages', () => { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 0f4d352487..122796397a 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1937,7 +1937,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options Number.isFinite(preRegPos.pageNumber) ) { // Use pre-computed position for page-relative anchors - const state = paginator.ensurePage(); + const state = paginator.getPageByNumber(preRegPos.pageNumber); const imgBlock = block as ImageBlock; const imgMeasure = measure as ImageMeasure; @@ -2013,7 +2013,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options Number.isFinite(preRegPos.pageNumber) ) { // Use pre-computed position for page-relative anchored drawings - const state = paginator.ensurePage(); + const state = paginator.getPageByNumber(preRegPos.pageNumber); const drawBlock = block as DrawingBlock; const drawMeasure = measure as DrawingMeasure; diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts index 01784cfdae..6c15b4bdb8 100644 --- a/packages/layout-engine/layout-engine/src/paginator.ts +++ b/packages/layout-engine/layout-engine/src/paginator.ts @@ -117,6 +117,10 @@ export function createPaginator(opts: PaginatorOptions) { return startNewPage(); }; + const getPageByNumber = (pageNumber: number): PageState => { + return states.find((s) => s.page.number === pageNumber) ?? ensurePage(); + }; + return { pages, states, @@ -125,5 +129,6 @@ export function createPaginator(opts: PaginatorOptions) { advanceColumn, columnX, getActiveColumnsForState, + getPageByNumber, } as const; } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ae68f933f5..6abdf1edeb 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2629,6 +2629,10 @@ export class DomPainter { img.style.objectPosition = 'left top'; } img.style.display = block.display === 'inline' ? 'inline-block' : 'block'; + const imageClipPath = resolveBlockClipPath(block); + if (imageClipPath) { + img.style.clipPath = imageClipPath; + } // Apply VML image adjustments (gain/blacklevel) as CSS filters for watermark effects // conversion formulas calculated based on Libreoffice vml reader @@ -2756,6 +2760,10 @@ export class DomPainter { img.style.objectPosition = 'left top'; } img.style.display = 'block'; + const imageClipPath = resolveBlockClipPath(drawing); + if (imageClipPath) { + img.style.clipPath = imageClipPath; + } return img; } @@ -3997,6 +4005,44 @@ export class DomPainter { applyRunDataAttributes(img, run.dataAttrs); } + const runClipPath = readClipPathValue((run as { clipPath?: unknown }).clipPath); + if (runClipPath && this.doc) { + img.style.clipPath = runClipPath; + img.style.display = 'block'; + img.style.marginTop = ''; + img.style.marginBottom = ''; + img.style.marginLeft = ''; + img.style.marginRight = ''; + img.style.verticalAlign = ''; + img.style.position = 'static'; + img.style.zIndex = ''; + + const wrapper = this.doc.createElement('span'); + wrapper.classList.add('superdoc-inline-image-clip-wrapper'); + wrapper.style.display = 'inline-block'; + wrapper.style.width = `${run.width}px`; + wrapper.style.height = `${run.height}px`; + wrapper.style.verticalAlign = run.verticalAlign ?? 'bottom'; + wrapper.style.position = 'relative'; + wrapper.style.zIndex = '1'; + if (run.distTop) wrapper.style.marginTop = `${run.distTop}px`; + if (run.distBottom) wrapper.style.marginBottom = `${run.distBottom}px`; + if (run.distLeft) wrapper.style.marginLeft = `${run.distLeft}px`; + if (run.distRight) wrapper.style.marginRight = `${run.distRight}px`; + + if (run.pmStart != null) { + wrapper.dataset.pmStart = String(run.pmStart); + } + if (run.pmEnd != null) { + wrapper.dataset.pmEnd = String(run.pmEnd); + } + wrapper.dataset.layoutEpoch = String(this.layoutEpoch); + this.applySdtDataset(wrapper, run.sdt); + + wrapper.appendChild(img); + return wrapper; + } + return img; } @@ -5572,6 +5618,7 @@ const deriveBlockVersion = (block: FlowBlock): string => { imgRun.distBottom ?? '', imgRun.distLeft ?? '', imgRun.distRight ?? '', + readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection ].join(','); } @@ -5676,7 +5723,14 @@ const deriveBlockVersion = (block: FlowBlock): string => { } if (block.kind === 'image') { - return [block.src ?? '', block.width ?? '', block.height ?? '', block.alt ?? '', block.title ?? ''].join('|'); + return [ + block.src ?? '', + block.width ?? '', + block.height ?? '', + block.alt ?? '', + block.title ?? '', + resolveBlockClipPath(block), + ].join('|'); } if (block.kind === 'drawing') { @@ -5689,6 +5743,7 @@ const deriveBlockVersion = (block: FlowBlock): string => { imageLike.width ?? '', imageLike.height ?? '', imageLike.alt ?? '', + resolveBlockClipPath(imageLike), ].join('|'); } if (block.drawingKind === 'vectorShape') { @@ -5940,6 +5995,29 @@ interface CommentHighlightResult { hasNestedComments?: boolean; } +const CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; + +const readClipPathValue = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const normalized = value.trim(); + if (normalized.length === 0) return ''; + const lower = normalized.toLowerCase(); + if (!CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; + return normalized; +}; + +const resolveClipPathFromAttrs = (attrs: unknown): string => { + if (!attrs || typeof attrs !== 'object') return ''; + const record = attrs as Record; + return readClipPathValue(record.clipPath); +}; + +const resolveBlockClipPath = (block: unknown): string => { + if (!block || typeof block !== 'object') return ''; + const record = block as Record; + return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); +}; + const getCommentHighlight = (run: TextRun, activeCommentId: string | null): CommentHighlightResult => { const comments = run.comments; if (!comments || comments.length === 0) return {}; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js index f183eb8557..6d82abef77 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.test.js @@ -284,6 +284,42 @@ describe('extractStrokeColor', () => { }; expect(extractStrokeColor(spPr, style)).toBeNull(); }); + + it('falls back to style lnRef with srgbClr', () => { + const spPr = { elements: [] }; + const style = { + elements: [ + { + name: 'a:lnRef', + attributes: { idx: '1' }, + elements: [{ name: 'a:srgbClr', attributes: { val: '123456' } }], + }, + ], + }; + + expect(extractStrokeColor(spPr, style)).toBe('#123456'); + }); + + it('falls back to style lnRef with prstClr and modifiers', () => { + const spPr = { elements: [] }; + const style = { + elements: [ + { + name: 'a:lnRef', + attributes: { idx: '1' }, + elements: [ + { + name: 'a:prstClr', + attributes: { val: 'white' }, + elements: [{ name: 'a:shade', attributes: { val: '50000' } }], + }, + ], + }, + ], + }; + + expect(extractStrokeColor(spPr, style)).toBe('#808080'); + }); }); describe('extractFillColor', () => { @@ -430,4 +466,50 @@ describe('extractFillColor', () => { }; expect(extractFillColor(spPr, style)).toBeNull(); }); + + it('falls back to style fillRef with srgbClr and alpha', () => { + const spPr = { elements: [] }; + const style = { + elements: [ + { + name: 'a:fillRef', + attributes: { idx: '1' }, + elements: [ + { + name: 'a:srgbClr', + attributes: { val: '00ff00' }, + elements: [{ name: 'a:alpha', attributes: { val: '50000' } }], + }, + ], + }, + ], + }; + + expect(extractFillColor(spPr, style)).toEqual({ + type: 'solidWithAlpha', + color: '#00ff00', + alpha: 0.5, + }); + }); + + it('falls back to style fillRef with prstClr and modifiers', () => { + const spPr = { elements: [] }; + const style = { + elements: [ + { + name: 'a:fillRef', + attributes: { idx: '1' }, + elements: [ + { + name: 'a:prstClr', + attributes: { val: 'white' }, + elements: [{ name: 'a:shade', attributes: { val: '50000' } }], + }, + ], + }, + ], + }; + + expect(extractFillColor(spPr, style)).toBe('#808080'); + }); });