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
55 changes: 54 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ export {
getFragmentZIndex,
} from './ooxml-z-index.js';

export {
resolveOuterShadowOffset,
getOuterShadowStdDeviation,
getOuterShadowPaintExtent,
type OuterShadowPaintEffect,
type PaintEffectExtent,
} from './shape-effects.js';

// Export justify utilities
export {
shouldApplyJustify,
Expand Down Expand Up @@ -585,6 +593,9 @@ export type ImageAlphaModFix = {
/** Hyperlink metadata from OOXML a:hlinkClick on a DrawingML image. */
export type ImageHyperlink = { url: string; tooltip?: string };

/** CSS object-fit values supported by SuperDoc image rendering paths. */
export type ObjectFit = 'contain' | 'cover' | 'fill' | 'scale-down';

/**
* 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 @@ -617,6 +628,10 @@ export type ImageRun = {
title?: string;
/** Clip-path value for cropped images. */
clipPath?: string;
/** Clip-path value for preset shape masks applied around the image box. */
shapeClipPath?: string;
/** CSS object-fit behavior for the painted image inside its layout box. */
objectFit?: ObjectFit;

/**
* Spacing around the image (from DOCX distT/distB/distL/distR attributes).
Expand Down Expand Up @@ -963,7 +978,7 @@ export type ImageBlock = {
height?: number;
alt?: string;
title?: string;
objectFit?: 'contain' | 'cover' | 'fill' | 'scale-down';
objectFit?: ObjectFit;
display?: 'inline' | 'block';
padding?: BoxSpacing;
margin?: BoxSpacing;
Expand Down Expand Up @@ -1079,6 +1094,11 @@ export type TextPart = {
isLineBreak?: boolean;
/** Indicates this line break follows an empty paragraph (creates extra spacing). */
isEmptyParagraph?: boolean;
/**
* True only on the line-break part that separates two logical paragraphs.
* Intra-paragraph <w:br> line breaks do not set this flag.
*/
isParagraphBoundary?: boolean;
/**
* SD-2804: ECMA-376 §20.4.2.38 lets a textbox hold full body-level
* content, including paragraphs whose runs carry inline w:drawing
Expand All @@ -1097,12 +1117,23 @@ export type TextPart = {
alt?: string;
};

export type ShapeTextParagraph = {
spacing?: {
/** CSS pixels. */
before?: number;
/** CSS pixels. */
after?: number;
};
};

/** Text content configuration for shapes. */
export type ShapeTextContent = {
/** Array of text parts with individual formatting. */
parts: TextPart[];
/** Horizontal text alignment within the shape. */
horizontalAlign?: 'left' | 'center' | 'right';
/** Paragraph metadata aligned to the logical paragraphs in `parts`. */
paragraphs?: ShapeTextParagraph[];
};

