diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 3390f22819..2ac733e793 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -3256,6 +3256,196 @@ 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('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', + 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', () => { @@ -3285,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 11f54aa8e3..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; @@ -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.getPageByNumber(preRegPos.pageNumber); + 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, 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.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/vector-shape-helpers.js index 61d44924cf..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 @@ -1,3 +1,215 @@ +/** + * 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; +} + +/** + * 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 extractColorFromElement(element) { + if (!element?.elements) return null; + + const schemeClr = element.elements.find((el) => el.name === 'a:schemeClr'); + if (schemeClr) { + const themeName = schemeClr.attributes?.['val']; + const baseColor = getThemeColor(themeName); + return applyModifiersAndAlpha(baseColor, schemeClr.elements); + } + + const srgbClr = element.elements.find((el) => el.name === 'a:srgbClr'); + if (srgbClr) { + const baseColor = '#' + srgbClr.attributes?.['val']; + return applyModifiersAndAlpha(baseColor, srgbClr.elements); + } + + const prstClr = element.elements.find((el) => el.name === 'a:prstClr'); + if (prstClr) { + const presetName = prstClr.attributes?.['val']; + const baseColor = getPresetColor(presetName); + if (!baseColor) return null; + return applyModifiersAndAlpha(baseColor, prstClr.elements); + } + + return null; +} + /** * Converts a theme color name to its corresponding hex color value. * Uses the default Office theme color palette. @@ -72,18 +284,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 +354,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 = extractColorFromElement(solidFill); + if (result) return result.color; } } @@ -177,29 +377,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 = extractColorFromElement(lnRef); + if (lnRefResult) return lnRefResult.color; - return color; + return null; } /** @@ -217,48 +399,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 = extractColorFromElement(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 +437,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 = extractColorFromElement(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..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 @@ -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,74 @@ 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('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 = { @@ -178,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', () => { @@ -221,6 +363,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); @@ -288,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'); + }); });