Skip to content

Commit 5229852

Browse files
fix: watermark word art, comment bubble bug (#2846)
* fix: comment bubble error and header anchor shape placement * fix: watermark layout and imported comment metadata handling --------- Co-authored-by: VladaHarbour <dataart.vladyslava@harbourcollaborators.com>
1 parent 3817ba6 commit 5229852

14 files changed

Lines changed: 980 additions & 91 deletions

File tree

packages/layout-engine/layout-bridge/src/cacheInvalidation.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,20 +109,33 @@ export function computeSectionMetadataHash(sections: SectionMetadata[]): string
109109
* @returns Constraints hash string
110110
*/
111111
export function computeConstraintsHash(constraints: HeaderFooterConstraints): string {
112-
const { width, height, pageWidth, margins, overflowBaseHeight } = constraints;
112+
const { width, height, pageWidth, pageHeight, margins, overflowBaseHeight } = constraints;
113113

114114
const parts = [`w:${width}`, `h:${height}`];
115115

116116
if (pageWidth !== undefined) {
117117
parts.push(`pw:${pageWidth}`);
118118
}
119119

120+
if (pageHeight !== undefined) {
121+
parts.push(`ph:${pageHeight}`);
122+
}
123+
120124
if (overflowBaseHeight !== undefined) {
121125
parts.push(`obh:${overflowBaseHeight}`);
122126
}
123127

124128
if (margins) {
125129
parts.push(`ml:${margins.left}`, `mr:${margins.right}`);
130+
if (margins.top !== undefined) {
131+
parts.push(`mt:${margins.top}`);
132+
}
133+
if (margins.bottom !== undefined) {
134+
parts.push(`mb:${margins.bottom}`);
135+
}
136+
if (margins.header !== undefined) {
137+
parts.push(`mh:${margins.header}`);
138+
}
126139
}
127140

128141
return parts.join('|');

packages/layout-engine/layout-bridge/test/cacheInvalidation.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,18 @@ describe('Cache Invalidation', () => {
133133
width: 500,
134134
height: 100,
135135
pageWidth: 600,
136-
margins: { left: 50, right: 50 },
136+
pageHeight: 900,
137+
margins: { left: 50, right: 50, top: 72, bottom: 72, header: 36 },
137138
};
138139

139140
const hash = computeConstraintsHash(constraints);
140141
expect(hash).toContain('pw:600');
142+
expect(hash).toContain('ph:900');
141143
expect(hash).toContain('ml:50');
142144
expect(hash).toContain('mr:50');
145+
expect(hash).toContain('mt:72');
146+
expect(hash).toContain('mb:72');
147+
expect(hash).toContain('mh:36');
143148
});
144149

145150
it('should produce different hashes for different constraints', () => {
@@ -584,5 +589,41 @@ describe('Cache Invalidation', () => {
584589
expect(invalidateSpy).toHaveBeenCalled();
585590
expect(invalidateSpy).toHaveBeenCalledWith(['p1']);
586591
});
592+
593+
it('should invalidate cache when page-relative header measurement constraints change', () => {
594+
const invalidateSpy = vi.spyOn(cache, 'invalidate');
595+
596+
const blocks = {
597+
default: [
598+
{
599+
kind: 'paragraph',
600+
id: 'p1',
601+
runs: [{ text: 'Hello' }],
602+
} as ParagraphBlock,
603+
],
604+
};
605+
606+
const constraints1: HeaderFooterConstraints = {
607+
width: 500,
608+
height: 100,
609+
pageHeight: 900,
610+
margins: { left: 50, right: 50, top: 72, bottom: 72, header: 36 },
611+
};
612+
613+
const constraints2: HeaderFooterConstraints = {
614+
width: 500,
615+
height: 100,
616+
pageHeight: 900,
617+
margins: { left: 50, right: 50, top: 96, bottom: 72, header: 36 },
618+
};
619+
620+
invalidateHeaderFooterCache(cache, cacheState, blocks, undefined, constraints1, undefined);
621+
622+
invalidateSpy.mockClear();
623+
invalidateHeaderFooterCache(cache, cacheState, blocks, undefined, constraints2, undefined);
624+
625+
expect(invalidateSpy).toHaveBeenCalled();
626+
expect(invalidateSpy).toHaveBeenCalledWith(['p1']);
627+
});
587628
});
588629
});

packages/layout-engine/layout-engine/src/index.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3038,6 +3038,66 @@ describe('layoutHeaderFooter', () => {
30383038
expect(layout.height).toBeCloseTo(60, 0);
30393039
});
30403040

