diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index ee626ce37f..02322e9ea3 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -245,6 +245,13 @@ export type LineBreakRun = { pmEnd?: number; }; +export type ImageLuminanceAdjustment = { + /** OOXML a:lum/@bright in raw units (-100000..100000). */ + bright?: number; + /** OOXML a:lum/@contrast in raw units (-100000..100000). */ + contrast?: number; +}; + /** * Inline image run for images that flow with text on the same line. * Unlike ImageBlock (anchored/floating images), ImageRun is part of the paragraph's run array @@ -318,6 +325,7 @@ export type ImageRun = { blacklevel?: string | number; // Contrast adjustment (VML hex string or number) // OOXML image effects grayscale?: boolean; // Apply grayscale filter to image + lum?: ImageLuminanceAdjustment; // DrawingML luminance adjustment from a:lum }; export type BreakRun = { @@ -569,6 +577,7 @@ export type ImageBlock = { blacklevel?: string | number; // Contrast adjustment (VML hex string or number) // OOXML image effects grayscale?: boolean; // Apply grayscale filter to image + lum?: ImageLuminanceAdjustment; // DrawingML luminance adjustment from a:lum // Image transformations from OOXML a:xfrm (applies to both inline and anchored images) rotation?: number; // Rotation angle in degrees flipH?: boolean; // Horizontal flip diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 660f8e0d9c..ac579eba28 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -4620,6 +4620,236 @@ describe('DomPainter', () => { expect(img?.height).toBe(100); }); + it('renders DrawingML luminance using percentage units', () => { + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const imageBlocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'img-block-dml', + runs: [ + { + kind: 'image', + src: dataUrl, + width: 100, + height: 100, + lum: { + bright: 70000, + contrast: -70000, + }, + }, + ], + }, + ]; + + const imageMeasures: Measure[] = [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 100, + ascent: 100, + descent: 0, + lineHeight: 100, + }, + ], + totalHeight: 100, + }, + ]; + + const imageLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'img-block-dml', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 100, + }, + ], + }, + ], + }; + + const painter = createDomPainter({ blocks: imageBlocks, measures: imageMeasures }); + painter.paint(imageLayout, mount); + + const img = mount.querySelector('img'); + expect(img).toBeTruthy(); + + const parseFilter = (value: string) => { + const match = value.match(/contrast\(([^)]+)\)\s+brightness\(([^)]+)\)/); + expect(match).toBeTruthy(); + return { + contrast: Number(match?.[1]), + brightness: Number(match?.[2]), + }; + }; + + const filter = parseFilter((img as HTMLElement).style.filter); + expect(filter.contrast).toBeCloseTo(0.3, 4); + expect(filter.brightness).toBeCloseTo(1.7, 4); + }); + + it('preserves zero-valued DrawingML luminance filters', () => { + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const imageBlocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'img-block-dml-zero', + runs: [ + { + kind: 'image', + src: dataUrl, + width: 100, + height: 100, + lum: { + bright: -100000, + contrast: -100000, + }, + }, + ], + }, + ]; + + const imageMeasures: Measure[] = [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 100, + ascent: 100, + descent: 0, + lineHeight: 100, + }, + ], + totalHeight: 100, + }, + ]; + + const imageLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'img-block-dml-zero', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 100, + }, + ], + }, + ], + }; + + const painter = createDomPainter({ blocks: imageBlocks, measures: imageMeasures }); + painter.paint(imageLayout, mount); + + const img = mount.querySelector('img'); + expect(img).toBeTruthy(); + expect((img as HTMLElement).style.filter).toContain('contrast(0)'); + expect((img as HTMLElement).style.filter).toContain('brightness(0)'); + }); + + it('renders VML gain and blacklevel using fixed-fraction units', () => { + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const imageBlocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'img-block-vml', + runs: [ + { + kind: 'image', + src: dataUrl, + width: 100, + height: 100, + gain: '19661f', + blacklevel: '22938f', + }, + ], + }, + ]; + + const imageMeasures: Measure[] = [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 100, + ascent: 100, + descent: 0, + lineHeight: 100, + }, + ], + totalHeight: 100, + }, + ]; + + const imageLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'img-block-vml', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 100, + }, + ], + }, + ], + }; + + const painter = createDomPainter({ blocks: imageBlocks, measures: imageMeasures }); + painter.paint(imageLayout, mount); + + const img = mount.querySelector('img'); + expect(img).toBeTruthy(); + + const parseFilter = (value: string) => { + const match = value.match(/contrast\(([^)]+)\)\s+brightness\(([^)]+)\)/); + expect(match).toBeTruthy(); + return { + contrast: Number(match?.[1]), + brightness: Number(match?.[2]), + }; + }; + + const filter = parseFilter((img as HTMLElement).style.filter); + expect(filter.contrast).toBeCloseTo(19661 / 65536, 4); + expect(filter.brightness).toBeCloseTo(1 + 22938 / 32767, 4); + }); + it('renders img element with external https URL', () => { const imageBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 2fa0241935..801f2891bc 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -776,6 +776,81 @@ const isValidSafeFragment = (fragment: string): boolean => { return SAFE_ANCHOR_PATTERN.test(fragment); }; +type ImageFilterSource = Pick; + +const clampLumUnit = (value: number): number => { + return Math.max(-100000, Math.min(100000, value)); +}; + +const parseVmlFixedFraction = (value: string | number | undefined): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value !== 'string' || value.length === 0) { + return null; + } + + if (value.endsWith('f')) { + const raw = Number.parseInt(value.slice(0, -1), 10); + return Number.isFinite(raw) ? raw / 65536 : null; + } + + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const buildImageFilters = (source: ImageFilterSource): string[] => { + const filters: string[] = []; + + if (source.grayscale) { + filters.push('grayscale(100%)'); + } + + if (source.gain != null || source.blacklevel != null) { + const gain = parseVmlFixedFraction(source.gain); + const blacklevel = parseVmlFixedFraction(source.blacklevel); + + if (gain != null) { + const contrast = Math.max(0, gain); + if (contrast > 0) { + filters.push(`contrast(${contrast})`); + } + } + + if (blacklevel != null) { + // CSS has no black-point control, so approximate VML blacklevel with a linear + // brightness shift using the same 0..32767 range Word's watermark UI uses. + const brightness = Math.max(0, 1 + blacklevel * (65536 / 32767)); + if (brightness > 0) { + filters.push(`brightness(${brightness})`); + } + } + } + + if (source.lum) { + // a:lum uses ST_FixedPercentage values expressed in thousandths of a percent. + // Convert those percentage deltas into CSS filter multipliers. + const contrastValue = typeof source.lum.contrast === 'number' ? clampLumUnit(source.lum.contrast) : null; + const brightValue = typeof source.lum.bright === 'number' ? clampLumUnit(source.lum.bright) : null; + + if (contrastValue != null) { + const contrast = Math.max(0, 1 + contrastValue / 100000); + if (contrast >= 0) { + filters.push(`contrast(${contrast})`); + } + } + + if (brightValue != null) { + const brightness = Math.max(0, 1 + brightValue / 100000); + if (brightness >= 0) { + filters.push(`brightness(${brightness})`); + } + } + } + + return filters; +}; + /** * URL-encode a fragment string for use in a URL hash. * Returns null if encoding fails (rare edge case). @@ -3254,36 +3329,7 @@ export class DomPainter { img.style.transformOrigin = 'center'; } - // Apply VML image adjustments (gain/blacklevel) as CSS filters for watermark effects - // conversion formulas calculated based on Libreoffice vml reader - // https://github.com/LibreOffice/core/blob/951a74d047cfddff78014225f55ecb2bbdcd9c4c/oox/source/vml/vmlshapecontext.cxx#L465C13-L493C1 - const filters: string[] = []; - - // Apply OOXML grayscale effect - if (block.grayscale) { - filters.push('grayscale(100%)'); - } - - if (block.gain != null || block.blacklevel != null) { - // Convert VML gain to CSS contrast - // VML gain is a hex string like "19661f" - higher = more contrast - if (block.gain && typeof block.gain === 'string' && block.gain.endsWith('f')) { - const contrast = Math.max(0, parseInt(block.gain) / 65536) * (2 / 3); // 2/3 factor based on visual comparison. - if (contrast > 0) { - filters.push(`contrast(${contrast})`); - } - } - - // Convert VML blacklevel (brightness) to CSS brightness - // VML blacklevel is a hex string like "22938f" - lower = less brightness - if (block.blacklevel && typeof block.blacklevel === 'string' && block.blacklevel.endsWith('f')) { - const brightness = Math.max(0, 1 + parseInt(block.blacklevel) / 327 / 100) * 1.3; // 1.3 factor added based on visual comparison. - if (brightness > 0) { - filters.push(`brightness(${brightness})`); - } - } - } - + const filters = buildImageFilters(block); if (filters.length > 0) { img.style.filter = filters.join(' '); } @@ -4744,32 +4790,7 @@ export class DomPainter { img.style.transformOrigin = 'center'; } - // Apply image effects (grayscale, VML adjustments for watermarks) - const filters: string[] = []; - - // Apply OOXML grayscale effect - if (run.grayscale) { - filters.push('grayscale(100%)'); - } - - if (run.gain != null || run.blacklevel != null) { - // Convert VML gain to CSS contrast - if (run.gain && typeof run.gain === 'string' && run.gain.endsWith('f')) { - const contrast = Math.max(0, parseInt(run.gain) / 65536) * (2 / 3); - if (contrast > 0) { - filters.push(`contrast(${contrast})`); - } - } - - // Convert VML blacklevel to CSS brightness - if (run.blacklevel && typeof run.blacklevel === 'string' && run.blacklevel.endsWith('f')) { - const brightness = Math.max(0, 1 + parseInt(run.blacklevel) / 327 / 100) * 1.3; - if (brightness > 0) { - filters.push(`brightness(${brightness})`); - } - } - } - + const filters = buildImageFilters(run); if (filters.length > 0) { img.style.filter = filters.join(' '); } diff --git a/packages/layout-engine/pm-adapter/src/converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/image.ts index c74fc266e7..b83f99af89 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.ts @@ -257,6 +257,9 @@ export function imageNodeToBlock( const explicitObjectFit = typeof attrs.objectFit === 'string' ? (attrs.objectFit as string) : undefined; const shouldCover = attrs.shouldCover === true; const isAnchor = anchor?.isAnchored ?? (typeof attrs.isAnchor === 'boolean' ? attrs.isAnchor : false); + const lum = isPlainObject(attrs.lum) ? attrs.lum : undefined; + const lumBright = pickNumber(lum?.bright); + const lumContrast = pickNumber(lum?.contrast); const objectFit: 'contain' | 'cover' | 'fill' | 'scale-down' | undefined = isAllowedObjectFit(explicitObjectFit) ? explicitObjectFit @@ -299,6 +302,13 @@ export function imageNodeToBlock( typeof attrs.blacklevel === 'string' || typeof attrs.blacklevel === 'number' ? attrs.blacklevel : undefined, // OOXML image effects (grayscale, etc.) grayscale: typeof attrs.grayscale === 'boolean' ? attrs.grayscale : undefined, + lum: + lumBright != null || lumContrast != null + ? { + ...(lumBright != null ? { bright: lumBright } : {}), + ...(lumContrast != null ? { contrast: lumContrast } : {}), + } + : undefined, // Image transformations from OOXML a:xfrm ...(rotation !== undefined && { rotation }), ...(flipH !== undefined && { flipH }), diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts index c99d3e7b9d..efdcddc873 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts @@ -156,6 +156,15 @@ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverter if (typeof attrs.grayscale === 'boolean') { run.grayscale = attrs.grayscale; } + const lum = isPlainObject(attrs.lum) ? attrs.lum : undefined; + const bright = pickNumber(lum?.bright); + const contrast = pickNumber(lum?.contrast); + if (bright != null || contrast != null) { + run.lum = { + ...(bright != null ? { bright } : {}), + ...(contrast != null ? { contrast } : {}), + }; + } return run; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index 9e834481c1..fcb83a916a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -278,6 +278,19 @@ export const translateImageNode = (params) => { const drawingXmlns = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const pictureXmlns = 'http://schemas.openxmlformats.org/drawingml/2006/picture'; + const blipEffects = []; + if (attrs.grayscale) { + blipEffects.push({ name: 'a:grayscl' }); + } + if (attrs.lum && (Number.isFinite(attrs.lum.bright) || Number.isFinite(attrs.lum.contrast))) { + blipEffects.push({ + name: 'a:lum', + attributes: { + ...(Number.isFinite(attrs.lum.bright) ? { bright: Math.round(attrs.lum.bright) } : {}), + ...(Number.isFinite(attrs.lum.contrast) ? { contrast: Math.round(attrs.lum.contrast) } : {}), + }, + }); + } // Resolve hyperlink relationship once; shared by wp:docPr and pic:cNvPr. const hlinkRId = resolveHyperlinkRId(attrs, params); @@ -330,11 +343,7 @@ export const translateImageNode = (params) => { attributes: { 'r:embed': imageId, }, - ...(attrs.grayscale - ? { - elements: [{ name: 'a:grayscl' }], - } - : {}), + ...(blipEffects.length ? { elements: blipEffects } : {}), }, ...(rawSrcRect ? [rawSrcRect] : []), { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 6e45927df2..87558867af 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -155,6 +155,36 @@ describe('translateImageNode', () => { expect(blip.elements).toEqual([{ name: 'a:grayscl' }]); }); + it('should export luminance adjustment when present', () => { + baseParams.node.attrs.lum = { bright: 70000, contrast: -70000 }; + + const result = translateImageNode(baseParams); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + + expect(blip.elements).toEqual([{ name: 'a:lum', attributes: { bright: 70000, contrast: -70000 } }]); + }); + + it('should export grayscale and luminance adjustment together when both are present', () => { + baseParams.node.attrs.grayscale = true; + baseParams.node.attrs.lum = { bright: 70000, contrast: -70000 }; + + const result = translateImageNode(baseParams); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + + expect(blip.elements).toEqual([ + { name: 'a:grayscl' }, + { name: 'a:lum', attributes: { bright: 70000, contrast: -70000 } }, + ]); + }); + it('should not export grayscale element when not present', () => { const result = translateImageNode(baseParams); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index adf0364e99..e38403058d 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -326,8 +326,18 @@ export function handleImageNode(node, params, isAnchor) { return null; } - // Check for image effects (grayscale, etc.) + // Check for image effects (grayscale, luminance, etc.) const hasGrayscale = blip.elements?.some((el) => el.name === 'a:grayscl'); + const lumEl = blip.elements?.find((el) => el.name === 'a:lum'); + const rawBright = Number(lumEl?.attributes?.bright); + const rawContrast = Number(lumEl?.attributes?.contrast); + const lum = + Number.isFinite(rawBright) || Number.isFinite(rawContrast) + ? { + ...(Number.isFinite(rawBright) ? { bright: rawBright } : {}), + ...(Number.isFinite(rawContrast) ? { contrast: rawContrast } : {}), + } + : undefined; // Check for stretch mode: // This tells Word to scale the image to fill the extent rectangle. @@ -552,6 +562,7 @@ export function handleImageNode(node, params, isAnchor) { ...(order.length ? { drawingChildOrder: order } : {}), ...(originalChildren.length ? { originalDrawingChildren: originalChildren } : {}), ...(hasGrayscale ? { grayscale: true } : {}), + ...(lum ? { lum } : {}), }; return { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index a07ef52706..7ac26d4d67 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -1094,12 +1094,29 @@ describe('handleImageNode', () => { expect(result.attrs.grayscale).toBe(true); }); + it('extracts luminance adjustment from a:blip element', () => { + const node = makeNode(); + const graphic = node.elements.find((el) => el.name === 'a:graphic'); + const graphicData = graphic.elements[0]; + const pic = graphicData.elements[0]; + const blipFill = pic.elements[0]; + const blip = blipFill.elements[0]; + + blip.elements = [{ name: 'a:lum', attributes: { bright: '70000', contrast: '-70000' } }]; + + const result = handleImageNode(node, makeParams(), false); + + expect(result).not.toBeNull(); + expect(result.attrs.lum).toEqual({ bright: 70000, contrast: -70000 }); + }); + it('does not set grayscale when effect is not present', () => { const node = makeNode(); const result = handleImageNode(node, makeParams(), false); expect(result).not.toBeNull(); expect(result.attrs.grayscale).toBeUndefined(); + expect(result.attrs.lum).toBeUndefined(); }); describe('lockAspectRatio / noChangeAspect import defaults', () => { diff --git a/packages/super-editor/src/extensions/image/image.js b/packages/super-editor/src/extensions/image/image.js index 2daa1c8a5c..0c19473a89 100644 --- a/packages/super-editor/src/extensions/image/image.js +++ b/packages/super-editor/src/extensions/image/image.js @@ -220,6 +220,16 @@ export const Image = Node.create({ rendered: false, }, + /** + * @category Attribute + * @param {{bright?: number, contrast?: number}} [lum] - DrawingML luminance adjustment from a:lum + * @private + */ + lum: { + default: null, + rendered: false, + }, + /** * @category Attribute * @param {string|number} [gain] - VML gain for brightness/washout (watermark effect) diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index e9390cbaf5..fa14eb46c5 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -499,6 +499,11 @@ export interface ImageAttrs extends ShapeNodeAttributes { clipPath?: string; /** @internal Raw a:srcRect element for lossless round-trip export */ rawSrcRect?: Record | null; + /** @internal DrawingML luminance adjustment from a:lum */ + lum?: { + bright?: number; + contrast?: number; + } | null; /** Whether aspect ratio is locked. Maps to OOXML a:picLocks/@noChangeAspect. */ lockAspectRatio?: boolean; /** Decorative image flag. Maps to OOXML adec:decorative. */