export type LineEnd = {
Expand All @@ -1123,11 +1154,25 @@ export type EffectExtent = {
bottom: number;
};

export type ShapeOuterShadowEffect = {
type: 'outerShadow';
blurRadius: number;
distance: number;
direction: number;
color: string;
opacity: number;
};

export type ShapeEffects = {
outerShadow?: ShapeOuterShadowEffect;
};

export type VectorShapeStyle = {
fillColor?: FillColor;
strokeColor?: StrokeColor;
strokeWidth?: number;
lineEnds?: LineEnds;
effects?: ShapeEffects;
textContent?: ShapeTextContent;
textAlign?: string;
textVerticalAlign?: 'top' | 'center' | 'bottom';
Expand All @@ -1150,6 +1195,9 @@ export type ShapeGroupTransform = {
childHeight?: number;
childOriginXEmu?: number;
childOriginYEmu?: number;
rotation?: number;
flipH?: boolean;
flipV?: boolean;
};

export type ShapeGroupVectorChild = {
Expand All @@ -1170,6 +1218,8 @@ export type ShapeGroupImageChild = {
alt?: string;
clipPath?: string;
alphaModFix?: ImageAlphaModFix;
shapeClipPath?: string;
objectFit?: ObjectFit;
imageId?: string;
imageName?: string;
};
Expand Down Expand Up @@ -1221,6 +1271,7 @@ export type VectorShapeDrawing = DrawingBlockBase & {
strokeColor?: StrokeColor;
strokeWidth?: number;
lineEnds?: LineEnds;
effects?: ShapeEffects;
effectExtent?: EffectExtent;
textContent?: ShapeTextContent;
textAlign?: string;
Expand All @@ -1242,6 +1293,7 @@ export type TextboxDrawing = DrawingBlockBase & {
strokeColor?: StrokeColor;
strokeWidth?: number;
lineEnds?: LineEnds;
effects?: ShapeEffects;
effectExtent?: EffectExtent;
textContent?: ShapeTextContent;
textAlign?: string;
Expand All @@ -1260,6 +1312,7 @@ export type TextboxDrawing = DrawingBlockBase & {
export type ShapeGroupDrawing = DrawingBlockBase & {
drawingKind: 'shapeGroup';
geometry: DrawingGeometry;
effectExtent?: EffectExtent;
groupTransform?: ShapeGroupTransform;
shapes: ShapeGroupChild[];
size?: {
Expand Down
33 changes: 33 additions & 0 deletions packages/layout-engine/contracts/src/shape-effects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { getOuterShadowPaintExtent, getOuterShadowStdDeviation, resolveOuterShadowOffset } from './shape-effects.js';

describe('shape effect geometry helpers', () => {
const shadow = {
type: 'outerShadow' as const,
blurRadius: 6.6667,
distance: 6.6667,
direction: 45,
color: '#a6a6a6',
opacity: 0.4,
};

it('resolves outer shadow offset from direction and distance', () => {
const offset = resolveOuterShadowOffset(shadow);

expect(offset.dx).toBeCloseTo(4.714, 3);
expect(offset.dy).toBeCloseTo(4.714, 3);
});

it('uses half the blur radius as the SVG standard deviation', () => {
expect(getOuterShadowStdDeviation(shadow)).toBeCloseTo(3.333, 3);
});

it('resolves the paint extent used by import and paint paths', () => {
const extent = getOuterShadowPaintExtent(shadow);

expect(extent.left).toBeCloseTo(5.286, 3);
expect(extent.top).toBeCloseTo(5.286, 3);
expect(extent.right).toBeCloseTo(14.714, 3);
expect(extent.bottom).toBeCloseTo(14.714, 3);
});
});
35 changes: 35 additions & 0 deletions packages/layout-engine/contracts/src/shape-effects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export type OuterShadowPaintEffect = {
blurRadius: number;
distance: number;
direction: number;
};

export type PaintEffectExtent = {
left: number;
top: number;
right: number;
bottom: number;
};

export const resolveOuterShadowOffset = (shadow: OuterShadowPaintEffect): { dx: number; dy: number } => {
const radians = (shadow.direction * Math.PI) / 180;
return {
dx: shadow.distance * Math.cos(radians),
dy: shadow.distance * Math.sin(radians),
};
};

export const getOuterShadowStdDeviation = (shadow: OuterShadowPaintEffect): number => {
return Math.max(0, shadow.blurRadius / 2);
};

export const getOuterShadowPaintExtent = (shadow: OuterShadowPaintEffect): PaintEffectExtent => {
const { dx, dy } = resolveOuterShadowOffset(shadow);
const spread = getOuterShadowStdDeviation(shadow) * 3;
return {
left: Math.max(0, spread - dx),
top: Math.max(0, spread - dy),
right: Math.max(0, spread + dx),
bottom: Math.max(0, spread + dy),
};
};
6 changes: 5 additions & 1 deletion packages/layout-engine/layout-bridge/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ const hashDrawingBlock = (block: DrawingBlock): string => {
JSON.stringify(block.customGeometry ?? null),
JSON.stringify(block.lineEnds ?? null),
JSON.stringify(block.effectExtent ?? null),
JSON.stringify(block.effects ?? null),
JSON.stringify(block.textContent ?? null),
block.textAlign ?? '',
block.textVerticalAlign ?? '',
Expand Down Expand Up @@ -451,7 +452,10 @@ const hashRuns = (block: FlowBlock): string => {
return `${block.id}:table:${contentHash}${tableAttrsKey}`;
}

if (block.kind !== 'paragraph') return block.id;
if (block.kind !== 'paragraph') {
return hashNonParagraphCellBlock(block as Exclude<FlowBlock, ParagraphBlock>);
}

const trackedMode =
(block.attrs && 'trackedChangesMode' in block.attrs && block.attrs.trackedChangesMode) || 'review';
const trackedEnabled = resolveTrackedChangesEnabled(block.attrs, true);
Expand Down
11 changes: 9 additions & 2 deletions packages/layout-engine/layout-bridge/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,8 @@ const imageRunsEqual = (a: ImageRun, b: ImageRun): boolean => {
a.alt === b.alt &&
a.title === b.title &&
a.clipPath === b.clipPath &&
a.shapeClipPath === b.shapeClipPath &&
a.objectFit === b.objectFit &&
a.distTop === b.distTop &&
a.distBottom === b.distBottom &&
a.distLeft === b.distLeft &&
Expand Down Expand Up @@ -527,15 +529,16 @@ const drawingBlocksEqual = (a: DrawingBlock, b: DrawingBlock): boolean => {
return (
drawingGeometryEqual(a.geometry, b.geometry) &&
a.shapeKind === b.shapeKind &&
jsonEqual(a.customGeometry, b.customGeometry) &&
a.fillColor === b.fillColor &&
a.strokeColor === b.strokeColor &&
a.strokeWidth === b.strokeWidth &&
a.textAlign === b.textAlign &&
a.textVerticalAlign === b.textVerticalAlign &&
jsonEqual(a.textInsets, b.textInsets) &&
jsonEqual(a.textContent, b.textContent) &&
jsonEqual(a.customGeometry, b.customGeometry) &&
jsonEqual(a.lineEnds, b.lineEnds) &&
jsonEqual(a.effects, b.effects) &&
jsonEqual(a.effectExtent, b.effectExtent) &&
textboxContentEqual
);
Expand All @@ -544,6 +547,7 @@ const drawingBlocksEqual = (a: DrawingBlock, b: DrawingBlock): boolean => {
if (a.drawingKind === 'shapeGroup' && b.drawingKind === 'shapeGroup') {
return (
drawingGeometryEqual(a.geometry, b.geometry) &&
jsonEqual(a.effectExtent, b.effectExtent) &&
shapeGroupTransformEqual(a.groupTransform, b.groupTransform) &&
shapeGroupSizeEqual(a.size, b.size) &&
shapeGroupChildrenEqual(a.shapes, b.shapes)
Expand Down Expand Up @@ -640,7 +644,10 @@ const shapeGroupTransformEqual = (a?: ShapeGroupTransform, b?: ShapeGroupTransfo
a.childWidth === b.childWidth &&
a.childHeight === b.childHeight &&
a.childOriginXEmu === b.childOriginXEmu &&
a.childOriginYEmu === b.childOriginYEmu
a.childOriginYEmu === b.childOriginYEmu &&
(a.rotation ?? 0) === (b.rotation ?? 0) &&
Boolean(a.flipH) === Boolean(b.flipH) &&
Boolean(a.flipV) === Boolean(b.flipV)
);
};

Expand Down
23 changes: 23 additions & 0 deletions packages/layout-engine/layout-bridge/test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,29 @@ describe('MeasureCache', () => {
expect(cache.get(item, 400, 600, '')?.totalHeight).toBe(20);
});

it('creates different cache keys when vector shape shadow opacity changes', () => {
const baseShadow = {
type: 'outerShadow' as const,
blurRadius: 6,
distance: 4,
direction: 45,
color: '#a6a6a6',
opacity: 0.4,
};
const block1: VectorShapeDrawing = {
...vectorShapeBlock('shadow-shape', 100, 50, '#ffffff'),
effects: { outerShadow: baseShadow },
};
const block2: VectorShapeDrawing = {
...vectorShapeBlock('shadow-shape', 100, 50, '#ffffff'),
effects: { outerShadow: { ...baseShadow, opacity: 0.8 } },
};

cache.set(block1, 400, 600, { totalHeight: 20 });

expect(cache.get(block2, 400, 600)).toBeUndefined();
});

it('invalidates by block id even when a font signature is part of the key', () => {
const item = block('0-paragraph', 'hello');
cache.set(item, 400, 600, { totalHeight: 20 }, 'docA');
Expand Down
Loading
Loading