diff --git a/packages/document-api/src/inline-semantics/token-parsers.ts b/packages/document-api/src/inline-semantics/token-parsers.ts index 51a68be4c1..49fc4be2fe 100644 --- a/packages/document-api/src/inline-semantics/token-parsers.ts +++ b/packages/document-api/src/inline-semantics/token-parsers.ts @@ -9,7 +9,13 @@ import type { CoreTogglePropertyId } from './property-ids.js'; import type { InvalidInlineTokenError, InvalidInlineTokenToggle, InvalidInlineTokenUnderline } from './error-types.js'; import type { DirectState } from './directives.js'; -import { ST_ON_OFF_VALUE_SET, ST_ON_OFF_ON_VALUES, ST_ON_OFF_OFF_VALUES, ST_UNDERLINE_VALUE_SET, ST_THEME_COLOR_VALUE_SET } from './token-sets.js'; +import { + ST_ON_OFF_VALUE_SET, + ST_ON_OFF_ON_VALUES, + ST_ON_OFF_OFF_VALUES, + ST_UNDERLINE_VALUE_SET, + ST_THEME_COLOR_VALUE_SET, +} from './token-sets.js'; // --------------------------------------------------------------------------- // Result types diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index eb453d72fa..4a0c9f8bc7 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1790,6 +1790,13 @@ export interface PositionMapping { readonly maps: readonly unknown[]; } +/** + * Rendering flow mode. + * - `paginated`: discrete page surfaces + * - `semantic`: continuous flow surface + */ +export type FlowMode = 'paginated' | 'semantic'; + export interface PainterDOM { paint(layout: Layout, mount: HTMLElement, mapping?: PositionMapping): void; /** diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 6b82c6358e..ab5953ab54 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -16,6 +16,7 @@ import { computeDisplayPageNumber, resolvePageNumberTokens, type NumberingContext, + SEMANTIC_PAGE_HEIGHT_PX, } from '@superdoc/layout-engine'; import { remeasureParagraph } from './remeasure'; import { computeDirtyRegions } from './diff'; @@ -740,6 +741,15 @@ export async function incrementalLayout( }, previousMeasures?: Measure[] | null, ): Promise { + const isSemanticFlow = options.flowMode === 'semantic'; + + // In semantic mode, neutralize paginated-only inputs so downstream code + // doesn't need per-step guards. + if (isSemanticFlow) { + headerFooter = undefined; + nextBlocks = rewriteSectionBreaksForSemanticFlow(nextBlocks, options); + } + // Dirty region computation const dirtyStart = performance.now(); const dirty = computeDirtyRegions(previousBlocks, nextBlocks); @@ -765,7 +775,15 @@ export async function incrementalLayout( } const hasPreviousMeasures = Array.isArray(previousMeasures) && previousMeasures.length === previousBlocks.length; - const previousConstraints = hasPreviousMeasures ? resolveMeasurementConstraints(options, previousBlocks) : null; + // In semantic mode, the options-level semantic.contentWidth can change between + // renders (container resize) while the block content stays the same. Since + // previousConstraints is re-derived from the current options (not the options + // that produced the previous measures), it would incorrectly match the current + // constraints even when the previous measures were taken at a different width. + // Disable previous-pass measure reuse in semantic mode; the width-keyed + // measureCache still provides fast lookups for unchanged blocks. + const previousConstraints = + hasPreviousMeasures && !isSemanticFlow ? resolveMeasurementConstraints(options, previousBlocks) : null; const canReusePreviousMeasures = hasPreviousMeasures && previousConstraints?.measurementWidth === measurementWidth && @@ -1098,7 +1116,7 @@ export async function incrementalLayout( let converged = true; // Only run token resolution if feature flag is enabled - if (FeatureFlags.BODY_PAGE_TOKENS) { + if (!isSemanticFlow && FeatureFlags.BODY_PAGE_TOKENS) { while (iteration < maxIterations) { // Build numbering context from current layout const sections = options.sectionMetadata ?? []; @@ -1212,7 +1230,7 @@ export async function incrementalLayout( let extraBlocks: FlowBlock[] | undefined; let extraMeasures: Measure[] | undefined; const footnotesInput = isFootnotesLayoutInput(options.footnotes) ? options.footnotes : null; - if (footnotesInput && footnotesInput.refs.length > 0 && footnotesInput.blocksById.size > 0) { + if (!isSemanticFlow && footnotesInput && footnotesInput.refs.length > 0 && footnotesInput.blocksById.size > 0) { const gap = typeof footnotesInput.gap === 'number' && Number.isFinite(footnotesInput.gap) ? footnotesInput.gap : 2; const topPadding = typeof footnotesInput.topPadding === 'number' && Number.isFinite(footnotesInput.topPadding) @@ -1921,6 +1939,40 @@ const DEFAULT_MARGINS = { top: 72, right: 72, bottom: 72, left: 72 }; export const normalizeMargin = (value: number | undefined, fallback: number): number => Number.isFinite(value) ? (value as number) : fallback; +/** + * Rewrites section break blocks so that `layoutDocument` uses the semantic page + * dimensions instead of the per-section DOCX page sizes. Without this, each + * section break carries its original narrow DOCX `pageSize` / `margins` / + * `columns`, and `layoutDocument` would switch `activePageSize` to those values + * — defeating the semantic flow's container-width–based layout. + * + * Only the block-level layout properties are overridden; everything else + * (numbering, header/footer refs, vAlign, orientation) is preserved. + */ +function rewriteSectionBreaksForSemanticFlow(blocks: FlowBlock[], options: LayoutOptions): FlowBlock[] { + const semanticPageSize = options.pageSize; + const semanticMargins = options.margins; + if (!semanticPageSize) return blocks; + if (!blocks.some((b) => b.kind === 'sectionBreak')) return blocks; + + return blocks.map((block) => { + if (block.kind !== 'sectionBreak') return block; + const sb = block as SectionBreakBlock; + return { + ...sb, + pageSize: { w: semanticPageSize.w, h: semanticPageSize.h }, + margins: { + ...sb.margins, + top: semanticMargins?.top, + right: semanticMargins?.right, + bottom: semanticMargins?.bottom, + left: semanticMargins?.left, + }, + columns: { count: 1, gap: 0 }, + }; + }); +} + /** * Computes measurement constraints for each block based on its section's properties. * @@ -2050,6 +2102,26 @@ export function resolveMeasurementConstraints( measurementWidth: number; measurementHeight: number; } { + if (options.flowMode === 'semantic') { + const semanticContentWidth = options.semantic?.contentWidth; + if (typeof semanticContentWidth === 'number' && Number.isFinite(semanticContentWidth) && semanticContentWidth > 0) { + const semanticTop = normalizeMargin( + options.semantic?.marginTop, + normalizeMargin(options.margins?.top, DEFAULT_MARGINS.top), + ); + const semanticBottom = normalizeMargin( + options.semantic?.marginBottom, + normalizeMargin(options.margins?.bottom, DEFAULT_MARGINS.bottom), + ); + const measurementHeight = Math.max(1, SEMANTIC_PAGE_HEIGHT_PX - (semanticTop + semanticBottom)); + const measurementWidth = Math.max(1, Math.floor(semanticContentWidth)); + return { + measurementWidth, + measurementHeight, + }; + } + } + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; const margins = { top: normalizeMargin(options.margins?.top, DEFAULT_MARGINS.top), diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index a99eadccf0..d076aed516 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -50,7 +50,7 @@ export { export type { HeaderFooterBatch, DigitBucket } from './layoutHeaderFooter'; export { findWordBoundaries, findParagraphBoundaries } from './text-boundaries'; export type { BoundaryRange } from './text-boundaries'; -export { incrementalLayout, measureCache } from './incrementalLayout'; +export { incrementalLayout, measureCache, normalizeMargin } from './incrementalLayout'; export type { HeaderFooterLayoutResult, IncrementalLayoutResult } from './incrementalLayout'; // Re-export computeDisplayPageNumber from layout-engine for section-aware page numbering export { computeDisplayPageNumber, type DisplayPageInfo } from '@superdoc/layout-engine'; diff --git a/packages/layout-engine/layout-bridge/test/incrementalLayout.semanticFlow.test.ts b/packages/layout-engine/layout-bridge/test/incrementalLayout.semanticFlow.test.ts new file mode 100644 index 0000000000..7a611ae7d8 --- /dev/null +++ b/packages/layout-engine/layout-bridge/test/incrementalLayout.semanticFlow.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { incrementalLayout } from '../src/incrementalLayout'; + +import type { FlowBlock, Measure, SectionBreakBlock } from '@superdoc/contracts'; + +const makeParagraph = (id: string, text: string): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [{ text, fontFamily: 'Arial', fontSize: 12 }], +}); + +const makeParagraphMeasure = (lineHeight: number, runLength: number, maxWidth: number): Measure => ({ + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: runLength, + width: Math.min(maxWidth, runLength * 7), + ascent: lineHeight * 0.8, + descent: lineHeight * 0.2, + lineHeight, + maxWidth, + }, + ], + totalHeight: lineHeight, +}); + +describe('incrementalLayout semantic flow', () => { + it('rewrites section-break columns to single-column semantic width before layout', async () => { + const semanticMargins = { top: 24, right: 100, bottom: 36, left: 100 }; + const semanticContentWidth = 600; + const semanticPageWidth = semanticContentWidth + semanticMargins.left + semanticMargins.right; + + const firstSectionBreak: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb-1', + type: 'continuous', + attrs: { isFirstSection: true, source: 'sectPr' }, + // Intentionally narrow + multi-column: would reduce paragraph fragment width + // without semantic rewrite in incrementalLayout. + pageSize: { w: 320, h: 900 }, + margins: { top: 12, right: 12, bottom: 12, left: 12 }, + columns: { count: 2, gap: 24 }, + }; + + const paragraph = makeParagraph('p-1', 'Semantic section rewrite keeps this paragraph full-width.'); + const paragraphTextLength = paragraph.kind === 'paragraph' ? paragraph.runs[0].text.length : 1; + + const measureBlock = vi.fn(async (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => { + if (block.kind !== 'paragraph') { + throw new Error(`Unexpected block kind in test measure: ${block.kind}`); + } + return makeParagraphMeasure(20, paragraphTextLength, constraints.maxWidth); + }); + + const result = await incrementalLayout( + [], + null, + [firstSectionBreak, paragraph], + { + flowMode: 'semantic', + pageSize: { w: semanticPageWidth, h: 900 }, + margins: semanticMargins, + semantic: { + contentWidth: semanticContentWidth, + marginTop: semanticMargins.top, + marginBottom: semanticMargins.bottom, + }, + }, + measureBlock, + ); + + const paragraphFragment = result.layout.pages + .flatMap((page) => page.fragments) + .find((fragment) => fragment.kind === 'para' && fragment.blockId === paragraph.id); + + expect(paragraphFragment).toBeDefined(); + expect(paragraphFragment?.width).toBe(semanticContentWidth); + }); + + it('skips header/footer layout work in semantic flow mode', async () => { + const paragraph = makeParagraph('body-1', 'Body content'); + const headerParagraph = makeParagraph('header-1', 'Header content'); + + const measureBlock = vi.fn(async (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => { + if (block.kind !== 'paragraph') { + throw new Error(`Unexpected block kind in test measure: ${block.kind}`); + } + const runLength = block.runs[0]?.text?.length ?? 1; + return makeParagraphMeasure(20, runLength, constraints.maxWidth); + }); + + const headerMeasure = vi.fn(async (block: FlowBlock, constraints: { maxWidth: number; maxHeight: number }) => { + if (block.kind !== 'paragraph') { + throw new Error(`Unexpected header block kind in test measure: ${block.kind}`); + } + const runLength = block.runs[0]?.text?.length ?? 1; + return makeParagraphMeasure(20, runLength, constraints.maxWidth); + }); + + const result = await incrementalLayout( + [], + null, + [paragraph], + { + flowMode: 'semantic', + pageSize: { w: 800, h: 900 }, + margins: { top: 40, right: 100, bottom: 40, left: 100 }, + semantic: { contentWidth: 600, marginTop: 40, marginBottom: 40 }, + }, + measureBlock, + { + headerBlocks: { default: [headerParagraph] }, + constraints: { width: 600, height: 80 }, + measure: headerMeasure, + }, + ); + + expect(result.headers).toBeUndefined(); + expect(result.footers).toBeUndefined(); + expect(headerMeasure).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts b/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts index 8b5b7b3e5f..1f112e72d0 100644 --- a/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts +++ b/packages/layout-engine/layout-bridge/test/resolveMeasurementConstraints.test.ts @@ -277,6 +277,54 @@ describe('resolveMeasurementConstraints', () => { }); }); + describe('semantic flow constraints', () => { + it('uses semantic content width directly when provided', () => { + const options: LayoutOptions = { + flowMode: 'semantic', + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + semantic: { + contentWidth: 530, + marginTop: 40, + marginBottom: 50, + }, + }; + + const result = resolveMeasurementConstraints(options); + expect(result.measurementWidth).toBe(530); + expect(result.measurementHeight).toBe(999910); // 1_000_000 - (40 + 50) + }); + + it('normalizes fractional semantic content width to match layout rounding', () => { + const options: LayoutOptions = { + flowMode: 'semantic', + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + semantic: { + contentWidth: 530.9, + marginTop: 40, + marginBottom: 50, + }, + }; + + const result = resolveMeasurementConstraints(options); + expect(result.measurementWidth).toBe(530); + expect(result.measurementHeight).toBe(999910); + }); + + it('falls back to paginated constraints when semantic content width is missing', () => { + const options: LayoutOptions = { + flowMode: 'semantic', + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + }; + + const result = resolveMeasurementConstraints(options); + expect(result.measurementWidth).toBe(468); + expect(result.measurementHeight).toBe(648); + }); + }); + describe('column width calculations', () => { it('handles zero gap in multi-column layout', () => { const options: LayoutOptions = { diff --git a/packages/layout-engine/layout-engine/src/index.d.ts b/packages/layout-engine/layout-engine/src/index.d.ts index fcd5e70ab2..88ae162921 100644 --- a/packages/layout-engine/layout-engine/src/index.d.ts +++ b/packages/layout-engine/layout-engine/src/index.d.ts @@ -1,6 +1,7 @@ import type { ColumnLayout, FlowBlock, + FlowMode, HeaderFooterLayout, Layout, Measure, @@ -23,8 +24,17 @@ export type LayoutOptions = { pageSize?: PageSize; margins?: Margins; columns?: ColumnLayout; + flowMode?: FlowMode; + semantic?: { + contentWidth?: number; + marginLeft?: number; + marginRight?: number; + marginTop?: number; + marginBottom?: number; + }; remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure; }; +export declare const SEMANTIC_PAGE_HEIGHT_PX = 1000000; export type HeaderFooterConstraints = { width: number; height: number; diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index a884d50b81..09aae9dc94 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -24,6 +24,7 @@ import type { DrawingMeasure, DrawingFragment, SectionNumbering, + FlowMode, } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; @@ -62,6 +63,12 @@ type NormalizedColumns = ColumnLayout & { width: number }; */ const DEFAULT_PARAGRAPH_LINE_HEIGHT_PX = 20; +/** + * Synthetic page height used in semantic flow mode to avoid pagination-driven clipping + * during measurement. A large finite value preserves stable measurement constraints. + */ +export const SEMANTIC_PAGE_HEIGHT_PX = 1_000_000; + /** * Type guard to check if a fragment has a height property. * Image, Drawing, and Table fragments all have a required height property. @@ -419,6 +426,14 @@ export type LayoutOptions = { pageSize?: PageSize; margins?: Margins; columns?: ColumnLayout; + flowMode?: FlowMode; + semantic?: { + contentWidth?: number; + marginLeft?: number; + marginRight?: number; + marginTop?: number; + marginBottom?: number; + }; remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure; sectionMetadata?: SectionMetadata[]; /** diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index ad734f8e99..cd029a149e 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -10,7 +10,7 @@ import type { } from '@superdoc/contracts'; import { DomPainter } from './renderer.js'; import type { PageStyles } from './styles.js'; -import type { PaintSnapshot, RulerOptions } from './renderer.js'; +import type { PaintSnapshot, RulerOptions, FlowMode } from './renderer.js'; // Re-export constants export { DOM_CLASS_NAMES } from './constants.js'; @@ -55,6 +55,7 @@ export { export type { PmPositionValidationStats } from './pm-position-validation.js'; export type LayoutMode = 'vertical' | 'horizontal' | 'book'; +export type { FlowMode } from './renderer.js'; export type PageDecorationPayload = { fragments: Fragment[]; height: number; @@ -82,6 +83,7 @@ export type DomPainterOptions = { measures: Measure[]; pageStyles?: PageStyles; layoutMode?: LayoutMode; + flowMode?: FlowMode; /** Gap between pages in pixels (default: 24px for vertical, 20px for horizontal) */ pageGap?: number; headerProvider?: PageDecorationProvider; @@ -99,7 +101,7 @@ export type DomPainterOptions = { overscan?: number; /** * Gap between pages used for spacer math (px). When set, container gap is overridden - * to this value during virtualization. Default approximates existing margin+gap look: 72. + * to this value during virtualization. Defaults to the effective `pageGap`. */ gap?: number; /** Optional mount padding-top override (px) used in scroll mapping; defaults to computed style. */ @@ -128,6 +130,7 @@ export const createDomPainter = ( const painter = new DomPainter(options.blocks, options.measures, { pageStyles: options.pageStyles, layoutMode: options.layoutMode, + flowMode: options.flowMode, pageGap: options.pageGap, headerProvider: options.headerProvider, footerProvider: options.footerProvider, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 9b00a82d97..4a6fe8c81e 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -43,6 +43,7 @@ import type { TableAttrs, TableCellAttrs, PositionMapping, + FlowMode, CustomGeometryData, } from '@superdoc/contracts'; import { calculateJustifySpacing, computeLinePmRange, shouldApplyJustify, SPACE_CHARS } from '@superdoc/contracts'; @@ -264,6 +265,8 @@ function isMinimalWordLayout(value: unknown): value is MinimalWordLayout { * - 'book': Book-style layout with facing pages */ export type LayoutMode = 'vertical' | 'horizontal' | 'book'; +// FlowMode is re-exported from @superdoc/contracts +export type { FlowMode } from '@superdoc/contracts'; type PageDecorationPayload = { fragments: Fragment[]; @@ -310,6 +313,7 @@ export type RulerOptions = { type PainterOptions = { pageStyles?: PageStyles; layoutMode?: LayoutMode; + flowMode?: FlowMode; /** Gap between pages in pixels (default: 24px for vertical, 20px for horizontal) */ pageGap?: number; headerProvider?: PageDecorationProvider; @@ -318,7 +322,7 @@ type PainterOptions = { enabled?: boolean; window?: number; overscan?: number; - /** Virtualization gap override (defaults to 72px; independent of pageGap) */ + /** Virtualization gap override (defaults to 72px; independent of pageGap). */ gap?: number; paddingTop?: number; }; @@ -986,6 +990,7 @@ export class DomPainter { private currentLayout: Layout | null = null; private changedBlocks = new Set(); private readonly layoutMode: LayoutMode; + private readonly isSemanticFlow: boolean; private headerProvider?: PageDecorationProvider; private footerProvider?: PageDecorationProvider; private totalPages = 0; @@ -1045,6 +1050,7 @@ export class DomPainter { constructor(blocks: FlowBlock[], measures: Measure[], options: PainterOptions = {}) { this.options = options; this.layoutMode = options.layoutMode ?? 'vertical'; + this.isSemanticFlow = (options.flowMode ?? 'paginated') === 'semantic'; this.blockLookup = this.buildBlockLookup(blocks, measures); this.headerProvider = options.headerProvider; this.footerProvider = options.footerProvider; @@ -1057,11 +1063,12 @@ export class DomPainter { : defaultGap; // Initialize virtualization config (feature-flagged) - if (this.layoutMode === 'vertical' && options.virtualization?.enabled) { + if (!this.isSemanticFlow && this.layoutMode === 'vertical' && options.virtualization?.enabled) { this.virtualEnabled = true; this.virtualWindow = Math.max(1, options.virtualization.window ?? 5); this.virtualOverscan = Math.max(0, options.virtualization.overscan ?? 0); - // Virtualization gap: use explicit virtualization.gap if provided, otherwise default to virtualized gap (72px) + // Virtualization gap: use explicit virtualization.gap if provided, + // otherwise default to legacy virtualized gap (72px). const maybeGap = options.virtualization.gap; if (typeof maybeGap === 'number' && Number.isFinite(maybeGap)) { this.virtualGap = Math.max(0, maybeGap); @@ -1433,7 +1440,7 @@ export class DomPainter { ensureSdtContainerStyles(doc); ensureImageSelectionStyles(doc); ensureNativeSelectionStyles(doc); - if (this.options.ruler?.enabled) { + if (!this.isSemanticFlow && this.options.ruler?.enabled) { ensureRulerStyles(doc); } mount.classList.add(CLASS_NAMES.container); @@ -1447,6 +1454,22 @@ export class DomPainter { this.beginPaintSnapshot(layout); this.totalPages = layout.pages.length; + 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) { + this.fullRender(layout); + } else { + this.patchLayout(layout); + } + this.currentLayout = layout; + this.changedBlocks.clear(); + this.currentMapping = null; + return; + } + let useDomSnapshotFallback = false; const mode = this.layoutMode; if (mode === 'horizontal') { @@ -1885,12 +1908,13 @@ export class DomPainter { const el = this.doc.createElement('div'); el.classList.add(CLASS_NAMES.page); applyStyles(el, pageStyles(width, height, this.getEffectivePageStyles())); + this.applySemanticPageOverrides(el); el.dataset.layoutEpoch = String(this.layoutEpoch); el.dataset.pageNumber = String(page.number); el.dataset.pageIndex = String(pageIndex); - // Render per-page ruler if enabled - if (this.options.ruler?.enabled) { + // Render per-page ruler if enabled (suppressed in semantic flow mode) + if (!this.isSemanticFlow && this.options.ruler?.enabled) { const rulerEl = this.renderPageRuler(width, page); if (rulerEl) { el.appendChild(rulerEl); @@ -1993,6 +2017,7 @@ export class DomPainter { } private renderDecorationsForPage(pageEl: HTMLElement, page: Page, pageIndex: number): void { + if (this.isSemanticFlow) return; this.renderDecorationSection(pageEl, page, pageIndex, 'header'); this.renderDecorationSection(pageEl, page, pageIndex, 'footer'); } @@ -2239,6 +2264,7 @@ export class DomPainter { private patchPage(state: PageDomState, page: Page, pageSize: { w: number; h: number }, pageIndex: number): void { const pageEl = state.element; applyStyles(pageEl, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles())); + this.applySemanticPageOverrides(pageEl); pageEl.dataset.pageNumber = String(page.number); pageEl.dataset.layoutEpoch = String(this.layoutEpoch); // pageIndex is already set during creation and doesn't change during patch @@ -2384,6 +2410,7 @@ export class DomPainter { const el = this.doc.createElement('div'); el.classList.add(CLASS_NAMES.page); applyStyles(el, pageStyles(pageSize.w, pageSize.h, this.getEffectivePageStyles())); + this.applySemanticPageOverrides(el); el.dataset.layoutEpoch = String(this.layoutEpoch); const contextBase: FragmentRenderContext = { @@ -2411,7 +2438,25 @@ export class DomPainter { return { element: el, fragments: fragmentStates }; } + private applySemanticPageOverrides(el: HTMLElement): void { + if (this.isSemanticFlow) { + el.style.overflow = 'visible'; + el.style.width = '100%'; + el.style.minWidth = '100%'; + } + } + private getEffectivePageStyles(): PageStyles | undefined { + if (this.isSemanticFlow) { + const base = this.options.pageStyles ?? {}; + return { + ...base, + background: base.background ?? '#fff', + boxShadow: 'none', + border: 'none', + margin: '0', + }; + } if (this.virtualEnabled && this.layoutMode === 'vertical') { // Remove top/bottom margins to avoid double-counting with container gap during virtualization const base = this.options.pageStyles ?? {}; diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index 2241668f79..1fa4388b85 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createDomPainter } from './index.js'; import type { FlowBlock, Measure, Layout, Fragment, PageMargins } from '@superdoc/contracts'; @@ -621,4 +621,69 @@ describe('DomPainter virtualization (vertical)', () => { const firstIndexAfter = firstPageAfter ? Number(firstPageAfter.dataset.pageIndex) : -1; expect(firstIndexAfter).toBeGreaterThanOrEqual(firstIndexBefore); }); + + it('disables virtualization rendering paths in semantic flow mode', () => { + const painter = createDomPainter({ + blocks: [block], + measures: [measure], + flowMode: 'semantic', + virtualization: { enabled: true, window: 2, overscan: 0, gap: 72, paddingTop: 0 }, + }); + + const layout = makeLayout(8); + painter.paint(layout, mount); + + const pages = mount.querySelectorAll('.superdoc-page'); + expect(pages.length).toBe(8); + expect(mount.querySelector('[data-virtual-spacer="top"]')).toBeNull(); + expect(mount.querySelector('[data-virtual-spacer="bottom"]')).toBeNull(); + }); + + it('skips header/footer decoration providers in semantic flow mode', () => { + const headerProvider = vi.fn(() => ({ + height: 20, + offset: 0, + fragments: [ + { + kind: 'para', + blockId: block.id, + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 50, + }, + ], + })); + const footerProvider = vi.fn(() => ({ + height: 20, + offset: 0, + fragments: [ + { + kind: 'para', + blockId: block.id, + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 50, + }, + ], + })); + + const painter = createDomPainter({ + blocks: [block], + measures: [measure], + flowMode: 'semantic', + headerProvider, + footerProvider, + }); + + painter.paint(makeLayout(2), mount); + + expect(headerProvider).not.toHaveBeenCalled(); + expect(footerProvider).not.toHaveBeenCalled(); + expect(mount.querySelector('.superdoc-page-header')).toBeNull(); + expect(mount.querySelector('.superdoc-page-footer')).toBeNull(); + }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/table.ts b/packages/layout-engine/pm-adapter/src/converters/table.ts index eefbadf98b..452383b756 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.ts @@ -34,7 +34,12 @@ import type { NestedConverters, TableNodeToBlockParams, } from '../types.js'; -import { extractTableBorders, extractCellPadding, convertBorderSpec, normalizeShadingColor } from '../attributes/index.js'; +import { + extractTableBorders, + extractCellPadding, + convertBorderSpec, + normalizeShadingColor, +} from '../attributes/index.js'; import { pickNumber, twipsToPx } from '../utilities.js'; import { hydrateTableStyleAttrs } from './table-styles.js'; import { collectTrackedChangeFromMarks } from '../marks/index.js'; diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index d1a5a8483b..61a9863cef 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -1115,13 +1115,16 @@ onBeforeUnmount(() => { @@ -171,6 +199,51 @@ const closeSidebar = () => { gap: 12px; } +.dev-sidebar__section { + display: grid; + gap: 10px; +} + +.dev-sidebar__section + .dev-sidebar__section { + border-top: 1px solid rgba(148, 163, 184, 0.45); + margin-top: 4px; + padding-top: 18px; +} + +.dev-sidebar__section-title { + margin: 0; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #1e293b; +} + +.dev-sidebar__section-icon { + width: 16px; + height: 16px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + line-height: 1; +} + +.dev-sidebar__section-icon--layout { + border: 1px solid rgba(59, 130, 246, 0.5); + color: #1d4ed8; + background: rgba(59, 130, 246, 0.12); +} + +.dev-sidebar__section-icon--word { + border: 1px solid rgba(37, 99, 235, 0.6); + color: #ffffff; + background: #2563eb; +} + .dev-sidebar__actions { display: grid; gap: 8px;