Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
230 changes: 230 additions & 0 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading