Skip to content

Commit 3c23c8b

Browse files
authored
feat: import and render document-level background (#3623)
* feat: import and render document-level background * fix: document background export and PresentationEditor background defaults * chore: update lock file
1 parent 81717eb commit 3c23c8b

17 files changed

Lines changed: 404 additions & 97 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,11 @@ export type PageMargins = {
835835
gutter?: number;
836836
};
837837

838+
export type DocumentBackground = {
839+
/** Solid page background color as a CSS hex value. */
840+
color: string;
841+
};
842+
838843
export type ImageBlockAttrs = {
839844
sdt?: SdtMetadata;
840845
containerSdt?: SdtMetadata;
@@ -2298,6 +2303,8 @@ export type HeaderFooterLayout = {
22982303
export type Layout = {
22992304
pageSize: { w: number; h: number };
23002305
pages: Page[];
2306+
/** Optional document-level page background from OOXML w:background. */
2307+
documentBackground?: DocumentBackground;
23012308
columns?: ColumnLayout;
23022309
headerFooter?: Partial<Record<HeaderFooterType, HeaderFooterLayout>>;
23032310
/**

packages/layout-engine/contracts/src/resolved-layout.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
ColumnLayout,
33
ColumnRegion,
4+
DocumentBackground,
45
DrawingBlock,
56
FlowMode,
67
Fragment,
@@ -32,6 +33,8 @@ export type ResolvedLayout = {
3233
blockVersions?: Record<string, string>;
3334
/** Resolved pages with normalized dimensions. */
3435
pages: ResolvedPage[];
36+
/** Optional document-level page background from OOXML w:background. */
37+
documentBackground?: DocumentBackground;
3538
/** Document epoch identifier from the source layout. Used for change tracking in the painter. */
3639
layoutEpoch?: number;
3740
};

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
SectionNumbering,
2929
FlowMode,
3030
NormalizedColumnLayout,
31+
DocumentBackground,
3132
HeaderFooterResolutionSection,
3233
} from '@superdoc/contracts';
3334
import {
@@ -463,6 +464,7 @@ function calculateChainHeight(
463464
export type LayoutOptions = {
464465
pageSize?: PageSize;
465466
margins?: Margins;
467+
documentBackground?: DocumentBackground;
466468
columns?: ColumnLayout;
467469
flowMode?: FlowMode;
468470
semantic?: {
@@ -3199,6 +3201,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
31993201
return {
32003202
pageSize,
32013203
pages,
3204+
...(options.documentBackground ? { documentBackground: options.documentBackground } : {}),
32023205
// Note: columns here reflects the effective default for subsequent pages
32033206
// after processing sections. Page/region-specific column changes are encoded
32043207
// implicitly via fragment positions. Consumers should not assume this is

packages/layout-engine/layout-resolved/src/resolveLayout.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
345345
flowMode,
346346
pageGap: layout.pageGap ?? 0,
347347
pages,
348+
...(layout.documentBackground ? { documentBackground: layout.documentBackground } : {}),
348349
};
349350

350351
if (blocks.length > 0) {

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,26 @@ describe('DomPainter', () => {
432432
expect(fragment.textContent).toContain('world');
433433
});
434434

435+
it('paints document-level page background from resolved layout', () => {
436+
const painter = createTestPainter({ blocks: [block], measures: [measure] });
437+
painter.paint({ ...layout, documentBackground: { color: '#EEEEEE' } }, mount);
438+
439+
const page = mount.querySelector('.superdoc-page') as HTMLElement;
440+
expectCssColor(page.style.background, '#EEEEEE');
441+
});
442+
443+
it('keeps the configured page background when no document background is present', () => {
444+
const painter = createTestPainter({
445+
blocks: [block],
446+
measures: [measure],
447+
pageStyles: { background: '#FFFFFF' },
448+
});
449+
painter.paint(layout, mount);
450+
451+
const page = mount.querySelector('.superdoc-page') as HTMLElement;
452+
expectCssColor(page.style.background, '#FFFFFF');
453+
});
454+
435455
it('applies paragraph alignment to line elements', () => {
436456
const alignedBlock: FlowBlock = {
437457
kind: 'paragraph',

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,18 +1281,19 @@ export class DomPainter {
12811281
this.beginPaintSnapshot(resolvedLayout);
12821282

12831283
this.totalPages = resolvedLayout.pages.length;
1284+
const previousLayout = this.currentLayout;
1285+
this.currentLayout = resolvedLayout;
12841286
if (this.isSemanticFlow) {
12851287
// Semantic mode always renders as a single continuous surface.
12861288
applyStyles(mount, containerStyles);
12871289
mount.style.gap = '0px';
12881290
mount.style.alignItems = 'stretch';
1289-
if (!this.currentLayout || this.pageStates.length === 0) {
1291+
if (!previousLayout || this.pageStates.length === 0) {
12901292
this.fullRender(resolvedLayout);
12911293
} else {
12921294
this.patchLayout(resolvedLayout);
12931295
}
12941296
this.setMountedPageIndices(this.createAllPageIndices(resolvedLayout.pages.length));
1295-
this.currentLayout = resolvedLayout;
12961297
this.changedBlocks.clear();
12971298
this.currentMapping = null;
12981299
return;
@@ -1339,7 +1340,7 @@ export class DomPainter {
13391340
} else {
13401341
// Use configured page gap for normal vertical rendering
13411342
mount.style.gap = `${this.pageGap}px`;
1342-
if (!this.currentLayout || this.pageStates.length === 0) {
1343+
if (!previousLayout || this.pageStates.length === 0) {
13431344
this.fullRender(resolvedLayout);
13441345
} else {
13451346
this.patchLayout(resolvedLayout);
@@ -2561,22 +2562,26 @@ export class DomPainter {
25612562
}
25622563

25632564
private getEffectivePageStyles(): PageStyles | undefined {
2565+
const documentBackgroundColor = this.currentLayout?.documentBackground?.color;
2566+
const base = this.options.pageStyles ?? {};
2567+
const baseWithDocumentBackground = documentBackgroundColor
2568+
? { ...base, background: documentBackgroundColor }
2569+
: base;
2570+
25642571
if (this.isSemanticFlow) {
2565-
const base = this.options.pageStyles ?? {};
25662572
return {
2567-
...base,
2568-
background: base.background ?? 'var(--sd-layout-page-bg, #fff)',
2573+
...baseWithDocumentBackground,
2574+
background: baseWithDocumentBackground.background ?? 'var(--sd-layout-page-bg, #fff)',
25692575
boxShadow: 'none',
25702576
border: 'none',
25712577
margin: '0',
25722578
};
25732579
}
25742580
if (this.virtualEnabled && this.layoutMode === 'vertical') {
25752581
// Remove top/bottom margins to avoid double-counting with container gap during virtualization
2576-
const base = this.options.pageStyles ?? {};
2577-
return { ...base, margin: '0 auto' };
2582+
return { ...baseWithDocumentBackground, margin: '0 auto' };
25782583
}
2579-
return this.options.pageStyles;
2584+
return documentBackgroundColor ? baseWithDocumentBackground : this.options.pageStyles;
25802585
}
25812586

25822587
private renderFragment(

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ import type {
192192
SectionMetadata,
193193
TrackedChangesMode,
194194
Fragment,
195+
DocumentBackground,
195196
} from '@superdoc/contracts';
196197
import { extractHeaderFooterSpace as _extractHeaderFooterSpace } from '@superdoc/contracts';
197198
// TrackChangesBasePluginKey is used by #syncTrackedChangesPreferences and getTrackChangesPluginState.
@@ -502,6 +503,7 @@ export class PresentationEditor extends EventEmitter {
502503
/** Scroll-isolating wrapper around #hiddenHost. Append/remove this from the DOM. */
503504
#hiddenHostWrapper: HTMLElement;
504505
#layoutOptions: LayoutEngineOptions;
506+
#configuredDocumentBackground: DocumentBackground | undefined;
505507
#layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() };
506508
#layoutLookupBlocks: FlowBlock[] = [];
507509
#layoutLookupMeasures: Measure[] = [];
@@ -702,6 +704,9 @@ export class PresentationEditor extends EventEmitter {
702704

703705
const requestedFlowMode = options.layoutEngineOptions?.flowMode === 'semantic' ? 'semantic' : 'paginated';
704706
const requestedLayoutMode = options.layoutEngineOptions?.layoutMode ?? 'vertical';
707+
this.#configuredDocumentBackground = this.#coerceDocumentBackground(
708+
options.layoutEngineOptions?.documentBackground,
709+
);
705710
this.#layoutOptions = {
706711
pageSize: options.layoutEngineOptions?.pageSize ?? DEFAULT_PAGE_SIZE,
707712
margins: options.layoutEngineOptions?.margins ?? DEFAULT_MARGINS,
@@ -713,6 +718,7 @@ export class PresentationEditor extends EventEmitter {
713718
}
714719
: options.layoutEngineOptions?.virtualization,
715720
zoom: options.layoutEngineOptions?.zoom ?? 1,
721+
...(this.#configuredDocumentBackground ? { documentBackground: this.#configuredDocumentBackground } : {}),
716722
pageStyles: options.layoutEngineOptions?.pageStyles,
717723
debugLabel: options.layoutEngineOptions?.debugLabel,
718724
layoutMode: requestedFlowMode === 'semantic' ? 'vertical' : requestedLayoutMode,
@@ -7861,6 +7867,12 @@ export class PresentationEditor extends EventEmitter {
78617867
this.#layoutOptions.pageSize = pageSize;
78627868
this.#layoutOptions.margins = margins;
78637869
const flowMode = this.#layoutOptions.flowMode ?? 'paginated';
7870+
const documentBackground = this.#resolveDocumentBackground();
7871+
if (documentBackground) {
7872+
this.#layoutOptions.documentBackground = documentBackground;
7873+
} else {
7874+
delete this.#layoutOptions.documentBackground;
7875+
}
78647876

78657877
const resolvedMargins = {
78667878
top: margins.top!,
@@ -7900,17 +7912,18 @@ export class PresentationEditor extends EventEmitter {
79007912
marginBottom: semanticMargins.bottom,
79017913
},
79027914
sectionMetadata,
7915+
...(documentBackground ? { documentBackground } : {}),
79037916
};
79047917
}
79057918

79067919
this.#hiddenHost.style.width = `${pageSize.w}px`;
79077920

79087921
const alternateHeaders = this.#resolveAlternateHeadersFlag();
7909-
79107922
return {
79117923
flowMode: 'paginated',
79127924
pageSize,
79137925
margins: resolvedMargins,
7926+
...(documentBackground ? { documentBackground } : {}),
79147927
...(columns ? { columns } : {}),
79157928
sectionMetadata,
79167929
alternateHeaders,
@@ -7938,6 +7951,19 @@ export class PresentationEditor extends EventEmitter {
79387951
return out;
79397952
}
79407953

7954+
#coerceDocumentBackground(candidate: unknown): DocumentBackground | undefined {
7955+
if (!candidate || typeof candidate !== 'object') return undefined;
7956+
const color = (candidate as { color?: unknown }).color;
7957+
return typeof color === 'string' && color.length > 0 ? { color } : undefined;
7958+
}
7959+
7960+
#resolveDocumentBackground(): DocumentBackground | undefined {
7961+
return (
7962+
this.#coerceDocumentBackground(this.#editor?.state?.doc?.attrs?.documentBackground) ??
7963+
(this.#configuredDocumentBackground ? { ...this.#configuredDocumentBackground } : undefined)
7964+
);
7965+
}
7966+
79417967
#buildHeaderFooterInput() {
79427968
if (this.#isSemanticFlowMode()) {
79437969
return null;

0 commit comments

Comments
 (0)