3041+
it('excludes centered page-relative header overlays from measurement height', () => {
3042+
const paragraphBlock: FlowBlock = {
3043+
kind: 'paragraph',
3044+
id: 'para-1',
3045+
runs: [{ text: 'Header text', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 12 }],
3046+
};
3047+
const centeredOverlay: FlowBlock = {
3048+
kind: 'drawing',
3049+
id: 'drawing-1',
3050+
drawingKind: 'vectorShape',
3051+
geometry: { width: 596, height: 531 },
3052+
anchor: {
3053+
isAnchored: true,
3054+
hRelativeFrom: 'page',
3055+
alignH: 'center',
3056+
vRelativeFrom: 'page',
3057+
alignV: 'center',
3058+
behindDoc: false,
3059+
},
3060+
shapeKind: 'Rectangle',
3061+
};
3062+
const paragraphMeasure: Measure = {
3063+
kind: 'paragraph',
3064+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 80, ascent: 12, descent: 3, lineHeight: 15 }],
3065+
totalHeight: 15,
3066+
};
3067+
const overlayMeasure: Measure = {
3068+
kind: 'drawing',
3069+
drawingKind: 'vectorShape',
3070+
width: 596,
3071+
height: 531,
3072+
scale: 1,
3073+
naturalWidth: 596,
3074+
naturalHeight: 531,
3075+
geometry: { width: 596, height: 531, rotation: 0, flipH: false, flipV: false },
3076+
};
3077+
const constraints = {
3078+
width: 624,
3079+
height: 864,
3080+
pageWidth: 816,
3081+
pageHeight: 1056,
3082+
margins: { left: 96, right: 96, top: 96, bottom: 96, header: 48 },
3083+
};
3084+
3085+
const layout = layoutHeaderFooter(
3086+
[paragraphBlock, centeredOverlay],
3087+
[paragraphMeasure, overlayMeasure],
3088+
constraints,
3089+
'header',
3090+
);
3091+
const overlayFragment = layout.pages[0]?.fragments.find((fragment) => fragment.blockId === 'drawing-1') as
3092+
| DrawingFragment
3093+
| undefined;
3094+
3095+
expect(layout.height).toBeCloseTo(15);
3096+
expect(layout.renderHeight).toBeGreaterThan(500);
3097+
expect(overlayFragment).toBeDefined();
3098+
expect(overlayFragment?.y).toBeGreaterThan(150);
3099+
});
3100+
30413101
it('returns minimal height when header contains only behindDoc fragments with extreme offsets', () => {
30423102
const imageBlock1: FlowBlock = {
30433103
kind: 'image',
@@ -3254,6 +3314,77 @@ describe('layoutHeaderFooter', () => {
32543314
expect(layout.renderHeight).toBeGreaterThan(layout.height);
32553315
});
32563316

3317+
it('keeps top-aligned page-relative header anchors in measurement height', () => {
3318+
const overlayBlock: FlowBlock = {
3319+
kind: 'drawing',
3320+
id: 'drawing-top',
3321+
drawingKind: 'vectorShape',
3322+
geometry: { width: 120, height: 60 },
3323+
anchor: {
3324+
isAnchored: true,
3325+
vRelativeFrom: 'page',
3326+
alignV: 'top',
3327+
offsetV: 40,
3328+
behindDoc: false,
3329+
},
3330+
shapeKind: 'Rectangle',
3331+
};
3332+
const overlayMeasure: Measure = {
3333+
kind: 'drawing',
3334+
drawingKind: 'vectorShape',
3335+
width: 120,
3336+
height: 60,
3337+
scale: 1,
3338+
naturalWidth: 120,
3339+
naturalHeight: 60,
3340+
geometry: { width: 120, height: 60, rotation: 0, flipH: false, flipV: false },
3341+
};
3342+
const constraints = {
3343+
width: 624,
3344+
height: 864,
3345+
pageWidth: 816,
3346+
pageHeight: 1056,
3347+
margins: { left: 96, right: 96, top: 96, bottom: 96, header: 48 },
3348+
};
3349+
3350+
const layout = layoutHeaderFooter([overlayBlock], [overlayMeasure], constraints, 'header');
3351+
3352+
expect(layout.height).toBeCloseTo(100);
3353+
expect(layout.renderHeight).toBeCloseTo(layout.height);
3354+
});
3355+
3356+
it('keeps bottom-aligned page-relative footer anchors in measurement height', () => {
3357+
const footerOverlay: FlowBlock = {
3358+
kind: 'image',
3359+
id: 'img-page',
3360+
src: 'data:image/png;base64,xxx',
3361+
anchor: {
3362+
isAnchored: true,
3363+
vRelativeFrom: 'page',
3364+
alignV: 'bottom',
3365+
offsetV: 0,
3366+
hRelativeFrom: 'page',
3367+
offsetH: 0,
3368+
},
3369+
};
3370+
const footerOverlayMeasure: Measure = {
3371+
kind: 'image',
3372+
width: 50,
3373+
height: 30,
3374+
};
3375+
const constraints = {
3376+
width: 200,
3377+
height: 800,
3378+
pageHeight: 1056,
3379+
margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 },
3380+
};
3381+
3382+
const layout = layoutHeaderFooter([footerOverlay], [footerOverlayMeasure], constraints, 'footer');
3383+
3384+
expect(layout.height).toBeCloseTo(72);
3385+
expect(layout.renderHeight).toBeCloseTo(layout.height);
3386+
});
3387+
32573388
it('post-normalizes page-relative anchors in footer layout', () => {
32583389
const paragraphBlock: FlowBlock = {
32593390
kind: 'paragraph',

packages/layout-engine/layout-engine/src/index.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2577,6 +2577,34 @@ function computeFragmentBottom(fragment: Fragment, block: FlowBlock, measure: Me
25772577
return bottom;
25782578
}
25792579

2580+
type VerticalBand = {
2581+
start: number;
2582+
end: number;
2583+
};
2584+
2585+
function rangesIntersect(startA: number, endA: number, startB: number, endB: number): boolean {
2586+
return endA > startB && startA < endB;
2587+
}
2588+
2589+
function getPageRelativeMeasurementBand(
2590+
kind: 'header' | 'footer' | undefined,
2591+
constraints: HeaderFooterConstraints,
2592+
): VerticalBand | null {
2593+
if (!kind || !constraints.margins) {
2594+
return null;
2595+
}
2596+
2597+
const bandSize = kind === 'header' ? constraints.margins.top : constraints.margins.bottom;
2598+
if (!Number.isFinite(bandSize) || bandSize == null || bandSize <= 0) {
2599+
return null;
2600+
}
2601+
2602+
return {
2603+
start: 0,
2604+
end: bandSize,
2605+
};
2606+
}
2607+
25802608
/**
25812609
* Determine whether a fragment should be excluded from measurement (pagination) bounds.
25822610
*
@@ -2585,8 +2613,18 @@ function computeFragmentBottom(fragment: Fragment, block: FlowBlock, measure: Me
25852613
* 2. Page-relative anchored fragments whose local Y range [y, y+h] does not
25862614
* intersect [0, canvasHeight] — they are out-of-band and should not inflate
25872615
* the measurement used by body pagination.
2616+
* 3. Page-relative header/footer overlays that do not intersect the region's
2617+
* reserved margin band — they should still render, but must not reserve
2618+
* body space like true header/footer content.
25882619
*/
2589-
function shouldExcludeFromMeasurement(fragment: Fragment, block: FlowBlock, canvasHeight: number): boolean {
2620+
function shouldExcludeFromMeasurement(
2621+
fragment: Fragment,
2622+
block: FlowBlock,
2623+
fragmentBottom: number,
2624+
canvasHeight: number,
2625+
kind: 'header' | 'footer' | undefined,
2626+
constraints: HeaderFooterConstraints,
2627+
): boolean {
25902628
const isAnchoredFragment =
25912629
(fragment.kind === 'image' || fragment.kind === 'drawing') &&
25922630
(fragment as { isAnchored?: boolean }).isAnchored === true;
@@ -2607,15 +2645,20 @@ function shouldExcludeFromMeasurement(fragment: Fragment, block: FlowBlock, canv
26072645
// Page-relative anchored fragments that sit entirely outside the measurement band
26082646
// should not inflate pagination height.
26092647
if (isPageRelativeAnchor(anchoredBlock)) {
2610-
const fragmentHeight = (fragment as { height?: number }).height ?? 0;
26112648
const fragmentTop = fragment.y;
2612-
const fragmentBottom = fragment.y + fragmentHeight;
26132649
// Exclude if the fragment range [top, bottom] does not intersect [0, canvasHeight]
26142650
if (fragmentBottom <= 0 || fragmentTop >= canvasHeight) {
26152651
return true;
26162652
}
26172653
}
26182654

2655+
if (anchoredBlock.anchor?.vRelativeFrom === 'page') {
2656+
const measurementBand = getPageRelativeMeasurementBand(kind, constraints);
2657+
if (measurementBand && !rangesIntersect(fragment.y, fragmentBottom, measurementBand.start, measurementBand.end)) {
2658+
return true;
2659+
}
2660+
}
2661+
26192662
return false;
26202663
}
26212664

@@ -2704,7 +2747,7 @@ export function layoutHeaderFooter(
27042747
if (bottom > renderMaxY) renderMaxY = bottom;
27052748

27062749
// Determine whether this fragment should be excluded from measurement (pagination) bounds
2707-
if (shouldExcludeFromMeasurement(fragment, block, height)) continue;
2750+
if (shouldExcludeFromMeasurement(fragment, block, bottom, height, kind, constraints)) continue;
27082751

27092752
if (fragment.y < measureMinY) measureMinY = fragment.y;
27102753
if (bottom > measureMaxY) measureMaxY = bottom;

0 commit comments

Comments
 (0)