diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 5b08aef9e0..3df0683917 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -835,6 +835,11 @@ export type PageMargins = { gutter?: number; }; +export type DocumentBackground = { + /** Solid page background color as a CSS hex value. */ + color: string; +}; + export type ImageBlockAttrs = { sdt?: SdtMetadata; containerSdt?: SdtMetadata; @@ -2298,6 +2303,8 @@ export type HeaderFooterLayout = { export type Layout = { pageSize: { w: number; h: number }; pages: Page[]; + /** Optional document-level page background from OOXML w:background. */ + documentBackground?: DocumentBackground; columns?: ColumnLayout; headerFooter?: Partial>; /** diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index fdad65d628..2ef0e7d7c0 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -1,6 +1,7 @@ import type { ColumnLayout, ColumnRegion, + DocumentBackground, DrawingBlock, FlowMode, Fragment, @@ -32,6 +33,8 @@ export type ResolvedLayout = { blockVersions?: Record; /** Resolved pages with normalized dimensions. */ pages: ResolvedPage[]; + /** Optional document-level page background from OOXML w:background. */ + documentBackground?: DocumentBackground; /** Document epoch identifier from the source layout. Used for change tracking in the painter. */ layoutEpoch?: number; }; diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 434f8033d3..3bddfbe377 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -28,6 +28,7 @@ import type { SectionNumbering, FlowMode, NormalizedColumnLayout, + DocumentBackground, HeaderFooterResolutionSection, } from '@superdoc/contracts'; import { @@ -463,6 +464,7 @@ function calculateChainHeight( export type LayoutOptions = { pageSize?: PageSize; margins?: Margins; + documentBackground?: DocumentBackground; columns?: ColumnLayout; flowMode?: FlowMode; semantic?: { @@ -3199,6 +3201,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options return { pageSize, pages, + ...(options.documentBackground ? { documentBackground: options.documentBackground } : {}), // Note: columns here reflects the effective default for subsequent pages // after processing sections. Page/region-specific column changes are encoded // implicitly via fragment positions. Consumers should not assume this is diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 1d62e074f5..07559ec863 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -345,6 +345,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { flowMode, pageGap: layout.pageGap ?? 0, pages, + ...(layout.documentBackground ? { documentBackground: layout.documentBackground } : {}), }; if (blocks.length > 0) { diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 85fc90d87c..499be5f8f6 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -432,6 +432,26 @@ describe('DomPainter', () => { expect(fragment.textContent).toContain('world'); }); + it('paints document-level page background from resolved layout', () => { + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint({ ...layout, documentBackground: { color: '#EEEEEE' } }, mount); + + const page = mount.querySelector('.superdoc-page') as HTMLElement; + expectCssColor(page.style.background, '#EEEEEE'); + }); + + it('keeps the configured page background when no document background is present', () => { + const painter = createTestPainter({ + blocks: [block], + measures: [measure], + pageStyles: { background: '#FFFFFF' }, + }); + painter.paint(layout, mount); + + const page = mount.querySelector('.superdoc-page') as HTMLElement; + expectCssColor(page.style.background, '#FFFFFF'); + }); + it('applies paragraph alignment to line elements', () => { const alignedBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index b1c9f832bf..3765f26e77 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1281,18 +1281,19 @@ export class DomPainter { this.beginPaintSnapshot(resolvedLayout); this.totalPages = resolvedLayout.pages.length; + const previousLayout = this.currentLayout; + this.currentLayout = resolvedLayout; if (this.isSemanticFlow) { // Semantic mode always renders as a single continuous surface. applyStyles(mount, containerStyles); mount.style.gap = '0px'; mount.style.alignItems = 'stretch'; - if (!this.currentLayout || this.pageStates.length === 0) { + if (!previousLayout || this.pageStates.length === 0) { this.fullRender(resolvedLayout); } else { this.patchLayout(resolvedLayout); } this.setMountedPageIndices(this.createAllPageIndices(resolvedLayout.pages.length)); - this.currentLayout = resolvedLayout; this.changedBlocks.clear(); this.currentMapping = null; return; @@ -1339,7 +1340,7 @@ export class DomPainter { } else { // Use configured page gap for normal vertical rendering mount.style.gap = `${this.pageGap}px`; - if (!this.currentLayout || this.pageStates.length === 0) { + if (!previousLayout || this.pageStates.length === 0) { this.fullRender(resolvedLayout); } else { this.patchLayout(resolvedLayout); @@ -2561,11 +2562,16 @@ export class DomPainter { } private getEffectivePageStyles(): PageStyles | undefined { + const documentBackgroundColor = this.currentLayout?.documentBackground?.color; + const base = this.options.pageStyles ?? {}; + const baseWithDocumentBackground = documentBackgroundColor + ? { ...base, background: documentBackgroundColor } + : base; + if (this.isSemanticFlow) { - const base = this.options.pageStyles ?? {}; return { - ...base, - background: base.background ?? 'var(--sd-layout-page-bg, #fff)', + ...baseWithDocumentBackground, + background: baseWithDocumentBackground.background ?? 'var(--sd-layout-page-bg, #fff)', boxShadow: 'none', border: 'none', margin: '0', @@ -2573,10 +2579,9 @@ export class DomPainter { } if (this.virtualEnabled && this.layoutMode === 'vertical') { // Remove top/bottom margins to avoid double-counting with container gap during virtualization - const base = this.options.pageStyles ?? {}; - return { ...base, margin: '0 auto' }; + return { ...baseWithDocumentBackground, margin: '0 auto' }; } - return this.options.pageStyles; + return documentBackgroundColor ? baseWithDocumentBackground : this.options.pageStyles; } private renderFragment( diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index cd1f83370e..42d37fd806 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -192,6 +192,7 @@ import type { SectionMetadata, TrackedChangesMode, Fragment, + DocumentBackground, } from '@superdoc/contracts'; import { extractHeaderFooterSpace as _extractHeaderFooterSpace } from '@superdoc/contracts'; // TrackChangesBasePluginKey is used by #syncTrackedChangesPreferences and getTrackChangesPluginState. @@ -502,6 +503,7 @@ export class PresentationEditor extends EventEmitter { /** Scroll-isolating wrapper around #hiddenHost. Append/remove this from the DOM. */ #hiddenHostWrapper: HTMLElement; #layoutOptions: LayoutEngineOptions; + #configuredDocumentBackground: DocumentBackground | undefined; #layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() }; #layoutLookupBlocks: FlowBlock[] = []; #layoutLookupMeasures: Measure[] = []; @@ -702,6 +704,9 @@ export class PresentationEditor extends EventEmitter { const requestedFlowMode = options.layoutEngineOptions?.flowMode === 'semantic' ? 'semantic' : 'paginated'; const requestedLayoutMode = options.layoutEngineOptions?.layoutMode ?? 'vertical'; + this.#configuredDocumentBackground = this.#coerceDocumentBackground( + options.layoutEngineOptions?.documentBackground, + ); this.#layoutOptions = { pageSize: options.layoutEngineOptions?.pageSize ?? DEFAULT_PAGE_SIZE, margins: options.layoutEngineOptions?.margins ?? DEFAULT_MARGINS, @@ -713,6 +718,7 @@ export class PresentationEditor extends EventEmitter { } : options.layoutEngineOptions?.virtualization, zoom: options.layoutEngineOptions?.zoom ?? 1, + ...(this.#configuredDocumentBackground ? { documentBackground: this.#configuredDocumentBackground } : {}), pageStyles: options.layoutEngineOptions?.pageStyles, debugLabel: options.layoutEngineOptions?.debugLabel, layoutMode: requestedFlowMode === 'semantic' ? 'vertical' : requestedLayoutMode, @@ -7861,6 +7867,12 @@ export class PresentationEditor extends EventEmitter { this.#layoutOptions.pageSize = pageSize; this.#layoutOptions.margins = margins; const flowMode = this.#layoutOptions.flowMode ?? 'paginated'; + const documentBackground = this.#resolveDocumentBackground(); + if (documentBackground) { + this.#layoutOptions.documentBackground = documentBackground; + } else { + delete this.#layoutOptions.documentBackground; + } const resolvedMargins = { top: margins.top!, @@ -7900,17 +7912,18 @@ export class PresentationEditor extends EventEmitter { marginBottom: semanticMargins.bottom, }, sectionMetadata, + ...(documentBackground ? { documentBackground } : {}), }; } this.#hiddenHost.style.width = `${pageSize.w}px`; const alternateHeaders = this.#resolveAlternateHeadersFlag(); - return { flowMode: 'paginated', pageSize, margins: resolvedMargins, + ...(documentBackground ? { documentBackground } : {}), ...(columns ? { columns } : {}), sectionMetadata, alternateHeaders, @@ -7938,6 +7951,19 @@ export class PresentationEditor extends EventEmitter { return out; } + #coerceDocumentBackground(candidate: unknown): DocumentBackground | undefined { + if (!candidate || typeof candidate !== 'object') return undefined; + const color = (candidate as { color?: unknown }).color; + return typeof color === 'string' && color.length > 0 ? { color } : undefined; + } + + #resolveDocumentBackground(): DocumentBackground | undefined { + return ( + this.#coerceDocumentBackground(this.#editor?.state?.doc?.attrs?.documentBackground) ?? + (this.#configuredDocumentBackground ? { ...this.#configuredDocumentBackground } : undefined) + ); + } + #buildHeaderFooterInput() { if (this.#isSemanticFlowMode()) { return null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index c179c8859d..d0d841de1b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -231,65 +231,73 @@ vi.mock('../input/PositionHitResolver.js', () => ({ // Mock Editor class vi.mock('../../Editor', () => { return { - Editor: vi.fn().mockImplementation(() => ({ - setDocumentMode: vi.fn(), - setOptions: vi.fn(), - on: vi.fn(), - off: vi.fn(), - destroy: vi.fn(), - getJSON: vi.fn(() => ({ type: 'doc', content: [] })), - isEditable: true, - state: { - selection: { - from: 0, - to: 0, - $from: { - depth: 0, - node: vi.fn(), + Editor: vi.fn().mockImplementation((options: { content?: unknown } = {}) => { + const contentAttrs = + options.content && typeof options.content === 'object' && !Array.isArray(options.content) + ? (((options.content as { attrs?: Record }).attrs ?? {}) as Record) + : {}; + + return { + setDocumentMode: vi.fn(), + setOptions: vi.fn(), + on: vi.fn(), + off: vi.fn(), + destroy: vi.fn(), + getJSON: vi.fn(() => ({ type: 'doc', content: [] })), + isEditable: true, + state: { + selection: { + from: 0, + to: 0, + $from: { + depth: 0, + node: vi.fn(), + }, }, - }, - doc: { - nodeSize: 100, - content: { - size: 100, + doc: { + attrs: contentAttrs, + nodeSize: 100, + content: { + size: 100, + }, + descendants: vi.fn(), + nodesBetween: vi.fn((_from: number, _to: number, callback: (node: unknown, pos: number) => void) => { + // Simulate a simple document with one text block at position 0. + callback({ isTextblock: true }, 0); + }), + resolve: vi.fn((pos: number) => ({ + pos, + depth: 0, + parent: { inlineContent: true }, + node: vi.fn(), + min: vi.fn((other: { pos: number }) => Math.min(pos, other.pos)), + max: vi.fn((other: { pos: number }) => Math.max(pos, other.pos)), + })), + }, + tr: { + setSelection: vi.fn().mockReturnThis(), }, - descendants: vi.fn(), - nodesBetween: vi.fn((_from: number, _to: number, callback: (node: unknown, pos: number) => void) => { - // Simulate a simple document with one text block at position 0. - callback({ isTextblock: true }, 0); - }), - resolve: vi.fn((pos: number) => ({ - pos, - depth: 0, - parent: { inlineContent: true }, - node: vi.fn(), - min: vi.fn((other: { pos: number }) => Math.min(pos, other.pos)), - max: vi.fn((other: { pos: number }) => Math.max(pos, other.pos)), - })), - }, - tr: { - setSelection: vi.fn().mockReturnThis(), }, - }, - view: { - dom: { - dispatchEvent: vi.fn(() => true), + view: { + dom: { + dispatchEvent: vi.fn(() => true), + focus: vi.fn(), + }, focus: vi.fn(), + dispatch: vi.fn(), }, - focus: vi.fn(), - dispatch: vi.fn(), - }, - options: { - documentId: 'test-doc', - element: document.createElement('div'), - }, - converter: mockEditorConverterStore.current, - storage: { - image: { - media: mockEditorConverterStore.mediaFiles, + options: { + documentId: 'test-doc', + element: document.createElement('div'), }, - }, - })), + converter: mockEditorConverterStore.current, + storage: { + image: { + media: mockEditorConverterStore.mediaFiles, + }, + }, + }; + }), }; }); @@ -1100,6 +1108,56 @@ describe('PresentationEditor', () => { }); }); + describe('documentBackground resolution', () => { + it('forwards configured documentBackground when the document has no imported background', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'doc-background-config-doc', + mode: 'docx', + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + layoutEngineOptions: { + documentBackground: { color: '#EEEEEE' }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(editor.getLayoutOptions().documentBackground).toEqual({ color: '#EEEEEE' }); + + const layoutOptions = mockIncrementalLayout.mock.calls[mockIncrementalLayout.mock.calls.length - 1]?.[3] as { + documentBackground?: { color?: string }; + }; + expect(layoutOptions.documentBackground).toEqual({ color: '#EEEEEE' }); + }); + + it('prefers imported documentBackground over the configured fallback', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'doc-background-import-doc', + mode: 'docx', + content: { + type: 'doc', + attrs: { + documentBackground: { color: '#DDDDDD' }, + }, + content: [{ type: 'paragraph' }], + }, + layoutEngineOptions: { + documentBackground: { color: '#EEEEEE' }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(editor.getLayoutOptions().documentBackground).toEqual({ color: '#DDDDDD' }); + + const layoutOptions = mockIncrementalLayout.mock.calls[mockIncrementalLayout.mock.calls.length - 1]?.[3] as { + documentBackground?: { color?: string }; + }; + expect(layoutOptions.documentBackground).toEqual({ color: '#DDDDDD' }); + }); + }); + describe('scrollToPage', () => { const buildMixedPageLayout = () => ({ layout: { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index 206df195de..d234396b1d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -15,6 +15,7 @@ import type { FlowMode, SectionMetadata, TrackChangeAuthor, + DocumentBackground, } from '@superdoc/contracts'; import type { LayoutMode, RulerOptions } from '@superdoc/painter-dom'; import type { ProofingConfig } from './proofing/types.js'; @@ -130,6 +131,7 @@ export type ResolvedLayoutOptions = pageSize: PageSize; margins: ResolvedMarginsBase; columns?: { count: number; gap: number }; + documentBackground?: DocumentBackground; sectionMetadata: SectionMetadata[]; alternateHeaders?: boolean; } @@ -138,6 +140,7 @@ export type ResolvedLayoutOptions = pageSize: PageSize; margins: ResolvedMarginsBase; columns: { count: 1; gap: 0 }; + documentBackground?: DocumentBackground; semantic: { contentWidth: number; marginLeft: number; @@ -151,6 +154,7 @@ export type ResolvedLayoutOptions = export type LayoutEngineOptions = { pageSize?: PageSize; margins?: PageMargins; + documentBackground?: DocumentBackground; zoom?: number; virtualization?: VirtualizationOptions; pageStyles?: Record; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js index a7a3882f9b..4ab1e89a7c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js @@ -409,6 +409,33 @@ function mergeMcIgnorable(defaultIgnorable = '', originalIgnorable = '') { return merged.join(' '); } +function normalizeDocumentBackgroundColorForExport(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + const hex = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed; + if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null; + return hex.toUpperCase(); +} + +function translateDocumentBackgroundNode(params) { + const background = params?.node?.attrs?.documentBackground; + if (!background || typeof background !== 'object') return null; + + if (background.originalXml && typeof background.originalXml === 'object') { + return carbonCopy(background.originalXml); + } + + const color = normalizeDocumentBackgroundColorForExport(background.color); + if (!color) return null; + + return { + type: 'element', + name: 'w:background', + attributes: { 'w:color': color }, + elements: [], + }; +} + /** * Translate a document node * @@ -421,6 +448,7 @@ function translateDocumentNode(params) { content: params.node.content, }; + const translatedBackgroundNode = translateDocumentBackgroundNode(params); const translatedBodyNode = exportSchemaToJson({ ...params, node: bodyNode }); // Merge original document attributes with defaults to preserve custom namespaces @@ -438,7 +466,7 @@ function translateDocumentNode(params) { const node = { name: 'w:document', - elements: [translatedBodyNode], + elements: translatedBackgroundNode ? [translatedBackgroundNode, translatedBodyNode] : [translatedBodyNode], attributes, }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js index 64e88a2894..fadeb8b959 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/bibliography-preprocessor.js @@ -11,7 +11,12 @@ import { buildBlockFieldNode } from './build-block-field-node.js'; * @param {Array<{type: string, text?: string}>} [legacyInstructionTokens] Legacy raw instruction tokens. * @returns {import('../../v2/types/index.js').OpenXmlNode[]} */ -export function preProcessBibliographyInstruction(nodesToCombine, instrText, options = {}, legacyInstructionTokens = null) { +export function preProcessBibliographyInstruction( + nodesToCombine, + instrText, + options = {}, + legacyInstructionTokens = null, +) { const instructionTokens = options?.instructionTokens ?? legacyInstructionTokens; return buildBlockFieldNode('sd:bibliography', nodesToCombine, instrText, instructionTokens); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js index 2f85247bc9..f9650b1c5a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.js @@ -11,7 +11,8 @@ import { parsePageNumberFieldSwitches } from '../shared/page-number-field-switch */ export function preProcessPageInstruction(nodesToCombine, instrText = 'PAGE', options = {}) { const fieldRunRPr = options.fieldRunRPr ?? null; - const normalizedInstruction = typeof instrText === 'string' && instrText.trim() ? instrText.trim().replace(/\s+/g, ' ') : 'PAGE'; + const normalizedInstruction = + typeof instrText === 'string' && instrText.trim() ? instrText.trim().replace(/\s+/g, ' ') : 'PAGE'; const fieldAttrs = { instruction: normalizedInstruction, ...parsePageNumberFieldSwitches(normalizedInstruction, 'PAGE'), diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index 239825d532..db841207a4 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -179,6 +179,23 @@ const parseTrackedChangeSourceIdMap = (raw) => { const readTrackedChangeSourceIdMap = (docx) => parseTrackedChangeSourceIdMap(readCustomProperty(docx, TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY)); +const normalizeDocumentBackgroundColor = (value) => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!/^[0-9a-fA-F]{6}$/.test(trimmed)) return null; + return `#${trimmed.toUpperCase()}`; +}; + +const getDocumentBackground = (documentNode) => { + const background = documentNode?.elements?.find((el) => el?.name === 'w:background'); + const color = normalizeDocumentBackgroundColor(background?.attributes?.['w:color'] ?? background?.attributes?.color); + if (!background || !color) return null; + return { + color, + originalXml: carbonCopy(background), + }; +}; + /** * Detect the document-level threading profile for comments based on file structure. * @param {ParsedDocx} docx The parsed docx object @@ -203,6 +220,7 @@ const detectCommentThreadingProfile = (docx) => { export const createDocumentJson = (docx, converter, editor) => { const json = carbonCopy(getInitialJSON(docx)); if (!json) return null; + const documentBackground = getDocumentBackground(json.elements?.[0]); if (converter) { importFootnotePropertiesFromSettings(docx, converter); @@ -290,21 +308,26 @@ export const createDocumentJson = (docx, converter, editor) => { attributes: json.elements[0].attributes, // Attach body-level sectPr if it exists ...(bodySectPr ? { bodySectPr } : {}), + ...(documentBackground ? { documentBackground } : {}), }, }; + const pageStyles = getDocumentStyles( + node, + docx, + converter, + editor, + numbering, + translatedNumbering, + translatedLinkedStyles, + ); + if (documentBackground) { + pageStyles.documentBackground = { color: documentBackground.color }; + } return { pmDoc: result, savedTagsToRestore: node, - pageStyles: getDocumentStyles( - node, - docx, - converter, - editor, - numbering, - translatedNumbering, - translatedLinkedStyles, - ), + pageStyles, comments, footnotes, endnotes, diff --git a/packages/super-editor/src/editors/v1/extensions/document/document.js b/packages/super-editor/src/editors/v1/extensions/document/document.js index 7a6a44cdce..1ed3501d3b 100644 --- a/packages/super-editor/src/editors/v1/extensions/document/document.js +++ b/packages/super-editor/src/editors/v1/extensions/document/document.js @@ -58,6 +58,15 @@ export const Document = Node.create({ * ensuring that the last section’s page size/orientation/margins are applied correctly. */ }, + documentBackground: { + rendered: false, + default: null, + /** + * Document-level background metadata extracted from w:document > w:background. + * Stored separately from paragraph/table shading so page paint can apply it + * without changing content-level background semantics. + */ + }, }; }, diff --git a/packages/super-editor/src/editors/v1/tests/import-export/documentBackgroundRoundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/documentBackgroundRoundtrip.test.js new file mode 100644 index 0000000000..c473758bac --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/documentBackgroundRoundtrip.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import JSZip from 'jszip'; +import { Editor } from '@core/Editor.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +import { initTestEditor, getTestDataAsFileBuffer } from '../helpers/helpers.js'; + +const TEST_DOC = 'blank-doc.docx'; + +async function buildDocxWithDocumentBackground() { + const baseBuffer = await getTestDataAsFileBuffer(TEST_DOC); + const zip = await JSZip.loadAsync(baseBuffer); + const documentEntry = zip.file('word/document.xml'); + if (!documentEntry) throw new Error('word/document.xml not found in fixture.'); + + const documentXml = await documentEntry.async('string'); + const patchedDocumentXml = documentXml.replace( + //, + '', + ); + zip.file('word/document.xml', patchedDocumentXml); + + return zip.generateAsync({ type: 'nodebuffer' }); +} + +function getDocumentBackground(xml) { + const documentJson = parseXmlToJson(xml); + const documentNode = documentJson?.elements?.find((el) => el?.name === 'w:document'); + return documentNode?.elements?.find((el) => el?.name === 'w:background') ?? null; +} + +describe('document background roundtrip', () => { + it('preserves imported w:background metadata on export', async () => { + const patchedBuffer = await buildDocxWithDocumentBackground(); + const inputFiles = await new DocxZipper().getDocxData(patchedBuffer, true); + const inputDocument = inputFiles.find((entry) => entry.name === 'word/document.xml')?.content; + + expect(inputDocument).toBeTruthy(); + expect(getDocumentBackground(inputDocument)?.attributes).toMatchObject({ + 'w:color': 'EEEEEE', + 'w:themeColor': 'accent3', + }); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(patchedBuffer, true); + const { editor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + }); + + try { + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); + expect(getDocumentBackground(updatedDocs['word/document.xml'])?.attributes).toMatchObject({ + 'w:color': 'EEEEEE', + 'w:themeColor': 'accent3', + }); + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const zipper = new DocxZipper(); + const exportedFiles = await zipper.getDocxData(exportedBuffer, true); + const exportedDocument = exportedFiles.find((entry) => entry.name === 'word/document.xml')?.content; + + expect(exportedDocument).toBeTruthy(); + expect(getDocumentBackground(exportedDocument)?.attributes).toMatchObject({ + 'w:color': 'EEEEEE', + 'w:themeColor': 'accent3', + }); + } finally { + editor.destroy(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/tests/import/docxImporter.test.js b/packages/super-editor/src/editors/v1/tests/import/docxImporter.test.js index 7e9e045ce2..5fa1ef35e7 100644 --- a/packages/super-editor/src/editors/v1/tests/import/docxImporter.test.js +++ b/packages/super-editor/src/editors/v1/tests/import/docxImporter.test.js @@ -113,6 +113,65 @@ describe('createDocumentJson', () => { expect(converter.footers).toEqual({}); }); + it('imports document-level w:background color as document metadata', () => { + const simpleDocXml = + '' + + '' + + 'Hello' + + ''; + + const docx = { + 'word/document.xml': parseXmlToJson(simpleDocXml), + 'word/styles.xml': parseXmlToJson(minimalStylesXml), + }; + + const converter = { + headers: {}, + footers: {}, + headerIds: {}, + footerIds: {}, + }; + + const editor = { options: {}, emit: vi.fn() }; + + const result = createDocumentJson(docx, converter, editor); + + expect(result.pmDoc.attrs.documentBackground).toMatchObject({ + color: '#EEEEEE', + originalXml: { + name: 'w:background', + attributes: { 'w:color': 'eeeeee' }, + }, + }); + expect(result.pageStyles.documentBackground).toEqual({ color: '#EEEEEE' }); + }); + + it('keeps default page behavior when document-level w:background is absent', () => { + const simpleDocXml = + '' + + 'Hello' + + ''; + + const docx = { + 'word/document.xml': parseXmlToJson(simpleDocXml), + 'word/styles.xml': parseXmlToJson(minimalStylesXml), + }; + + const converter = { + headers: {}, + footers: {}, + headerIds: {}, + footerIds: {}, + }; + + const editor = { options: {}, emit: vi.fn() }; + + const result = createDocumentJson(docx, converter, editor); + + expect(result.pmDoc.attrs.documentBackground).toBeUndefined(); + expect(result.pageStyles.documentBackground).toBeUndefined(); + }); + it('imports alternatecontent_valid sample and preserves choice content', async () => { const docx = await getTestDataByFileName('alternateContent_valid.docx'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1e2880d48..17fdb36a84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2396,7 +2396,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vue: specifier: 3.5.32 version: 3.5.32(typescript@5.9.3) @@ -2421,7 +2421,7 @@ importers: version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) concurrently: specifier: 'catalog:' version: 9.2.1 @@ -2445,7 +2445,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) packages/document-api: {} @@ -33507,25 +33507,6 @@ snapshots: vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.32(typescript@5.9.3) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.12 - debug: 4.4.3(supports-color@5.5.0) - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - std-env: 3.10.0 - test-exclude: 7.0.2 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0