diff --git a/README.md b/README.md index 7cd2361..6048bc4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Vitrine is organized as a pnpm workspace monorepo with several publishable packa |---------|-----------|----------|-------------| | Core | `packages/core` | `vitrine` | Block tree, rendering, events, hit-testing, frustum culling — zero GUI dependencies | | GUI | `packages/gui` | `vitrine-gui` | High-level GUI controls, theming, layout, and `VitrineComponent` | +| Layout & Pagination | `packages/layout-pagination` | `vitrine-layout-pagination` | Renderer-agnostic document layout and pagination contracts | | Tables | `packages/tables` | `vitrine-tables` | Table layout model and extension contracts | | Tables Adapter | `packages/tables-vitrine` | `vitrine-tables-adapter` | Vitrine adapter for table rendering | | Texta | `packages/texta` | `texta` | Attributed text engine for semantic and render transformations | diff --git a/packages/demo/component-demo.html b/packages/demo/component-demo.html index f500c0e..0cf474f 100644 --- a/packages/demo/component-demo.html +++ b/packages/demo/component-demo.html @@ -181,7 +181,7 @@

Raw Blocks

diff --git a/packages/demo/gallery.html b/packages/demo/gallery.html index 33c9439..6054108 100644 --- a/packages/demo/gallery.html +++ b/packages/demo/gallery.html @@ -523,7 +523,7 @@

Performance

diff --git a/packages/demo/layout-pagination-flow-setup.js b/packages/demo/layout-pagination-flow-setup.js new file mode 100644 index 0000000..74c1d8d --- /dev/null +++ b/packages/demo/layout-pagination-flow-setup.js @@ -0,0 +1,27 @@ +import { VitrineComponent } from 'vitrine-gui'; +import { registerTextaBlockType } from 'texta/browser'; +import { + buildFlowPageFlipWrapper, + createFlowPageFlipState +} from 'vitrine-layout-pagination-adapter'; +import { documentFlow, flowResult } from './layout-pagination-scenarios.ts'; + +registerTextaBlockType(); + +const flowFlipState = createFlowPageFlipState(); + +const component = VitrineComponent.block(() => ( + buildFlowPageFlipWrapper(documentFlow, flowResult, flowFlipState, { + x: 270, + y: 96, + mainScale: 1.12, + thumbnailScale: 0.18, + thumbnailGap: 22 + }) +), { + width: 1600, + height: 1120, + renderMode: 'continuous' +}); + +component.mount(document.getElementById('canvas')); diff --git a/packages/demo/layout-pagination-flow.html b/packages/demo/layout-pagination-flow.html new file mode 100644 index 0000000..08ae9bd --- /dev/null +++ b/packages/demo/layout-pagination-flow.html @@ -0,0 +1,94 @@ + + + + + + + Vitrine - Layout Pagination Flow Document + + + +
+

Layout Pagination — Flow Document

+

+ Standalone paginated document preview with realistic page proportions, repeated header/footer regions, + and explicit semantic page breaks for markdown-like content. +

+
+
+

Flow content is paginated semantically into pages; it is not merely sliced by viewport.

+
+
+ + + + + + diff --git a/packages/demo/layout-pagination-presentation-setup.js b/packages/demo/layout-pagination-presentation-setup.js new file mode 100644 index 0000000..3789947 --- /dev/null +++ b/packages/demo/layout-pagination-presentation-setup.js @@ -0,0 +1,27 @@ +import { VitrineComponent } from 'vitrine-gui'; +import { registerTextaBlockType } from 'texta/browser'; +import { + buildPresentationPageFlipWrapper, + createPresentationPageFlipState +} from 'vitrine-layout-pagination-adapter'; +import { documentPresentation, presentationResult } from './layout-pagination-scenarios.ts'; + +registerTextaBlockType(); + +const slideFlipState = createPresentationPageFlipState(); + +const component = VitrineComponent.block(() => ( + buildPresentationPageFlipWrapper(documentPresentation, presentationResult, slideFlipState, { + x: 72, + y: 92, + mainScale: 0.86, + thumbnailScale: 0.17, + thumbnailGap: 20 + }) +), { + width: 1600, + height: 980, + renderMode: 'continuous' +}); + +component.mount(document.getElementById('canvas')); diff --git a/packages/demo/layout-pagination-presentation.html b/packages/demo/layout-pagination-presentation.html new file mode 100644 index 0000000..fe397fa --- /dev/null +++ b/packages/demo/layout-pagination-presentation.html @@ -0,0 +1,94 @@ + + + + + + + Vitrine - Layout Pagination Presentation + + + +
+

Layout Pagination — Presentation

+

+ Standalone presentation-page preview with explicit 16:9 slide geometry, a large active slide, + and a realistic thumbnail rail for browser-authored slide decks. +

+
+
+

Slides are explicit pages. The page size remains fixed; only the browser preview scales.

+
+
+ + + + + + diff --git a/packages/demo/layout-pagination-scenarios.ts b/packages/demo/layout-pagination-scenarios.ts new file mode 100644 index 0000000..abb5e4e --- /dev/null +++ b/packages/demo/layout-pagination-scenarios.ts @@ -0,0 +1,611 @@ +import { group, rectangle, text } from 'vitrine'; +import { createPaginatedLayoutEngine } from 'vitrine-layout-pagination'; +import { + createVitrineLayoutMeasureDelegate, + type VitrineLayoutDocument, + type VitrineLayoutLeafContent +} from 'vitrine-layout-pagination-adapter'; +import { generateSampleEvents, rsCalendarDayView, rsCalendarMonthView } from 'vitrine-gui'; +import { + applyStyle, + detectRgStorageMode, + getRgCodePointBoundaryUtf16, + getRgGraphemeBoundaryUtf16, + texta as textaBlock, + type AttributedTextValue +} from 'texta/browser'; +import { parseMarkdownToRender } from 'texta/markdown'; +import { buildDebugTextaScene } from './text-selection-debug-texta-scene.js'; + +type DemoDocument = VitrineLayoutDocument; + +const rgCalendarEvent = generateSampleEvents(); + +function paragraph(id: string, value: string): DemoDocument['body'] { + return { + kind: 'text', + id, + text: value, + textStyle: { + fontSize: 16, + lineHeight: 24, + fill: '#1e293b' + } + }; +} + +function createLeafContent( + minHeight: number, + renderBlocks: VitrineLayoutLeafContent['renderBlocks'], + clip: boolean = true +): VitrineLayoutLeafContent { + return { + intrinsicSize: { + minWidth: 0, + maxWidth: Number.POSITIVE_INFINITY, + minHeight + }, + clip, + renderBlocks + }; +} + +function getUnitCount(strText: string, rgStorageMode: AttributedTextValue['rgStorageMode']): number { + if (rgStorageMode === 'fastCodeUnit') { + return strText.length; + } + if (rgStorageMode === 'fastCodePoint') { + return getRgCodePointBoundaryUtf16(strText).length - 1; + } + return getRgGraphemeBoundaryUtf16(strText).length - 1; +} + +function getRgSegGraphemeToUtf16( + strText: string, + rgStorageMode: AttributedTextValue['rgStorageMode'] +): number[] { + if (rgStorageMode !== 'segmentedGrapheme') { + return []; + } + return getRgGraphemeBoundaryUtf16(strText).slice(1); +} + +function createAttributedText( + strText: string, + styleDefault: Record +): AttributedTextValue { + const rgStorageMode = detectRgStorageMode(strText); + const iUnits = getUnitCount(strText, rgStorageMode); + return { + iVersion: 1, + rgUnits: 'grapheme', + rgStorageMode, + strText, + rgSegGraphemeToUtf16: getRgSegGraphemeToUtf16(strText, rgStorageMode), + rgIdStyleRef: new Array(iUnits).fill(1), + mpId_StyleEntry: { 1: styleDefault }, + idStyleDefault: 1 + }; +} + +function applyRanges( + value: AttributedTextValue, + ranges: Array<{ start: number; end: number; style: Record }> +): AttributedTextValue { + let next = value; + for (const range of ranges) { + next = applyStyle(next, range.start, range.end, range.style, 'merge'); + } + return next; +} + +const valueSlideAside = applyRanges( + createAttributedText( + 'Selection geometry, styled runs, and markdown all flow through the same Vitrine block pipeline.', + { fontFamily: 'ui-sans-serif', fontSize: 18, fill: '#0f172a', lineHeight: 28 } + ), + [ + { start: 0, end: 18, style: { fontWeight: '700', fill: '#2563eb' } }, + { start: 20, end: 31, style: { fontStyle: 'italic', fill: '#7c3aed' } }, + { start: 63, end: 84, style: { fontWeight: '700', background: '#dbeafe', fill: '#1d4ed8' } } + ] +); + +const valueDocumentCallout = applyRanges( + createAttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Styled spans, background highlights, and mixed emphasis should remain native block content even after pagination.', + { fontFamily: 'Georgia', fontSize: 18, fill: '#334155', lineHeight: 30 } + ), + [ + { start: 0, end: 11, style: { fontWeight: '700', fill: '#1d4ed8' } }, + { start: 28, end: 39, style: { fontStyle: 'italic', fill: '#7c3aed' } }, + { start: 64, end: 85, style: { background: '#fef3c7', fill: '#92400e' } }, + { start: 126, end: 145, style: { fontWeight: '700', fill: '#0f766e' } } + ] +); + +const valueDocumentSummary = applyRanges( + createAttributedText( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam.', + { fontFamily: 'ui-sans-serif', fontSize: 17, fill: '#1f2937', lineHeight: 26 } + ), + [ + { start: 0, end: 22, style: { fontWeight: '700', fill: '#2563eb' } }, + { start: 51, end: 73, style: { fontStyle: 'italic', fill: '#7c3aed' } }, + { start: 89, end: 111, style: { background: '#dcfce7', fill: '#166534' } } + ] +); + +const stSlideMarkdown = `## Demo collage + +- **Calendar Views** brings dense visual structure. +- *Texta* contributes styled runs and rich inline formatting. +- Both now sit inside paginated slides as regular Vitrine blocks.`; + +const stDocumentMarkdownIntro = `# Layout package demo + +This document borrows directly from the **Markdown** and **Texta** demos. + +> The page model decides pagination. The content stays renderer-native. + +Lorem ipsum dolor sit amet, *consectetur adipiscing elit*, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +- **Bold** emphasis for section keywords +- Inline \`code\` for implementation details +- Mixed rhythm with paragraphs and lists`; + +const stDocumentMarkdownChapter = `## Formatted narrative + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. **Praesent** commodo cursus magna, vel scelerisque nisl consectetur et. + +1. Build semantic content with markdown or texta. +2. Measure it as a paginated layout node. +3. Render the resulting fragments as Vitrine blocks. + +--- + +\`\`\` +render(layoutDocument) + -> paginate() + -> buildVitrineBlocksFromPaginatedLayout() +\`\`\``; + +const stDocumentMarkdownClosing = `### Closing note + +*Curabitur blandit tempus porttitor.* Donec sed odio dui. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. + +> Lorem ipsum pages can still carry polished formatting without leaving the Vitrine block model.`; + +const valueMarkdownIntro = parseMarkdownToRender(stDocumentMarkdownIntro); +const valueMarkdownChapter = parseMarkdownToRender(stDocumentMarkdownChapter); +const valueMarkdownClosing = parseMarkdownToRender(stDocumentMarkdownClosing); +const valueSlideMarkdown = parseMarkdownToRender(stSlideMarkdown); + +function frameCard(x: number, y: number, width: number, height: number, title: string, subtitle?: string): ReturnType { + return group({ x, y }, [ + rectangle({ + dx: width, + dy: height, + fill: '#ffffff', + stroke: '#d7e1ee', + strokeWidth: 1, + cornerRadius: 20, + shadow: { offsetX: 0, offsetY: 10, blur: 24, color: 'rgba(15, 23, 42, 0.08)' } + }), + text({ + x: 20, + y: 18, + text: title, + fontSize: 16, + fill: '#0f172a', + font: '600 16px ui-sans-serif', + baseline: 'top' + }), + ...(subtitle + ? [text({ + x: 20, + y: 42, + text: subtitle, + fontSize: 12, + fill: '#64748b', + baseline: 'top' + })] + : []) + ]); +} + +function monthCalendarBlocks(x: number, y: number, scale: number): ReturnType { + return group({ x, y, scaleX: scale, scaleY: scale }, [ + rsCalendarMonthView({ + cMonth: 1, + dateStart: new Date(2026, 1, 1), + dayStartWeek: 0, + rgEvent: rgCalendarEvent, + x: 0, + y: 0 + }) + ]); +} + +function weekCalendarBlocks(x: number, y: number, scale: number): ReturnType { + return group({ x, y, scaleX: scale, scaleY: scale }, [ + rsCalendarDayView({ + cDay: 5, + dateStart: new Date(2026, 1, 16), + hourStart: 8, + hourEnd: 18, + rgEvent: rgCalendarEvent, + x: 0, + y: 0 + }) + ]); +} + +function buildMarkdownPanel( + valueMarkdown: AttributedTextValue, + width: number, + height: number +): ReturnType { + return group({}, [ + rectangle({ + dx: width, + dy: height, + fill: '#ffffff', + stroke: '#d7e1ee', + strokeWidth: 1, + cornerRadius: 18 + }), + group({ x: 22, y: 22, clip: true, dx: width - 44, dy: height - 44 }, [ + textaBlock({ + x: 0, + y: 0, + dx: width - 44, + texta: valueMarkdown, + baseline: 'top' + }) + ]) + ]); +} + +function buildTextaPanel( + title: string, + valueTexta: AttributedTextValue, + width: number, + height: number +): ReturnType { + return group({}, [ + rectangle({ + dx: width, + dy: height, + fill: '#ffffff', + stroke: '#d7e1ee', + strokeWidth: 1, + cornerRadius: 18 + }), + text({ + x: 18, + y: 16, + text: title, + fontSize: 13, + fill: '#64748b', + font: '600 13px ui-sans-serif', + baseline: 'top' + }), + group({ x: 18, y: 42, clip: true, dx: width - 36, dy: height - 60 }, [ + textaBlock({ + x: 0, + y: 0, + dx: width - 36, + texta: valueTexta, + baseline: 'top' + }) + ]) + ]); +} + +function slideShowcaseContent(): VitrineLayoutLeafContent { + return createLeafContent(408, ({ frame }) => { + const dxLeft = frame.width * 0.58; + const dxRight = frame.width - dxLeft - 24; + const dyInner = frame.height - 70; + const sMonth = Math.min((dxLeft - 40) / 1160, (dyInner - 34) / 560); + const sTexta = Math.min((dxRight - 24) / 1000, (dyInner - 34) / 920); + + return [ + frameCard(0, 0, dxLeft, frame.height, 'Calendar Views demo', 'Month view scaled into slide geometry'), + frameCard(dxLeft + 24, 0, dxRight, frame.height, 'Texta selection demo', 'The same debug scene embedded as slide content'), + group({ x: 20, y: 64, clip: true, dx: dxLeft - 40, dy: dyInner - 14 }, [ + monthCalendarBlocks(0, 0, sMonth) + ]), + group({ x: dxLeft + 36, y: 64, clip: true, dx: dxRight - 24, dy: dyInner - 14 }, [ + group({ scaleX: sTexta, scaleY: sTexta }, [buildDebugTextaScene()]) + ]) + ]; + }); +} + +function slidePlannerContent(): VitrineLayoutLeafContent { + return createLeafContent(430, ({ frame }) => { + const dxVisual = frame.width * 0.68; + const dxAside = frame.width - dxVisual - 24; + const sWeek = Math.min((dxVisual - 40) / 1360, (frame.height - 98) / 800); + + return [ + frameCard(0, 0, dxVisual, frame.height, 'Calendar demo reuse', 'Week view adapted to fit a slide panel'), + frameCard(dxVisual + 24, 0, dxAside, frame.height, 'Compact markdown/texta aside', 'Small amount of copy, heavy visual bias'), + group({ x: 20, y: 68, clip: true, dx: dxVisual - 40, dy: frame.height - 88 }, [ + weekCalendarBlocks(0, 0, sWeek) + ]), + group({ x: dxVisual + 40, y: 64 }, [ + buildMarkdownPanel(valueSlideMarkdown, dxAside - 32, 150) + ]), + group({ x: dxVisual + 40, y: 232 }, [ + buildTextaPanel('Texta aside', valueSlideAside, dxAside - 32, 150) + ]) + ]; + }); +} + +function slideTypographyContent(): VitrineLayoutLeafContent { + return createLeafContent(392, ({ frame }) => { + const dxLeft = frame.width * 0.52; + const dxRight = frame.width - dxLeft - 24; + const sTexta = Math.min((dxLeft - 24) / 1000, (frame.height - 78) / 920); + const sMonth = Math.min((dxRight - 40) / 1160, (frame.height - 250) / 560); + + return [ + frameCard(0, 0, dxLeft, frame.height, 'Texta debug collage', 'The existing scene becomes a slide-scale visual texture'), + frameCard(dxLeft + 24, 0, dxRight, 170, 'Markdown panel', 'Formatted text still renders as native blocks'), + frameCard(dxLeft + 24, 190, dxRight, frame.height - 190, 'Mini calendar', 'A second embedded visual borrowed from the calendar demo'), + group({ x: 12, y: 60, clip: true, dx: dxLeft - 24, dy: frame.height - 72 }, [ + group({ scaleX: sTexta, scaleY: sTexta }, [buildDebugTextaScene()]) + ]), + group({ x: dxLeft + 42, y: 58 }, [ + buildMarkdownPanel(valueSlideMarkdown, dxRight - 36, 100) + ]), + group({ x: dxLeft + 44, y: 254, clip: true, dx: dxRight - 40, dy: frame.height - 278 }, [ + monthCalendarBlocks(0, 0, sMonth) + ]) + ]; + }); +} + +function markdownSectionContent( + valueMarkdown: AttributedTextValue, + minHeight: number +): VitrineLayoutLeafContent { + return createLeafContent(minHeight, ({ frame }) => [ + buildMarkdownPanel(valueMarkdown, frame.width, frame.height) + ]); +} + +function textaSectionContent( + title: string, + valueTexta: AttributedTextValue, + minHeight: number +): VitrineLayoutLeafContent { + return createLeafContent(minHeight, ({ frame }) => [ + buildTextaPanel(title, valueTexta, frame.width, frame.height) + ]); +} + +const layoutMeasureDelegate = createVitrineLayoutMeasureDelegate(); + +export const documentPresentation: DemoDocument = { + kind: 'presentation', + id: 'browser-deck', + page: { + width: 1280, + height: 720, + unit: 'px', + margins: { + top: 72, + right: 92, + bottom: 72, + left: 92 + } + }, + slides: [ + { + kind: 'stack', + id: 'slide-title', + gap: 26, + children: [ + { + kind: 'text', + id: 'slide-title-heading', + text: 'Paginated slides with real demo content', + textStyle: { fontSize: 42, fill: '#0f172a', lineHeight: 52 } + }, + { + kind: 'text', + id: 'slide-title-subheading', + text: 'Calendar Views and the Texta selection scene now live inside the slide deck as Vitrine blocks, not placeholders.', + textStyle: { fontSize: 21, fill: '#334155', lineHeight: 31 } + }, + { + kind: 'measured', + id: 'slide-title-showcase', + measureKey: 'slide-title-showcase', + content: slideShowcaseContent() + } + ] + }, + { + kind: 'stack', + id: 'slide-agenda', + gap: 22, + children: [ + { + kind: 'text', + id: 'slide-agenda-heading', + text: 'Mostly visual slides, with small rich-text asides', + textStyle: { fontSize: 36, fill: '#0f172a', lineHeight: 44 } + }, + { + kind: 'text', + id: 'slide-agenda-subheading', + text: 'The page model stays explicit while measured regions host calendar components, markdown-rendered texta, and selection-debug content.', + textStyle: { fontSize: 20, fill: '#475569', lineHeight: 30 } + }, + { + kind: 'measured', + id: 'slide-agenda-showcase', + measureKey: 'slide-agenda-showcase', + content: slidePlannerContent() + } + ] + }, + { + kind: 'stack', + id: 'slide-closing', + gap: 22, + children: [ + { + kind: 'text', + id: 'slide-closing-heading', + text: 'The slide deck is now a content host', + textStyle: { fontSize: 38, fill: '#0f172a', lineHeight: 48 } + }, + { + kind: 'text', + id: 'slide-closing-body', + text: 'That means presentation pages can mix existing demo scenes, formatted markdown, and attributed text without any separate rendering path.', + textStyle: { fontSize: 23, fill: '#334155', lineHeight: 34 } + }, + { + kind: 'measured', + id: 'slide-closing-showcase', + measureKey: 'slide-closing-showcase', + content: slideTypographyContent() + } + ] + } + ] +}; + +export const presentationResult = createPaginatedLayoutEngine(layoutMeasureDelegate).layout(documentPresentation); + +export const documentFlow: DemoDocument = { + kind: 'flow', + id: 'markdown-doc', + page: { + width: 612, + height: 792, + unit: 'pt', + margins: { + top: 54, + right: 54, + bottom: 54, + left: 54 + } + }, + background: { + kind: 'box', + id: 'doc-bg', + child: undefined + }, + header: { + kind: 'text', + id: 'doc-header', + text: 'Markdown + Texta inside paginated Vitrine pages', + textStyle: { + fontSize: 14, + lineHeight: 18, + fill: '#1d4ed8' + } + }, + footer: { + kind: 'text', + id: 'doc-footer', + text: 'Renderer-native content · semantic pagination · reusable page model', + textStyle: { + fontSize: 12, + lineHeight: 16, + fill: '#64748b' + } + }, + body: { + kind: 'stack', + id: 'doc-body', + gap: 18, + children: [ + { + kind: 'text', + id: 'doc-title', + text: 'Paginated pages with markdown and texta content', + textStyle: { + fontSize: 28, + lineHeight: 36, + fill: '#0f172a' + } + }, + paragraph( + 'doc-intro', + 'This flow demo now pulls content patterns from the markdown and texta demos: formatted headings, lists, code, and rich attributed text all appear as measured Vitrine content inside page fragments.' + ), + { + kind: 'measured', + id: 'doc-markdown-intro', + measureKey: 'doc-markdown-intro', + content: markdownSectionContent(valueMarkdownIntro, 310) + }, + paragraph( + 'doc-bridge', + 'The paginator still operates over semantic layout nodes, but those nodes now host renderer-native payloads instead of placeholder rectangles.' + ), + { + kind: 'measured', + id: 'doc-texta-callout', + measureKey: 'doc-texta-callout', + content: textaSectionContent('Texta excerpt', valueDocumentCallout, 208) + }, + paragraph( + 'doc-middle', + 'Lorem ipsum is enough to show the mechanics: spacing, emphasis, and decorative backgrounds survive because the content adapter hands the page preview ordinary Vitrine blocks.' + ), + { + kind: 'measured', + id: 'doc-markdown-chapter', + measureKey: 'doc-markdown-chapter', + content: markdownSectionContent(valueMarkdownChapter, 300) + }, + { + kind: 'pageBreak', + id: 'doc-break-1' + }, + { + kind: 'text', + id: 'doc-section-2-title', + text: 'Second chapter', + textStyle: { + fontSize: 22, + lineHeight: 30, + fill: '#0f172a' + } + }, + paragraph( + 'doc-second-intro', + 'The second page leans more heavily on texta-specific styling: run-level emphasis, color accents, and highlighted spans drawn through the same custom block type used elsewhere in the repo.' + ), + { + kind: 'measured', + id: 'doc-texta-summary', + measureKey: 'doc-texta-summary', + content: textaSectionContent('Styled summary', valueDocumentSummary, 190) + }, + { + kind: 'measured', + id: 'doc-markdown-closing', + measureKey: 'doc-markdown-closing', + content: markdownSectionContent(valueMarkdownClosing, 220) + }, + paragraph( + 'doc-outro', + 'The result is the core promise of the package: slides and documents can be composed from real Vitrine blocks while pagination remains a separate, reusable concern.' + ) + ] + } +}; + +export const flowResult = createPaginatedLayoutEngine(layoutMeasureDelegate).layout(documentFlow); diff --git a/packages/demo/package.json b/packages/demo/package.json index 56fb5ac..598faec 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -13,7 +13,9 @@ "dependencies": { "texta": "workspace:*", "vitrine": "workspace:*", - "vitrine-gui": "workspace:*" + "vitrine-gui": "workspace:*", + "vitrine-layout-pagination": "workspace:*", + "vitrine-layout-pagination-adapter": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/packages/demo/text-selection-debug-texta.html b/packages/demo/text-selection-debug-texta.html index 725c191..feb9633 100644 --- a/packages/demo/text-selection-debug-texta.html +++ b/packages/demo/text-selection-debug-texta.html @@ -88,8 +88,8 @@

Performance and Cache Counters

diff --git a/packages/demo/text-selection-debug.html b/packages/demo/text-selection-debug.html index 86c845b..837bb5b 100644 --- a/packages/demo/text-selection-debug.html +++ b/packages/demo/text-selection-debug.html @@ -4,45 +4,32 @@ - Vitrine - Text Selection Debug - + + Redirecting to Text Selection… + -
-

Vitrine Text Selection Debug

-

- Testing text selection with various text block configurations: different fonts, sizes, line spacing, and multi-line wrapping. -

-
-
-

Canvas with various text blocks for debugging. Check debug output below when clicking.

-
- -
-

Text Cache Counters

-
-
- Fonts (G/P/M) - 0 / 0 / 0 -
-
- Entries (G/P/M) - 0 / 0 / 0 -
-
-
- -
- Click on text blocks and check debug output here. - Coordinates will show canvas size, click position, and hit test results. -
-
- - - - +

Redirecting to Text Selection…

+ diff --git a/packages/demo/text-selection.html b/packages/demo/text-selection.html index 148d617..1610648 100644 --- a/packages/demo/text-selection.html +++ b/packages/demo/text-selection.html @@ -49,8 +49,8 @@

Vitrine Text Selection

diff --git a/packages/demo/vite.config.ts b/packages/demo/vite.config.ts index 29f4a1e..c88b618 100644 --- a/packages/demo/vite.config.ts +++ b/packages/demo/vite.config.ts @@ -23,6 +23,8 @@ export default defineConfig({ componentDemo: resolve(__dirname, 'component-demo.html'), texta: resolve(__dirname, 'texta.html'), markdown: resolve(__dirname, 'markdown.html'), + layoutPaginationPresentation: resolve(__dirname, 'layout-pagination-presentation.html'), + layoutPaginationFlow: resolve(__dirname, 'layout-pagination-flow.html'), tables: resolve(__dirname, 'tables.html'), textSelection: resolve(__dirname, 'text-selection.html'), textSelectionDebug: resolve(__dirname, 'text-selection-debug.html'), @@ -37,6 +39,8 @@ export default defineConfig({ 'texta/markdown': resolve(__dirname, '../texta/src/markdown.ts'), 'vitrine-tables': resolve(__dirname, '../tables/src/index.ts'), 'vitrine-tables-adapter': resolve(__dirname, '../tables-vitrine/src/index.ts'), + 'vitrine-layout-pagination': resolve(__dirname, '../layout-pagination/src/index.ts'), + 'vitrine-layout-pagination-adapter': resolve(__dirname, '../layout-pagination-vitrine/src/index.ts'), 'vitrine': resolve(__dirname, '../core/src/index.ts'), 'vitrine-gui': resolve(__dirname, '../gui/src/index.ts') } diff --git a/packages/layout-pagination-vitrine/package.json b/packages/layout-pagination-vitrine/package.json new file mode 100644 index 0000000..69e890c --- /dev/null +++ b/packages/layout-pagination-vitrine/package.json @@ -0,0 +1,31 @@ +{ + "name": "vitrine-layout-pagination-adapter", + "version": "0.1.0", + "description": "Vitrine adapter for vitrine-layout-pagination documents and paginated results", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "dependencies": { + "vitrine": "workspace:*", + "vitrine-layout-pagination": "workspace:*" + }, + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "license": "MIT", + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/layout-pagination-vitrine/src/index.ts b/packages/layout-pagination-vitrine/src/index.ts new file mode 100644 index 0000000..f06b77a --- /dev/null +++ b/packages/layout-pagination-vitrine/src/index.ts @@ -0,0 +1,1174 @@ +import { group, rectangle, text } from 'vitrine'; +import type { Block, GroupProps, VitrinePointerEvent } from 'vitrine'; +import { + normalizeLayoutInsets, + type BoxLayoutNode, + type LayoutAvailableSpace, + type LayoutDocument, + type LayoutFragment, + type LayoutIntrinsicSize, + type LayoutMeasureDelegate, + type LayoutMeasureRequest, + type LayoutMeasuredContent, + type LayoutNode, + type LayoutRect, + type MeasuredLayoutNode, + type PageArtifactKind, + type PaginatedLayoutResult, + type PaginatedPage, + type ResolvedPageSpec, + type StackLayoutNode, + type TableLayoutNode, + type TextLayoutNode +} from 'vitrine-layout-pagination'; + +export interface VitrineLayoutContentRenderContext { + nodeId?: TNodeId; + nodeKind: LayoutNode['kind']; + pageIndex: number; + frame: LayoutRect; + pageRect: LayoutRect; + scale: number; + renderData?: unknown; +} + +export interface VitrineLayoutLeafContent { + blocks?: Block[]; + renderBlocks?: (context: VitrineLayoutContentRenderContext) => Block[]; + intrinsicSize?: LayoutIntrinsicSize; + clip?: boolean; +} + +export type VitrineLayoutDocument = LayoutDocument< + TNodeId, + VitrineLayoutLeafContent, + VitrineLayoutLeafContent +>; + +type VitrineLayoutNode = LayoutNode< + TNodeId, + VitrineLayoutLeafContent, + VitrineLayoutLeafContent +>; + +type VitrineLeafLayoutNode = + | MeasuredLayoutNode> + | TableLayoutNode>; + +export interface VitrineLayoutMeasureRequest { + node: VitrineLeafLayoutNode; + content: VitrineLayoutLeafContent; + availableSpace: LayoutAvailableSpace; + page: ResolvedPageSpec; +} + +export interface CreateVitrineLayoutMeasureDelegateOptions { + measureMeasuredContent?: (request: VitrineLayoutMeasureRequest) => LayoutMeasuredContent | null; + measureTableContent?: (request: VitrineLayoutMeasureRequest) => LayoutMeasuredContent | null; + fallbackIntrinsicSize?: LayoutIntrinsicSize; +} + +export interface BuildVitrineBlocksFromPaginatedPageOptions { + x?: number; + y?: number; + scale?: number; + rootGroupProps?: GroupProps; + showPageShadow?: boolean; + showPageFrame?: boolean; + pageFill?: string; + pageStroke?: string; + pageStrokeWidth?: number; + pageCornerRadius?: number; + pageShadowFill?: string; + pageShadowOffsetX?: number; + pageShadowOffsetY?: number; + pageShadowCornerRadius?: number; + artifactFills?: Partial>; + defaultTextFill?: string; +} + +export interface BuildVitrineBlocksFromPaginatedLayoutOptions + extends Omit { + x?: number; + y?: number; + columns?: number; + pageGap?: number; + rootGroupProps?: GroupProps; +} + +export interface VitrinePreviewTheme { + canvasFill: string; + pageFill: string; + pageStroke: string; + shadowFill: string; + textFill: string; + mutedTextFill: string; + accentFill: string; + artifactFills: Partial>; +} + +export interface FlowPreviewOptions { + x?: number; + y?: number; + scale?: number; + pageGap?: number; + columns?: number; + showPageLabels?: boolean; + theme?: Partial; +} + +export interface PresentationPreviewOptions { + x?: number; + y?: number; + mainScale?: number; + thumbnailScale?: number; + thumbnailGap?: number; + currentPageIndex?: number; + theme?: Partial; +} + +interface PageFlipStateBase { + currentPageIndex: number; + dragStartXs?: number; + dragCommitted?: boolean; +} + +export interface PresentationPageFlipState extends PageFlipStateBase {} + +export interface FlowPageFlipState extends PageFlipStateBase {} + +export interface PresentationPageFlipWrapperOptions { + x?: number; + y?: number; + mainScale?: number; + thumbnailScale?: number; + thumbnailGap?: number; + dragThreshold?: number; + theme?: Partial; +} + +export interface FlowPageFlipWrapperOptions { + x?: number; + y?: number; + mainScale?: number; + thumbnailScale?: number; + thumbnailGap?: number; + dragThreshold?: number; + theme?: Partial; +} + +export const themeDefault: VitrinePreviewTheme = { + canvasFill: '#0f172a', + pageFill: '#ffffff', + pageStroke: '#cbd5e1', + shadowFill: 'rgba(15, 23, 42, 0.14)', + textFill: '#0f172a', + mutedTextFill: '#475569', + accentFill: '#2563eb', + artifactFills: { + background: 'rgba(59, 130, 246, 0.06)', + header: 'rgba(59, 130, 246, 0.08)', + footer: 'rgba(148, 163, 184, 0.10)', + foreground: 'rgba(14, 165, 233, 0.05)' + } +}; + +function mergePreviewTheme(theme?: Partial): VitrinePreviewTheme { + return { + ...themeDefault, + ...theme, + artifactFills: { + ...themeDefault.artifactFills, + ...theme?.artifactFills + } + }; +} + +function clampPageIndex(index: number, pageCount: number): number { + if (pageCount <= 0) { + return 0; + } + return Math.max(0, Math.min(index, pageCount - 1)); +} + +function setCurrentPageIndex( + state: PageFlipStateBase, + pageCount: number, + nextPageIndex: number +): void { + state.currentPageIndex = clampPageIndex(nextPageIndex, pageCount); +} + +function stepPage( + state: PageFlipStateBase, + pageCount: number, + delta: number +): void { + setCurrentPageIndex(state, pageCount, state.currentPageIndex + delta); +} + +function beginPageFlipDrag( + state: PageFlipStateBase, + event: VitrinePointerEvent +): void { + state.dragStartXs = event.xs; + state.dragCommitted = false; +} + +function resetPageFlipDrag(state: PageFlipStateBase): void { + state.dragStartXs = undefined; + state.dragCommitted = false; +} + +function handlePageFlipDrag( + state: PageFlipStateBase, + event: VitrinePointerEvent, + pageCount: number, + threshold: number +): void { + if (state.dragCommitted || state.dragStartXs === undefined || event.xs === undefined) { + return; + } + + const dxDrag = event.xs - state.dragStartXs; + if (dxDrag <= -threshold) { + stepPage(state, pageCount, 1); + state.dragCommitted = true; + } else if (dxDrag >= threshold) { + stepPage(state, pageCount, -1); + state.dragCommitted = true; + } +} + +function pageFlipOverlay( + x: number, + y: number, + width: number, + height: number, + state: PageFlipStateBase, + pageCount: number, + dragThreshold: number +): Block { + return rectangle({ + x, + y, + dx: width, + dy: height, + fill: 'rgba(0, 0, 0, 0)', + onClick: (event: VitrinePointerEvent) => { + if (event.xl === undefined) { + return; + } + if (event.xl < width * 0.35) { + stepPage(state, pageCount, -1); + } else if (event.xl > width * 0.65) { + stepPage(state, pageCount, 1); + } + }, + onPointerDown: (event: VitrinePointerEvent) => { + beginPageFlipDrag(state, event); + }, + onDrag: (event: VitrinePointerEvent) => { + handlePageFlipDrag(state, event, pageCount, dragThreshold); + }, + onPointerUp: () => { + resetPageFlipDrag(state); + } + }); +} + +function navButton( + x: number, + y: number, + width: number, + height: number, + label: string, + enabled: boolean, + onClick: () => void +): Block { + const fill = enabled ? '#ffffff' : 'rgba(255,255,255,0.45)'; + const stroke = enabled ? '#cbd5e1' : '#cbd5e1'; + const textFill = enabled ? '#0f172a' : '#94a3b8'; + return group({}, [ + rectangle({ + x, + y, + dx: width, + dy: height, + fill, + stroke, + strokeWidth: 1, + cornerRadius: 12, + onClick: enabled ? () => onClick() : undefined + }), + text({ + x: x + width / 2, + y: y + height / 2, + text: label, + fontSize: 14, + fill: textFill, + align: 'center', + baseline: 'middle' + }) + ]); +} + +function pageCounterLabel( + x: number, + y: number, + label: string, + theme: VitrinePreviewTheme +): Block { + return text({ + x, + y, + text: label, + fontSize: 13, + fill: theme.mutedTextFill, + baseline: 'top' + }); +} + +export function createPresentationPageFlipState( + currentPageIndex: number = 0 +): PresentationPageFlipState { + return { + currentPageIndex, + dragCommitted: false + }; +} + +export function createFlowPageFlipState( + currentPageIndex: number = 0 +): FlowPageFlipState { + return { + currentPageIndex, + dragCommitted: false + }; +} + +function estimateTextHeight(node: TextLayoutNode, availableWidth: number): number { + const fontSize = typeof node.textStyle?.fontSize === 'number' ? node.textStyle.fontSize : 16; + const lineHeight = typeof node.textStyle?.lineHeight === 'number' ? node.textStyle.lineHeight : fontSize * 1.4; + const cCharsPerLine = Math.max(1, Math.floor(Math.max(availableWidth, fontSize) / Math.max(1, fontSize * 0.58))); + return node.text + .split('\n') + .reduce((count: number, line: string) => count + Math.max(1, Math.ceil(line.length / cCharsPerLine)), 0) * lineHeight; +} + +function resolveLeafContent( + node: VitrineLayoutNode +): VitrineLayoutLeafContent | null { + switch (node.kind) { + case 'measured': + case 'table': + return node.content; + default: + return null; + } +} + +function estimateNodeHeight( + node: VitrineLayoutNode, + availableWidth: number +): number { + switch (node.kind) { + case 'text': + return estimateTextHeight(node, availableWidth); + case 'spacer': + return node.size; + case 'box': { + const insets = normalizeLayoutInsets(node.padding); + const childHeight = node.child + ? estimateNodeHeight(node.child, Math.max(0, availableWidth - insets.left - insets.right)) + : 0; + const naturalHeight = childHeight + insets.top + insets.bottom; + let nextHeight = node.height ?? naturalHeight; + if (node.minHeight !== undefined) { + nextHeight = Math.max(nextHeight, node.minHeight); + } + if (node.maxHeight !== undefined) { + nextHeight = Math.min(nextHeight, node.maxHeight); + } + return nextHeight; + } + case 'stack': { + const insets = normalizeLayoutInsets(node.padding); + const innerWidth = Math.max(0, availableWidth - insets.left - insets.right); + let height = insets.top + insets.bottom; + node.children.forEach((child: VitrineLayoutNode, index: number) => { + height += estimateNodeHeight(child, innerWidth); + if (index < node.children.length - 1) { + height += node.gap ?? 0; + } + }); + return height; + } + case 'measured': + case 'table': + return resolveLeafContent(node)?.intrinsicSize?.minHeight ?? 0; + case 'pageBreak': + return 0; + case 'fixed': + return estimateNodeHeight(node.child, availableWidth); + } + + return 0; +} + +function findNodeById( + node: VitrineLayoutNode | undefined, + id: string +): VitrineLayoutNode | null { + if (!node) { + return null; + } + if (node.id === id) { + return node; + } + switch (node.kind) { + case 'stack': + for (const child of node.children) { + const found = findNodeById(child, id); + if (found) { + return found; + } + } + return null; + case 'box': + return node.child ? findNodeById(node.child, id) : null; + case 'fixed': + return findNodeById(node.child, id); + default: + return null; + } +} + +function lookupNode( + document: VitrineLayoutDocument, + fragment: LayoutFragment, + pageIndex: number +): VitrineLayoutNode | null { + if (fragment.artifactKind) { + switch (fragment.artifactKind) { + case 'background': + return document.background ?? null; + case 'foreground': + return document.foreground ?? null; + case 'header': + return document.kind === 'flow' ? document.header ?? null : null; + case 'footer': + return document.kind === 'flow' ? document.footer ?? null : null; + } + } + + if (document.kind === 'presentation') { + return document.slides[pageIndex] ?? null; + } + + if (!fragment.nodeId) { + return null; + } + + return findNodeById(document.body, fragment.nodeId) + ?? findNodeById(document.header, fragment.nodeId) + ?? findNodeById(document.footer, fragment.nodeId) + ?? findNodeById(document.background, fragment.nodeId) + ?? findNodeById(document.foreground, fragment.nodeId); +} + +function buildTextBlock( + node: TextLayoutNode, + rect: LayoutRect, + defaultTextFill: string +): Block { + return text({ + x: rect.x, + y: rect.y, + text: node.text, + dx: rect.width, + font: node.textStyle?.font, + fontSize: node.textStyle?.fontSize, + dyLineHeight: node.textStyle?.lineHeight, + fill: node.textStyle?.fill ?? defaultTextFill, + baseline: 'top' + }); +} + +function renderLeafNode( + node: VitrineLeafLayoutNode, + rect: LayoutRect, + pageIndex: number, + scale: number, + renderData?: unknown +): Block[] { + const content = resolveLeafContent(node); + if (!content) { + return []; + } + + const rgblChildren = content.renderBlocks + ? content.renderBlocks({ + nodeId: node.id, + nodeKind: node.kind, + pageIndex, + frame: { + x: 0, + y: 0, + width: rect.width, + height: rect.height + }, + pageRect: rect, + scale, + renderData + }) + : (content.blocks ?? []); + + return [ + group({ + x: rect.x, + y: rect.y, + clip: content.clip ?? false, + dx: rect.width, + dy: rect.height + }, rgblChildren) + ]; +} + +function renderBoxIntoRect( + node: BoxLayoutNode, + rect: LayoutRect, + pageIndex: number, + scale: number, + defaultTextFill: string +): Block[] { + if (!node.child) { + return []; + } + const insets = normalizeLayoutInsets(node.padding); + return renderNodeIntoRect(node.child, { + x: rect.x + insets.left, + y: rect.y + insets.top, + width: Math.max(0, rect.width - insets.left - insets.right), + height: Math.max(0, rect.height - insets.top - insets.bottom) + }, pageIndex, scale, defaultTextFill); +} + +function renderStackIntoRect( + node: StackLayoutNode, + rect: LayoutRect, + pageIndex: number, + scale: number, + defaultTextFill: string +): Block[] { + const insets = normalizeLayoutInsets(node.padding); + const innerRect: LayoutRect = { + x: rect.x + insets.left, + y: rect.y + insets.top, + width: Math.max(0, rect.width - insets.left - insets.right), + height: Math.max(0, rect.height - insets.top - insets.bottom) + }; + let yCursor = innerRect.y; + const rgblBlocks: Block[] = []; + + node.children.forEach((child: VitrineLayoutNode, index: number) => { + const dyChild = estimateNodeHeight(child, innerRect.width); + const childRect: LayoutRect = { + x: innerRect.x, + y: yCursor, + width: innerRect.width, + height: dyChild + }; + rgblBlocks.push(...renderNodeIntoRect(child, childRect, pageIndex, scale, defaultTextFill)); + yCursor += dyChild; + if (index < node.children.length - 1) { + yCursor += node.gap ?? 0; + } + }); + + return rgblBlocks; +} + +function renderNodeIntoRect( + node: VitrineLayoutNode, + rect: LayoutRect, + pageIndex: number, + scale: number, + defaultTextFill: string, + renderData?: unknown +): Block[] { + switch (node.kind) { + case 'text': + return [buildTextBlock(node, rect, defaultTextFill)]; + case 'spacer': + case 'pageBreak': + return []; + case 'stack': + return renderStackIntoRect(node, rect, pageIndex, scale, defaultTextFill); + case 'box': + return renderBoxIntoRect(node, rect, pageIndex, scale, defaultTextFill); + case 'measured': + case 'table': + return renderLeafNode(node, rect, pageIndex, scale, renderData); + case 'fixed': + return renderNodeIntoRect(node.child, rect, pageIndex, scale, defaultTextFill, renderData); + } + + return []; +} + +function resolvePreferredMeasuredContent( + intrinsicSize: LayoutIntrinsicSize, + availableSpace: LayoutAvailableSpace +): LayoutMeasuredContent { + const dxPreferred = Math.max( + intrinsicSize.minWidth, + Math.min(availableSpace.width, intrinsicSize.maxWidth) + ); + + return { + intrinsicSize, + preferredRect: { + x: 0, + y: 0, + width: Number.isFinite(dxPreferred) ? dxPreferred : availableSpace.width, + height: intrinsicSize.minHeight + } + }; +} + +export function createVitrineLayoutMeasureDelegate( + options: CreateVitrineLayoutMeasureDelegateOptions = {} +): LayoutMeasureDelegate { + const fallbackIntrinsicSize = options.fallbackIntrinsicSize ?? { + minWidth: 0, + maxWidth: Number.POSITIVE_INFINITY, + minHeight: 0 + }; + + return { + measure(request: LayoutMeasureRequest): LayoutMeasuredContent | null { + if (request.node.kind !== 'measured' && request.node.kind !== 'table') { + return null; + } + + const content = request.node.content as VitrineLayoutLeafContent; + const rsRequest: VitrineLayoutMeasureRequest = { + node: request.node as VitrineLeafLayoutNode, + content, + availableSpace: request.availableSpace, + page: request.page + }; + + const measured = request.node.kind === 'measured' + ? options.measureMeasuredContent?.(rsRequest) + : options.measureTableContent?.(rsRequest); + if (measured) { + return measured; + } + + return resolvePreferredMeasuredContent(content.intrinsicSize ?? fallbackIntrinsicSize, request.availableSpace); + } + }; +} + +export function buildVitrineBlocksFromPaginatedPage( + document: VitrineLayoutDocument, + page: PaginatedPage, + options: BuildVitrineBlocksFromPaginatedPageOptions = {} +): Block { + const x = options.x ?? 0; + const y = options.y ?? 0; + const scale = options.scale ?? 1; + const rgblChildren: Block[] = []; + + if (options.showPageShadow) { + rgblChildren.push(rectangle({ + x: options.pageShadowOffsetX ?? 8, + y: options.pageShadowOffsetY ?? 10, + dx: page.page.width, + dy: page.page.height, + fill: options.pageShadowFill ?? themeDefault.shadowFill, + cornerRadius: options.pageShadowCornerRadius ?? 18 + })); + } + + if (options.showPageFrame) { + rgblChildren.push(rectangle({ + x: 0, + y: 0, + dx: page.page.width, + dy: page.page.height, + fill: options.pageFill ?? themeDefault.pageFill, + stroke: options.pageStroke ?? themeDefault.pageStroke, + strokeWidth: options.pageStrokeWidth ?? 1, + cornerRadius: options.pageCornerRadius ?? 18 + })); + } + + for (const fragment of page.fragments) { + if (fragment.artifactKind) { + const artifactFill = options.artifactFills?.[fragment.artifactKind]; + if (artifactFill !== undefined) { + rgblChildren.push(rectangle({ + x: fragment.rect.x, + y: fragment.rect.y, + dx: fragment.rect.width, + dy: fragment.rect.height, + fill: artifactFill + })); + } + } + + const node = lookupNode(document, fragment, page.index); + if (!node) { + continue; + } + + rgblChildren.push( + ...renderNodeIntoRect( + node, + fragment.rect, + page.index, + scale, + options.defaultTextFill ?? themeDefault.textFill, + fragment.renderData + ) + ); + } + + return group({ + ...(options.rootGroupProps ?? {}), + x, + y, + scaleX: scale, + scaleY: scale + }, rgblChildren); +} + +export function buildVitrineBlocksFromPaginatedLayout( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + options: BuildVitrineBlocksFromPaginatedLayoutOptions = {} +): Block { + const xStart = options.x ?? 0; + const yStart = options.y ?? 0; + const scale = options.scale ?? 1; + const gap = options.pageGap ?? 24; + const cColumns = Math.max(1, options.columns ?? 1); + const dxPageScaled = result.page.width * scale; + const dyPageScaled = result.page.height * scale; + const dxStride = dxPageScaled + gap; + const dyStride = dyPageScaled + gap; + + const rgblChildren = result.pages.map((page: PaginatedPage, index: number) => { + const col = index % cColumns; + const row = Math.floor(index / cColumns); + return buildVitrineBlocksFromPaginatedPage(document, page, { + ...options, + x: xStart + col * dxStride, + y: yStart + row * dyStride, + scale + }); + }); + + return group(options.rootGroupProps ?? {}, rgblChildren); +} + +export function buildFlowDocumentPreview( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + options: FlowPreviewOptions = {} +): Block { + const theme = mergePreviewTheme(options.theme); + const xStart = options.x ?? 40; + const yStart = options.y ?? 60; + const scale = options.scale ?? 0.34; + const gap = options.pageGap ?? 28; + const cColumns = Math.max(1, options.columns ?? 2); + const dxPageScaled = result.page.width * scale; + const dyPageScaled = result.page.height * scale; + const dxStride = dxPageScaled + gap; + const dyStride = dyPageScaled + gap; + const rgblChildren: Block[] = [ + rectangle({ + dx: 1280, + dy: 900, + fill: theme.canvasFill + }), + text({ + x: 44, + y: 26, + text: `Flow document preview · ${result.pages.length} pages`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 44, + y: 58, + text: 'Semantic pagination with repeated page furniture and page breaks.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }) + ]; + + result.pages.forEach((page: PaginatedPage, index: number) => { + const col = index % cColumns; + const row = Math.floor(index / cColumns); + const xPage = xStart + col * dxStride; + const yPage = yStart + row * dyStride; + rgblChildren.push(buildVitrineBlocksFromPaginatedPage(document, page, { + x: xPage, + y: yPage, + scale, + showPageShadow: true, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: theme.pageStroke, + pageShadowFill: theme.shadowFill, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + })); + if (options.showPageLabels !== false) { + rgblChildren.push(text({ + x: xPage + 14, + y: yPage + 14, + text: `Page ${index + 1}`, + fontSize: 11, + fill: theme.mutedTextFill, + baseline: 'top' + })); + } + }); + + return group({}, rgblChildren); +} + +export function buildPresentationPreview( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + options: PresentationPreviewOptions = {} +): Block { + const theme = mergePreviewTheme(options.theme); + const iCurrent = Math.max(0, Math.min(options.currentPageIndex ?? 0, result.pages.length - 1)); + const mainScale = options.mainScale ?? 0.5; + const thumbScale = options.thumbnailScale ?? 0.14; + const thumbGap = options.thumbnailGap ?? 18; + const xStart = options.x ?? 40; + const yStart = options.y ?? 64; + const pageCurrent = result.pages[iCurrent]!; + const dyMain = result.page.height * mainScale; + const rgblChildren: Block[] = [ + rectangle({ + dx: 1280, + dy: 860, + fill: '#020617' + }), + text({ + x: 44, + y: 26, + text: `Presentation preview · slide ${iCurrent + 1} of ${result.pages.length}`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 44, + y: 58, + text: 'Explicit slide pages with fixed 16:9 geometry for browser presentations.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }), + buildVitrineBlocksFromPaginatedPage(document, pageCurrent, { + x: xStart, + y: yStart, + scale: mainScale, + showPageShadow: true, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: theme.pageStroke, + pageShadowFill: theme.shadowFill, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + }) + ]; + + result.pages.forEach((page: PaginatedPage, index: number) => { + const thumbX = xStart + index * (result.page.width * thumbScale + thumbGap); + const thumbY = yStart + dyMain + 34; + rgblChildren.push(buildVitrineBlocksFromPaginatedPage(document, page, { + x: thumbX, + y: thumbY, + scale: thumbScale, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: index === iCurrent ? theme.accentFill : theme.pageStroke, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + })); + rgblChildren.push(text({ + x: thumbX + 8, + y: thumbY + 8, + text: String(index + 1), + fontSize: 10, + fill: index === iCurrent ? theme.accentFill : theme.mutedTextFill, + baseline: 'top' + })); + }); + + return group({}, rgblChildren); +} + +export function buildPresentationPageFlipWrapper( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + state: PresentationPageFlipState, + options: PresentationPageFlipWrapperOptions = {} +): Block { + const theme = mergePreviewTheme(options.theme); + const pageCount = result.pages.length; + const iCurrent = clampPageIndex(state.currentPageIndex, pageCount); + state.currentPageIndex = iCurrent; + + const xStart = options.x ?? 40; + const yStart = options.y ?? 64; + const mainScale = options.mainScale ?? 0.5; + const thumbScale = options.thumbnailScale ?? 0.14; + const thumbGap = options.thumbnailGap ?? 18; + const dragThreshold = options.dragThreshold ?? 72; + const pageCurrent = result.pages[iCurrent]!; + const dxMain = result.page.width * mainScale; + const dyMain = result.page.height * mainScale; + const rgblChildren: Block[] = [ + rectangle({ + dx: 1280, + dy: 860, + fill: '#020617' + }), + text({ + x: 44, + y: 26, + text: `Presentation flip view · slide ${iCurrent + 1} of ${pageCount}`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 44, + y: 58, + text: 'Click, drag, or use the thumbnail strip to flip between explicit slide pages.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }), + buildVitrineBlocksFromPaginatedPage(document, pageCurrent, { + x: xStart, + y: yStart, + scale: mainScale, + showPageShadow: true, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: theme.pageStroke, + pageShadowFill: theme.shadowFill, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + }), + pageFlipOverlay( + xStart, + yStart, + dxMain, + dyMain, + state, + pageCount, + dragThreshold + ), + navButton( + xStart, + yStart + dyMain + 18, + 96, + 38, + 'Prev', + iCurrent > 0, + () => stepPage(state, pageCount, -1) + ), + navButton( + xStart + dxMain - 96, + yStart + dyMain + 18, + 96, + 38, + 'Next', + iCurrent < pageCount - 1, + () => stepPage(state, pageCount, 1) + ), + pageCounterLabel( + xStart + dxMain / 2 - 46, + yStart + dyMain + 28, + `Slide ${iCurrent + 1} / ${pageCount}`, + theme + ) + ]; + + result.pages.forEach((page: PaginatedPage, index: number) => { + const thumbX = xStart + index * (result.page.width * thumbScale + thumbGap); + const thumbY = yStart + dyMain + 74; + rgblChildren.push(buildVitrineBlocksFromPaginatedPage(document, page, { + x: thumbX, + y: thumbY, + scale: thumbScale, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: index === iCurrent ? theme.accentFill : theme.pageStroke, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + })); + rgblChildren.push(rectangle({ + x: thumbX, + y: thumbY, + dx: result.page.width * thumbScale, + dy: result.page.height * thumbScale, + fill: 'rgba(0, 0, 0, 0)', + onClick: () => { + setCurrentPageIndex(state, pageCount, index); + } + })); + rgblChildren.push(text({ + x: thumbX + 8, + y: thumbY + 8, + text: String(index + 1), + fontSize: 10, + fill: index === iCurrent ? theme.accentFill : theme.mutedTextFill, + baseline: 'top' + })); + }); + + return group({}, rgblChildren); +} + +export function buildFlowPageFlipWrapper( + document: VitrineLayoutDocument, + result: PaginatedLayoutResult, + state: FlowPageFlipState, + options: FlowPageFlipWrapperOptions = {} +): Block { + const theme = mergePreviewTheme(options.theme); + const pageCount = result.pages.length; + const iCurrent = clampPageIndex(state.currentPageIndex, pageCount); + state.currentPageIndex = iCurrent; + + const xStart = options.x ?? 92; + const yStart = options.y ?? 92; + const mainScale = options.mainScale ?? 1.02; + const thumbScale = options.thumbnailScale ?? 0.16; + const thumbGap = options.thumbnailGap ?? 20; + const dragThreshold = options.dragThreshold ?? 58; + const pageCurrent = result.pages[iCurrent]!; + const dxMain = result.page.width * mainScale; + const dyMain = result.page.height * mainScale; + const thumbStride = result.page.width * thumbScale + thumbGap; + const thumbStartX = xStart + Math.max(0, (dxMain - ((Math.min(pageCount, 5) * thumbStride) - thumbGap)) / 2); + const iThumbStart = Math.max(0, Math.min(iCurrent - 2, Math.max(0, pageCount - 5))); + const iThumbEnd = Math.min(pageCount, iThumbStart + 5); + + const rgblChildren: Block[] = [ + rectangle({ + dx: 1600, + dy: 1120, + fill: theme.canvasFill + }), + text({ + x: 52, + y: 28, + text: `Flow document flip view · page ${iCurrent + 1} of ${pageCount}`, + fontSize: 22, + fill: '#e2e8f0', + baseline: 'top' + }), + text({ + x: 52, + y: 60, + text: 'Document pages render one at a time here; drag or click to turn pages while keeping paginated content intact.', + fontSize: 13, + fill: '#94a3b8', + baseline: 'top' + }), + buildVitrineBlocksFromPaginatedPage(document, pageCurrent, { + x: xStart, + y: yStart, + scale: mainScale, + showPageShadow: true, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: theme.pageStroke, + pageShadowFill: theme.shadowFill, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill + }), + pageFlipOverlay( + xStart, + yStart, + dxMain, + dyMain, + state, + pageCount, + dragThreshold + ), + navButton( + xStart, + yStart + dyMain + 18, + 110, + 40, + 'Previous', + iCurrent > 0, + () => stepPage(state, pageCount, -1) + ), + navButton( + xStart + dxMain - 110, + yStart + dyMain + 18, + 110, + 40, + 'Next', + iCurrent < pageCount - 1, + () => stepPage(state, pageCount, 1) + ), + pageCounterLabel( + xStart + dxMain / 2 - 42, + yStart + dyMain + 30, + `Page ${iCurrent + 1} / ${pageCount}`, + theme + ) + ]; + + for (let iPage = iThumbStart; iPage < iThumbEnd; iPage += 1) { + const page = result.pages[iPage]!; + const xThumb = thumbStartX + (iPage - iThumbStart) * thumbStride; + const yThumb = yStart + dyMain + 84; + rgblChildren.push(buildVitrineBlocksFromPaginatedPage(document, page, { + x: xThumb, + y: yThumb, + scale: thumbScale, + showPageFrame: true, + pageFill: theme.pageFill, + pageStroke: iPage === iCurrent ? theme.accentFill : theme.pageStroke, + artifactFills: theme.artifactFills, + defaultTextFill: theme.textFill, + rootGroupProps: { + opacity: iPage === iCurrent ? 1 : 0.82 + } + })); + rgblChildren.push(rectangle({ + x: xThumb, + y: yThumb, + dx: result.page.width * thumbScale, + dy: result.page.height * thumbScale, + fill: 'rgba(0, 0, 0, 0)', + onClick: () => { + setCurrentPageIndex(state, pageCount, iPage); + } + })); + } + + return group({}, rgblChildren); +} diff --git a/packages/layout-pagination-vitrine/tsconfig.json b/packages/layout-pagination-vitrine/tsconfig.json new file mode 100644 index 0000000..f543bbd --- /dev/null +++ b/packages/layout-pagination-vitrine/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/layout-pagination/README.md b/packages/layout-pagination/README.md new file mode 100644 index 0000000..75b2c83 --- /dev/null +++ b/packages/layout-pagination/README.md @@ -0,0 +1,278 @@ +# Vitrine Layout & Pagination + +`vitrine-layout-pagination` is a renderer-agnostic package for describing document-like layout trees and turning them into paginated results. + +It exists to solve two related problems: + +1. **Presentation pages** — explicit slide/page sequences with fixed geometry or aspect ratio for browser previews. +2. **Flow documents** — semantic document content that should paginate across pages instead of being sliced arbitrarily by viewport. + +The package does **not** render to canvas or PDF directly. Instead, it produces a paginated model that other layers can preview or export. + +## Package architecture + +The code is split into three model files plus an engine: + +| File | Responsibility | +| --- | --- | +| `src/common.ts` | Shared node types, geometry/inset types, break policies, and layout-node typing helpers | +| `src/flow.ts` | Top-level document modes: flow documents and presentation documents | +| `src/pagination.ts` | Page specs, resolved page geometry, fragments, pages, diagnostics, and pagination results | +| `src/measure.ts` | Measurement contracts for custom/measured content | +| `src/engine.ts` | Validation, document preparation, and the current MVP pagination/layout algorithm | + +## Document modes + +### 1. Presentation documents + +Presentation documents are explicit page sequences: + +- each slide is already a page +- page geometry is fixed +- preview scaling is a display concern, not a layout concern + +Use this mode for browser-authored slide decks or other page-by-page experiences. + +### 2. Flow documents + +Flow documents are semantic content trees: + +- content is laid out into a page body region +- pages may repeat background/header/footer/foreground artifacts +- the engine decides where page breaks occur + +Use this mode for markdown-like or document-like content that should paginate meaningfully. + +## Layout node model + +The node typing pattern mirrors core Vitrine block typing: + +1. `LayoutNodeType` is the discriminant enum +2. `LayoutNodePropsByType` maps each node kind to its prop shape +3. `LayoutNodeForType` produces one concrete node shape +4. `LayoutNode` is the mapped discriminated union over all node types + +This gives the same benefits as core `BlockType` / `BlockPropsByType`: + +- exhaustive switching on `kind` +- precise per-node props +- strongly typed `LayoutNodeOfType` +- a consistent mental model across packages + +Core node kinds currently supported: + +- `stack` +- `box` +- `spacer` +- `pageBreak` +- `text` +- `table` +- `measured` +- `fixed` + +## Core concepts + +### Page spec + +`PageSpec` defines authored page geometry: + +- `width` +- `height` +- `unit` +- optional `margins` + +`resolvePageSpec(...)` turns that into a `ResolvedPageSpec` with an explicit `contentBox`. + +### Fragments + +Pagination output is fragment-based: + +- a fragment belongs to a page +- a fragment has a `rect` +- a fragment may be associated with a `nodeId` +- a fragment may represent a repeated page artifact (`header`, `footer`, etc.) + +This fragment model is the bridge between semantic layout and future preview/export adapters. + +### Measurement + +Measured content is delegated through `LayoutMeasureDelegate`: + +- the engine supplies available width/height and current page geometry +- the delegate returns intrinsic size and optional render data + +This keeps the package renderer-agnostic while still allowing text, tables, and custom content to participate in layout. + +For Vitrine rendering, use the companion adapter package: + +```ts +import { + buildVitrineBlocksFromPaginatedLayout, + createVitrineLayoutMeasureDelegate +} from 'vitrine-layout-pagination-adapter'; +``` + +That adapter lets `measured` and `table` nodes carry intrinsic size plus Vitrine block content while this package remains layout-only. + +## Current engine behavior + +The current `PaginatedLayoutEngine` is intentionally an MVP. + +### Preparation phase + +`prepareLayoutDocument(...)`: + +1. validates the document +2. resolves page geometry +3. normalizes document shape into a prepared form used by layout + +### Presentation path + +For `kind: 'presentation'`: + +1. each slide becomes exactly one page +2. page geometry is copied from the resolved page spec +3. optional background/foreground artifacts are injected per page + +This path is simple because page boundaries are explicit in the authored model. + +### Flow path + +For `kind: 'flow'`: + +1. the engine measures optional header and footer nodes +2. it reserves those heights from the page content box to form the page `bodyBox` +3. it traverses the body tree vertically +4. it emits fragments into pages +5. it inserts repeated background/header/footer/foreground artifacts on each page + +## Current core algorithms + +### 1. Text height estimation + +The engine currently estimates text height using: + +- `fontSize` +- `lineHeight` +- a simple character-per-line heuristic based on available width + +This is intentionally lightweight. It is good enough for the current MVP and can later be replaced or augmented by a richer text measurement integration. + +### 2. Vertical stack layout + +`stack` is the main flow primitive today: + +1. apply stack padding +2. lay out children top-to-bottom +3. insert `gap` between children +4. accumulate child heights to advance the vertical cursor + +This is the main algorithm for markdown-like flow content right now. + +### 3. Box sizing + +`box` computes height as: + +1. child height +2. plus top/bottom padding +3. then clamped by optional `minHeight` / `maxHeight` +4. or overridden by explicit `height` + +In the current engine, `box` behaves like a contained vertical block in the flow. + +### 4. Page breaking + +Current page breaking is deliberately simple: + +1. compute the next node’s total estimated height +2. compare it with remaining space in the current page `bodyBox` +3. if it does not fit, advance to the next page +4. if a node exceeds the full body height of a page, emit a diagnostic +5. explicit `pageBreak` nodes always force a new page +6. `breakBefore: 'page'` and `breakAfter: 'page'` are respected + +This gives deterministic pagination without yet implementing advanced fragmentation. + +### 5. Repeated page artifacts + +Flow documents may define: + +- `background` +- `header` +- `footer` +- `foreground` + +The engine injects these per page in two phases: + +1. start-of-page artifacts (`background`, `header`) +2. end-of-page artifacts (`footer`, `foreground`) + +That preserves a sensible layering order relative to body fragments. + +## Diagnostics + +The engine emits `PaginationDiagnostic` entries for layout conditions that need attention, such as: + +- missing measurement delegates for measured/table nodes +- nodes taller than a page body region +- other pagination anomalies + +Diagnostics are part of the public result so adapters and applications can surface or inspect them. + +## Current limitations + +This is still an MVP engine. It does **not** yet provide: + +- rich inline/text fragmentation +- widow/orphan control +- keep-with-next / avoid-break-inside enforcement beyond type-level support +- table-specific pagination behavior +- PDF output +- semantic markdown parsing or markdown-to-layout conversion + +## Example + +```ts +import { + createPaginatedLayoutEngine, + type LayoutDocument +} from 'vitrine-layout-pagination'; + +const document: LayoutDocument<'title' | 'body'> = { + kind: 'flow', + id: 'doc-1', + page: { + width: 612, + height: 792, + unit: 'pt' + }, + body: { + kind: 'stack', + gap: 16, + children: [ + { + kind: 'text', + id: 'title', + text: 'Hello' + }, + { + kind: 'text', + id: 'body', + text: 'Paginated content' + } + ] + } +}; + +const engine = createPaginatedLayoutEngine(); +const result = engine.layout(document); +``` + +## Intended next steps + +Likely next areas of work: + +1. richer flow fragmentation +2. stronger measurement integration for text/texta/tables +3. semantic markdown/document builders +4. PDF export integration based on the paginated result diff --git a/packages/layout-pagination/package.json b/packages/layout-pagination/package.json new file mode 100644 index 0000000..44fff65 --- /dev/null +++ b/packages/layout-pagination/package.json @@ -0,0 +1,29 @@ +{ + "name": "vitrine-layout-pagination", + "version": "0.1.0", + "description": "Renderer-agnostic layout and pagination contracts for Vitrine", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "license": "MIT", + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.0" + } +} diff --git a/packages/layout-pagination/src/common.ts b/packages/layout-pagination/src/common.ts new file mode 100644 index 0000000..75dd9bd --- /dev/null +++ b/packages/layout-pagination/src/common.ts @@ -0,0 +1,217 @@ +export interface LayoutInsets { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface LayoutSize { + width: number; + height: number; +} + +export interface LayoutRect extends LayoutSize { + x: number; + y: number; +} + +export interface LayoutTextStyle { + font?: string; + fontSize?: number; + lineHeight?: number; + fill?: string; +} + +export type LayoutContent = object; + +export type BreakBeforePolicy = 'auto' | 'page'; +export type BreakAfterPolicy = 'auto' | 'page'; +export type BreakInsidePolicy = 'auto' | 'avoid'; +export type PageArtifactKind = 'header' | 'footer' | 'background' | 'foreground'; + +export enum LayoutNodeType { + Stack = 'stack', + Box = 'box', + Spacer = 'spacer', + PageBreak = 'pageBreak', + Text = 'text', + Table = 'table', + Measured = 'measured', + Fixed = 'fixed' +} + +export interface BaseLayoutNodeProps { + id?: TNodeId; + breakBefore?: BreakBeforePolicy; + breakAfter?: BreakAfterPolicy; + breakInside?: BreakInsidePolicy; + keepWithNext?: boolean; +} + +export interface StackLayoutNodeProps< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> extends BaseLayoutNodeProps { + gap?: number; + padding?: Partial; + children: LayoutNode[]; +} + +export interface BoxLayoutNodeProps< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> extends BaseLayoutNodeProps { + padding?: Partial; + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + width?: number; + height?: number; + child?: LayoutNode; +} + +export interface SpacerLayoutNodeProps extends BaseLayoutNodeProps { + size: number; +} + +export interface PageBreakLayoutNodeProps extends BaseLayoutNodeProps { + reason?: string; +} + +export interface TextLayoutNodeProps< + TNodeId extends string = string, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> extends BaseLayoutNodeProps { + text: string; + textStyle?: TTextStyle; +} + +export interface TableLayoutNodeProps< + TNodeId extends string = string, + TTableContent = LayoutContent +> extends BaseLayoutNodeProps { + tableId: string; + content: TTableContent; +} + +export interface MeasuredLayoutNodeProps< + TNodeId extends string = string, + TMeasuredContent = LayoutContent +> extends BaseLayoutNodeProps { + measureKey: string; + content: TMeasuredContent; +} + +export interface FixedLayoutNodeProps< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> extends BaseLayoutNodeProps { + artifactKind: PageArtifactKind; + child: LayoutNode; +} + +export type LayoutNodePropsByType< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = { + [LayoutNodeType.Stack]: StackLayoutNodeProps; + [LayoutNodeType.Box]: BoxLayoutNodeProps; + [LayoutNodeType.Spacer]: SpacerLayoutNodeProps; + [LayoutNodeType.PageBreak]: PageBreakLayoutNodeProps; + [LayoutNodeType.Text]: TextLayoutNodeProps; + [LayoutNodeType.Table]: TableLayoutNodeProps; + [LayoutNodeType.Measured]: MeasuredLayoutNodeProps; + [LayoutNodeType.Fixed]: FixedLayoutNodeProps; +}; + +export type LayoutNodeForType< + T extends LayoutNodeType, + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = { + kind: T; +} & LayoutNodePropsByType[T]; + +export type LayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = + { + [T in LayoutNodeType]: LayoutNodeForType; + }[LayoutNodeType]; + +export type LayoutNodeOfType< + T extends LayoutNodeType, + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = Extract, { kind: T }>; + +export type StackLayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> = LayoutNodeForType; + +export type BoxLayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> = LayoutNodeForType; + +export type SpacerLayoutNode = + LayoutNodeForType; + +export type PageBreakLayoutNode = + LayoutNodeForType; + +export type TextLayoutNode< + TNodeId extends string = string, + TTextStyle extends LayoutTextStyle = LayoutTextStyle +> = LayoutNodeForType; + +export type TableLayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent +> = LayoutNodeForType; + +export type MeasuredLayoutNode< + TNodeId extends string = string, + TMeasuredContent = LayoutContent +> = LayoutNodeForType; + +export type FixedLayoutNode< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> = LayoutNodeForType; + +const ZERO_INSETS: LayoutInsets = { + top: 0, + right: 0, + bottom: 0, + left: 0 +}; + +export function normalizeLayoutInsets( + insets?: Partial, + defaults: LayoutInsets = ZERO_INSETS +): LayoutInsets { + return { + top: insets?.top ?? defaults.top, + right: insets?.right ?? defaults.right, + bottom: insets?.bottom ?? defaults.bottom, + left: insets?.left ?? defaults.left + }; +} diff --git a/packages/layout-pagination/src/engine.ts b/packages/layout-pagination/src/engine.ts new file mode 100644 index 0000000..3c4398b --- /dev/null +++ b/packages/layout-pagination/src/engine.ts @@ -0,0 +1,544 @@ +import type { + LayoutTextStyle, + LayoutNode, + LayoutRect, + PageArtifactKind +} from './common.ts'; +import { normalizeLayoutInsets } from './common.ts'; +import type { + FlowLayoutDocument, + LayoutDocument, + PresentationLayoutDocument, +} from './flow.ts'; +import type { + LayoutFragment, + LayoutPaginationOptions, + PaginatedLayoutResult, + PaginationDiagnostic, + ResolvedPageSpec +} from './pagination.ts'; +import { resolvePageSpec } from './pagination.ts'; +import type { LayoutMeasureDelegate, LayoutMeasuredContent } from './measure.ts'; +import { createNullMeasureDelegate } from './measure.ts'; + +export interface PreparedFlowLayoutDocument { + kind: 'flow'; + documentId: string; + page: ResolvedPageSpec; + body: LayoutNode; + header?: LayoutNode; + footer?: LayoutNode; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export interface PreparedPresentationLayoutDocument { + kind: 'presentation'; + documentId: string; + page: ResolvedPageSpec; + slides: LayoutNode[]; + previewScale: 'contain' | 'cover' | 'stretch'; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export type PreparedLayoutDocument = + | PreparedFlowLayoutDocument + | PreparedPresentationLayoutDocument; + +interface FlowLayoutRuntime { + page: ResolvedPageSpec; + delegate: LayoutMeasureDelegate; + diagnostics: PaginationDiagnostic[]; + pages: PaginatedLayoutResult['pages']; + bodyBox: LayoutRect; + maxPages: number; + currentPageIndex: number; + cursorY: number; +} + +function assertRootNodeAllowed( + node: LayoutNode, + role: 'body' | 'header' | 'footer' | 'background' | 'foreground' +): void { + if (node.kind === 'fixed' && role !== 'body') { + throw new Error(`Nested fixed nodes are not allowed in ${role}.`); + } +} + +function validateFlowDocument(document: FlowLayoutDocument): void { + assertRootNodeAllowed(document.body, 'body'); + if (document.header) { + assertRootNodeAllowed(document.header, 'header'); + } + if (document.footer) { + assertRootNodeAllowed(document.footer, 'footer'); + } + if (document.background) { + assertRootNodeAllowed(document.background, 'background'); + } + if (document.foreground) { + assertRootNodeAllowed(document.foreground, 'foreground'); + } +} + +function validatePresentationDocument(document: PresentationLayoutDocument): void { + if (document.slides.length === 0) { + throw new Error('Presentation documents must define at least one slide.'); + } + + for (const slide of document.slides) { + assertRootNodeAllowed(slide, 'body'); + } + if (document.background) { + assertRootNodeAllowed(document.background, 'background'); + } + if (document.foreground) { + assertRootNodeAllowed(document.foreground, 'foreground'); + } +} + +export function validateLayoutDocument(document: LayoutDocument): void { + if (document.id.trim().length === 0) { + throw new Error('Layout document id must be non-empty.'); + } + + if (document.kind === 'presentation') { + validatePresentationDocument(document); + return; + } + + validateFlowDocument(document); +} + +export function prepareLayoutDocument( + document: LayoutDocument +): PreparedLayoutDocument { + validateLayoutDocument(document); + + const page = resolvePageSpec(document.page); + if (document.kind === 'presentation') { + return { + kind: 'presentation', + documentId: document.id, + page, + slides: document.slides, + previewScale: document.previewScale ?? 'contain', + background: document.background, + foreground: document.foreground + }; + } + + return { + kind: 'flow', + documentId: document.id, + page, + body: document.body, + header: document.header, + footer: document.footer, + background: document.background, + foreground: document.foreground + }; +} + +function cloneRect(rect: LayoutRect): LayoutRect { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; +} + +function clampDimension(value: number, min?: number, max?: number): number { + let next = value; + if (min !== undefined) { + next = Math.max(min, next); + } + if (max !== undefined) { + next = Math.min(max, next); + } + return next; +} + +function addDiagnostic( + diagnostics: PaginationDiagnostic[], + diagnostic: PaginationDiagnostic +): void { + diagnostics.push(diagnostic); +} + +function estimateTextHeight(text: string, availableWidth: number, textStyle?: LayoutTextStyle): number { + const fontSize = typeof textStyle?.fontSize === 'number' ? textStyle.fontSize : 16; + const lineHeight = typeof textStyle?.lineHeight === 'number' ? textStyle.lineHeight : fontSize * 1.4; + const charsPerLine = Math.max(1, Math.floor(Math.max(availableWidth, fontSize) / Math.max(1, fontSize * 0.6))); + return text + .split('\n') + .reduce((count, line) => count + Math.max(1, Math.ceil(line.length / charsPerLine)), 0) * lineHeight; +} + +function measureLeafNode( + node: LayoutNode, + availableWidth: number, + page: ResolvedPageSpec, + delegate: LayoutMeasureDelegate, + diagnostics: PaginationDiagnostic[] +): { width: number; height: number; renderData?: unknown } { + const measured = delegate.measure({ + node, + availableSpace: { + width: availableWidth, + height: page.contentBox.height + }, + page + }); + if (measured) { + return { + width: measured.preferredRect?.width ?? availableWidth, + height: measured.preferredRect?.height ?? measured.intrinsicSize.minHeight, + renderData: measured.renderData + }; + } + + switch (node.kind) { + case 'spacer': + return { width: availableWidth, height: node.size }; + case 'text': + return { + width: availableWidth, + height: estimateTextHeight(node.text, availableWidth, node.textStyle) + }; + case 'measured': + case 'table': + addDiagnostic(diagnostics, { + code: 'missing-measure-delegate', + severity: 'warning', + message: `No measure delegate provided for ${node.kind} node.`, + nodeId: node.id + }); + return { width: availableWidth, height: 0 }; + default: + return { width: availableWidth, height: 0 }; + } +} + +function estimateNodeHeight( + node: LayoutNode, + availableWidth: number, + page: ResolvedPageSpec, + delegate: LayoutMeasureDelegate, + diagnostics: PaginationDiagnostic[] +): number { + switch (node.kind) { + case 'stack': { + const padding = normalizeLayoutInsets(node.padding); + let height = padding.top + padding.bottom; + node.children.forEach((child, index) => { + height += estimateNodeHeight(child, Math.max(0, availableWidth - padding.left - padding.right), page, delegate, diagnostics); + if (index < node.children.length - 1) { + height += node.gap ?? 0; + } + }); + return height; + } + case 'box': { + const padding = normalizeLayoutInsets(node.padding); + const childHeight = node.child + ? estimateNodeHeight(node.child, Math.max(0, availableWidth - padding.left - padding.right), page, delegate, diagnostics) + : 0; + const naturalHeight = childHeight + padding.top + padding.bottom; + return clampDimension(node.height ?? naturalHeight, node.minHeight, node.maxHeight); + } + case 'pageBreak': + return 0; + default: + return measureLeafNode(node, availableWidth, page, delegate, diagnostics).height; + } +} + +function createArtifactFragment( + node: LayoutNode, + pageIndex: number, + rect: LayoutRect, + artifactKind: PageArtifactKind +): LayoutFragment { + return { + nodeId: node.id, + nodeKind: node.kind, + pageIndex, + rect, + continuation: 'none', + artifactKind + }; +} + +function ensurePage(runtime: FlowLayoutRuntime): void { + if (runtime.pages[runtime.currentPageIndex]) { + return; + } + if (runtime.currentPageIndex >= runtime.maxPages) { + throw new Error(`Pagination exceeded maxPages=${runtime.maxPages}.`); + } + runtime.pages.push({ + index: runtime.currentPageIndex, + page: runtime.page, + bodyBox: cloneRect(runtime.bodyBox), + fragments: [] + }); +} + +function currentPage( + runtime: FlowLayoutRuntime +): PaginatedLayoutResult['pages'][number] { + ensurePage(runtime); + return runtime.pages[runtime.currentPageIndex]!; +} + +function pushPageArtifacts( + pageFragments: LayoutFragment[], + prepared: PreparedFlowLayoutDocument, + pageIndex: number, + bodyBox: LayoutRect, + headerHeight: number, + footerHeight: number, + phase: 'start' | 'end' +): void { + if (phase === 'start' && prepared.background) { + pageFragments.push(createArtifactFragment(prepared.background, pageIndex, cloneRect(prepared.page.contentBox), 'background')); + } + if (phase === 'start' && prepared.header && headerHeight > 0) { + pageFragments.push(createArtifactFragment(prepared.header, pageIndex, { + x: prepared.page.contentBox.x, + y: prepared.page.contentBox.y, + width: prepared.page.contentBox.width, + height: headerHeight + }, 'header')); + } + if (phase === 'end' && prepared.footer && footerHeight > 0) { + pageFragments.push(createArtifactFragment(prepared.footer, pageIndex, { + x: prepared.page.contentBox.x, + y: bodyBox.y + bodyBox.height, + width: prepared.page.contentBox.width, + height: footerHeight + }, 'footer')); + } + if (phase === 'end' && prepared.foreground) { + pageFragments.push(createArtifactFragment(prepared.foreground, pageIndex, cloneRect(prepared.page.contentBox), 'foreground')); + } +} + +function startNewFlowPage( + runtime: FlowLayoutRuntime, + prepared: PreparedFlowLayoutDocument, + headerHeight: number, + footerHeight: number +): void { + ensurePage(runtime); + const page = currentPage(runtime); + if (page.fragments.length === 0) { + pushPageArtifacts(page.fragments, prepared, runtime.currentPageIndex, runtime.bodyBox, headerHeight, footerHeight, 'start'); + } + runtime.cursorY = runtime.bodyBox.y; +} + +function advancePage( + runtime: FlowLayoutRuntime, + prepared: PreparedFlowLayoutDocument, + headerHeight: number, + footerHeight: number +): void { + runtime.currentPageIndex += 1; + ensurePage(runtime); + const page = currentPage(runtime); + if (page.fragments.length === 0) { + pushPageArtifacts(page.fragments, prepared, runtime.currentPageIndex, runtime.bodyBox, headerHeight, footerHeight, 'start'); + } + runtime.cursorY = runtime.bodyBox.y; +} + +function emitLeafFragment( + runtime: FlowLayoutRuntime, + node: LayoutNode, + width: number, + height: number, + renderData?: unknown +): void { + currentPage(runtime).fragments.push({ + nodeId: node.id, + nodeKind: node.kind, + pageIndex: runtime.currentPageIndex, + rect: { + x: runtime.bodyBox.x, + y: runtime.cursorY, + width, + height + }, + continuation: 'none', + renderData + }); + runtime.cursorY += height; +} + +function layoutNodeIntoPages( + node: LayoutNode, + runtime: FlowLayoutRuntime, + prepared: PreparedFlowLayoutDocument, + availableWidth: number, + headerHeight: number, + footerHeight: number +): void { + if (node.breakBefore === 'page' || node.kind === 'pageBreak') { + advancePage(runtime, prepared, headerHeight, footerHeight); + if (node.kind === 'pageBreak') { + return; + } + } + + if (node.kind === 'stack') { + const padding = normalizeLayoutInsets(node.padding); + const innerWidth = Math.max(0, availableWidth - padding.left - padding.right); + runtime.cursorY += padding.top; + node.children.forEach((child, index) => { + if (index > 0) { + runtime.cursorY += node.gap ?? 0; + } + const totalHeight = estimateNodeHeight(child, innerWidth, runtime.page, runtime.delegate, runtime.diagnostics); + const remainingHeight = runtime.bodyBox.y + runtime.bodyBox.height - runtime.cursorY; + if (remainingHeight < totalHeight && totalHeight <= runtime.bodyBox.height) { + advancePage(runtime, prepared, headerHeight, footerHeight); + } + layoutNodeIntoPages(child, runtime, prepared, innerWidth, headerHeight, footerHeight); + }); + runtime.cursorY += padding.bottom; + } else if (node.kind === 'box') { + const totalHeight = estimateNodeHeight(node, availableWidth, runtime.page, runtime.delegate, runtime.diagnostics); + const remainingHeight = runtime.bodyBox.y + runtime.bodyBox.height - runtime.cursorY; + if (remainingHeight < totalHeight && totalHeight <= runtime.bodyBox.height) { + advancePage(runtime, prepared, headerHeight, footerHeight); + } + if (totalHeight > runtime.bodyBox.height) { + addDiagnostic(runtime.diagnostics, { + code: 'box-overflow-page', + severity: 'warning', + message: 'Box node is taller than the available page body area.', + nodeId: node.id, + pageIndex: runtime.currentPageIndex + }); + } + emitLeafFragment(runtime, node, availableWidth, totalHeight); + } else { + const measured = measureLeafNode(node, availableWidth, runtime.page, runtime.delegate, runtime.diagnostics); + const remainingHeight = runtime.bodyBox.y + runtime.bodyBox.height - runtime.cursorY; + if (remainingHeight < measured.height && measured.height <= runtime.bodyBox.height) { + advancePage(runtime, prepared, headerHeight, footerHeight); + } + if (measured.height > runtime.bodyBox.height) { + addDiagnostic(runtime.diagnostics, { + code: 'node-overflow-page', + severity: 'warning', + message: `${node.kind} node is taller than the available page body area.`, + nodeId: node.id, + pageIndex: runtime.currentPageIndex + }); + } + emitLeafFragment(runtime, node, measured.width, measured.height, measured.renderData); + } + + if (node.breakAfter === 'page') { + advancePage(runtime, prepared, headerHeight, footerHeight); + } +} + +function layoutFlowDocument( + prepared: PreparedFlowLayoutDocument, + delegate: LayoutMeasureDelegate, + options?: LayoutPaginationOptions +): PaginatedLayoutResult { + const diagnostics: PaginationDiagnostic[] = []; + const headerHeight = prepared.header + ? estimateNodeHeight(prepared.header, prepared.page.contentBox.width, prepared.page, delegate, diagnostics) + : 0; + const footerHeight = prepared.footer + ? estimateNodeHeight(prepared.footer, prepared.page.contentBox.width, prepared.page, delegate, diagnostics) + : 0; + const bodyBox: LayoutRect = { + x: prepared.page.contentBox.x, + y: prepared.page.contentBox.y + headerHeight, + width: prepared.page.contentBox.width, + height: Math.max(0, prepared.page.contentBox.height - headerHeight - footerHeight) + }; + const runtime: FlowLayoutRuntime = { + page: prepared.page, + delegate, + diagnostics, + pages: [], + bodyBox, + maxPages: options?.maxPages ?? 1000, + currentPageIndex: 0, + cursorY: bodyBox.y + }; + + startNewFlowPage(runtime, prepared, headerHeight, footerHeight); + layoutNodeIntoPages(prepared.body, runtime, prepared, bodyBox.width, headerHeight, footerHeight); + runtime.pages.forEach((page, index) => { + pushPageArtifacts(page.fragments, prepared, index, bodyBox, headerHeight, footerHeight, 'end'); + }); + + return { + documentId: prepared.documentId, + page: prepared.page, + pages: runtime.pages, + diagnostics + }; +} + +function layoutPresentationDocument( + prepared: PreparedPresentationLayoutDocument +): PaginatedLayoutResult { + return { + documentId: prepared.documentId, + page: prepared.page, + diagnostics: [], + pages: prepared.slides.map((slide, index) => ({ + index, + page: prepared.page, + bodyBox: cloneRect(prepared.page.contentBox), + fragments: [ + ...(prepared.background ? [createArtifactFragment(prepared.background, index, cloneRect(prepared.page.contentBox), 'background')] : []), + { + nodeId: slide.id, + nodeKind: slide.kind, + pageIndex: index, + rect: cloneRect(prepared.page.contentBox), + continuation: 'none' + }, + ...(prepared.foreground ? [createArtifactFragment(prepared.foreground, index, cloneRect(prepared.page.contentBox), 'foreground')] : []) + ] + })) + }; +} + +export class PaginatedLayoutEngine { + private delegate: LayoutMeasureDelegate; + + constructor(delegate: LayoutMeasureDelegate = createNullMeasureDelegate()) { + this.delegate = delegate; + } + + getMeasureDelegate(): LayoutMeasureDelegate { + return this.delegate; + } + + prepare(document: LayoutDocument): PreparedLayoutDocument { + return prepareLayoutDocument(document); + } + + layout(document: LayoutDocument, options?: LayoutPaginationOptions): PaginatedLayoutResult { + const prepared = this.prepare(document); + if (prepared.kind === 'presentation') { + return layoutPresentationDocument(prepared); + } + return layoutFlowDocument(prepared, this.delegate, options); + } +} + +export function createPaginatedLayoutEngine( + delegate: LayoutMeasureDelegate = createNullMeasureDelegate() +): PaginatedLayoutEngine { + return new PaginatedLayoutEngine(delegate); +} diff --git a/packages/layout-pagination/src/flow.ts b/packages/layout-pagination/src/flow.ts new file mode 100644 index 0000000..b611bc1 --- /dev/null +++ b/packages/layout-pagination/src/flow.ts @@ -0,0 +1,42 @@ +import type { LayoutContent, LayoutNode } from './common.ts'; +import type { PageSpec } from './pagination.ts'; + +export type LayoutDocumentKind = 'flow' | 'presentation'; +export type PresentationPreviewScale = 'contain' | 'cover' | 'stretch'; + +export interface FlowLayoutDocument< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> { + kind: 'flow'; + id: string; + page: PageSpec; + body: LayoutNode; + header?: LayoutNode; + footer?: LayoutNode; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export interface PresentationLayoutDocument< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> { + kind: 'presentation'; + id: string; + page: PageSpec; + slides: LayoutNode[]; + previewScale?: PresentationPreviewScale; + background?: LayoutNode; + foreground?: LayoutNode; +} + +export type LayoutDocument< + TNodeId extends string = string, + TTableContent = LayoutContent, + TMeasuredContent = LayoutContent +> = + | FlowLayoutDocument + | PresentationLayoutDocument; diff --git a/packages/layout-pagination/src/index.ts b/packages/layout-pagination/src/index.ts new file mode 100644 index 0000000..52f3b3f --- /dev/null +++ b/packages/layout-pagination/src/index.ts @@ -0,0 +1,77 @@ +export type { + BaseLayoutNodeProps, + BoxLayoutNode, + BoxLayoutNodeProps, + BreakAfterPolicy, + BreakBeforePolicy, + BreakInsidePolicy, + FixedLayoutNode, + FixedLayoutNodeProps, + LayoutContent, + LayoutInsets, + LayoutNode, + LayoutNodeForType, + LayoutNodeOfType, + LayoutNodePropsByType, + LayoutNodeType, + LayoutTextStyle, + LayoutRect, + LayoutSize, + MeasuredLayoutNode, + MeasuredLayoutNodeProps, + PageArtifactKind, + PageBreakLayoutNode, + PageBreakLayoutNodeProps, + SpacerLayoutNode, + SpacerLayoutNodeProps, + StackLayoutNode, + StackLayoutNodeProps, + TableLayoutNode, + TableLayoutNodeProps, + TextLayoutNode, + TextLayoutNodeProps +} from './common.ts'; +export { + normalizeLayoutInsets +} from './common.ts'; +export type { + FlowLayoutDocument, + LayoutDocument, + LayoutDocumentKind, + PresentationLayoutDocument, + PresentationPreviewScale +} from './flow.ts'; +export type { + FragmentContinuation, + LayoutFragment, + LayoutPaginationOptions, + PageSpec, + PageUnit, + PaginatedLayoutResult, + PaginatedPage, + PaginationDiagnostic, + PaginationDiagnosticSeverity, + ResolvedPageSpec +} from './pagination.ts'; +export { + resolvePageSpec +} from './pagination.ts'; +export type { + LayoutAvailableSpace, + LayoutIntrinsicSize, + LayoutMeasureDelegate, + LayoutMeasuredContent, + LayoutMeasureRequest +} from './measure.ts'; +export { createNullMeasureDelegate } from './measure.ts'; +export type { + PreparedFlowLayoutDocument, + PreparedLayoutDocument, + PreparedPresentationLayoutDocument +} from './engine.ts'; +export { + createPaginatedLayoutEngine, + PaginatedLayoutEngine, + prepareLayoutDocument, + validateLayoutDocument +} from './engine.ts'; diff --git a/packages/layout-pagination/src/measure.ts b/packages/layout-pagination/src/measure.ts new file mode 100644 index 0000000..b75e891 --- /dev/null +++ b/packages/layout-pagination/src/measure.ts @@ -0,0 +1,34 @@ +import type { LayoutNode, LayoutRect, LayoutSize } from './common.ts'; +import type { ResolvedPageSpec } from './pagination.ts'; + +export interface LayoutAvailableSpace extends LayoutSize {} + +export interface LayoutIntrinsicSize { + minWidth: number; + maxWidth: number; + minHeight: number; +} + +export interface LayoutMeasureRequest { + node: LayoutNode; + availableSpace: LayoutAvailableSpace; + page: ResolvedPageSpec; +} + +export interface LayoutMeasuredContent { + intrinsicSize: LayoutIntrinsicSize; + preferredRect?: LayoutRect; + renderData?: unknown; +} + +export interface LayoutMeasureDelegate { + measure(request: LayoutMeasureRequest): LayoutMeasuredContent | null; +} + +export function createNullMeasureDelegate(): LayoutMeasureDelegate { + return { + measure(): LayoutMeasuredContent | null { + return null; + } + }; +} diff --git a/packages/layout-pagination/src/pagination.ts b/packages/layout-pagination/src/pagination.ts new file mode 100644 index 0000000..bad7e5b --- /dev/null +++ b/packages/layout-pagination/src/pagination.ts @@ -0,0 +1,95 @@ +import type { LayoutNode, LayoutRect, LayoutInsets } from './common.ts'; +import { normalizeLayoutInsets } from './common.ts'; + +export type PageUnit = 'pt' | 'px' | 'mm' | 'in'; + +export interface PageSpec { + id?: string; + width: number; + height: number; + unit?: PageUnit; + margins?: Partial; +} + +export interface ResolvedPageSpec { + id?: string; + width: number; + height: number; + unit: PageUnit; + margins: LayoutInsets; + contentBox: LayoutRect; +} + +export type FragmentContinuation = 'none' | 'start' | 'middle' | 'end'; + +export interface LayoutFragment { + nodeId?: TNodeId; + pageIndex: number; + rect: LayoutRect; + continuation: FragmentContinuation; + artifactKind?: import('./common.ts').PageArtifactKind; + nodeKind?: LayoutNode['kind']; + renderData?: unknown; +} + +export interface PaginatedPage { + index: number; + page: ResolvedPageSpec; + bodyBox: LayoutRect; + fragments: LayoutFragment[]; +} + +export type PaginationDiagnosticSeverity = 'info' | 'warning' | 'error'; + +export interface PaginationDiagnostic { + code: string; + severity: PaginationDiagnosticSeverity; + message: string; + nodeId?: TNodeId; + pageIndex?: number; +} + +export interface PaginatedLayoutResult { + documentId: string; + page: ResolvedPageSpec; + pages: PaginatedPage[]; + diagnostics: PaginationDiagnostic[]; +} + +export interface LayoutPaginationOptions { + maxPages?: number; +} + +const DEFAULT_PAGE_MARGINS: LayoutInsets = { + top: 48, + right: 48, + bottom: 48, + left: 48 +}; + +export function resolvePageSpec(page: PageSpec): ResolvedPageSpec { + if (!(page.width > 0) || !(page.height > 0)) { + throw new Error('Page width and height must be greater than zero.'); + } + + const margins = normalizeLayoutInsets(page.margins, DEFAULT_PAGE_MARGINS); + const contentWidth = page.width - margins.left - margins.right; + const contentHeight = page.height - margins.top - margins.bottom; + if (contentWidth < 0 || contentHeight < 0) { + throw new Error('Page margins exceed the page size.'); + } + + return { + id: page.id, + width: page.width, + height: page.height, + unit: page.unit ?? 'pt', + margins, + contentBox: { + x: margins.left, + y: margins.top, + width: contentWidth, + height: contentHeight + } + }; +} diff --git a/packages/layout-pagination/test/page-spec.test.ts b/packages/layout-pagination/test/page-spec.test.ts new file mode 100644 index 0000000..457e5bc --- /dev/null +++ b/packages/layout-pagination/test/page-spec.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest'; +import { + createPaginatedLayoutEngine, + createNullMeasureDelegate, + prepareLayoutDocument, + resolvePageSpec, + validateLayoutDocument +} from '../src/index.ts'; +import type { LayoutDocument, LayoutMeasureRequest, LayoutMeasuredContent } from '../src/index.ts'; + +describe('layout pagination foundations', () => { + it('resolves page specs into a content box', () => { + expect(resolvePageSpec({ + width: 612, + height: 792, + margins: { + top: 36, + right: 24, + bottom: 48, + left: 24 + } + })).toEqual({ + id: undefined, + width: 612, + height: 792, + unit: 'pt', + margins: { + top: 36, + right: 24, + bottom: 48, + left: 24 + }, + contentBox: { + x: 24, + y: 36, + width: 564, + height: 708 + } + }); + }); + + it('rejects margins larger than the page', () => { + expect(() => resolvePageSpec({ + width: 100, + height: 100, + margins: { + left: 60, + right: 60 + } + })).toThrow('Page margins exceed the page size.'); + }); + + it('prepares a validated document through the engine', () => { + const document: LayoutDocument = { + kind: 'flow', + id: 'doc-1', + page: { + width: 595, + height: 842, + unit: 'pt' + }, + body: { + kind: 'stack', + gap: 12, + children: [ + { kind: 'text', text: 'Hello page' }, + { kind: 'spacer', size: 24 } + ] + } + }; + + validateLayoutDocument(document); + const prepared = prepareLayoutDocument(document); + expect(prepared.kind).toBe('flow'); + expect(prepared.page.contentBox.width).toBe(499); + + const engine = createPaginatedLayoutEngine(); + expect(engine.prepare(document)).toEqual(prepared); + }); + + it('prepares presentation documents as explicit slide sequences', () => { + const document: LayoutDocument = { + kind: 'presentation', + id: 'deck-1', + page: { + width: 1280, + height: 720, + unit: 'px', + margins: { + top: 0, + right: 0, + bottom: 0, + left: 0 + } + }, + previewScale: 'contain', + slides: [ + { + kind: 'stack', + children: [ + { kind: 'text', text: 'Title slide' } + ] + }, + { + kind: 'stack', + children: [ + { kind: 'text', text: 'Agenda slide' } + ] + } + ] + }; + + const prepared = prepareLayoutDocument(document); + expect(prepared).toEqual({ + kind: 'presentation', + documentId: 'deck-1', + page: { + id: undefined, + width: 1280, + height: 720, + unit: 'px', + margins: { + top: 0, + right: 0, + bottom: 0, + left: 0 + }, + contentBox: { + x: 0, + y: 0, + width: 1280, + height: 720 + } + }, + previewScale: 'contain', + slides: document.slides, + background: undefined, + foreground: undefined + }); + }); + + it('lays out flow content across multiple pages with repeated artifacts', () => { + const document: LayoutDocument = { + kind: 'flow', + id: 'doc-flow', + page: { + width: 200, + height: 220, + unit: 'px', + margins: { + top: 10, + right: 10, + bottom: 10, + left: 10 + } + }, + header: { + kind: 'spacer', + id: 'header', + size: 20 + }, + footer: { + kind: 'spacer', + id: 'footer', + size: 15 + }, + background: { + kind: 'spacer', + id: 'bg', + size: 0 + }, + foreground: { + kind: 'spacer', + id: 'fg', + size: 0 + }, + body: { + kind: 'stack', + gap: 10, + children: [ + { kind: 'spacer', id: 'a', size: 60 }, + { kind: 'spacer', id: 'b', size: 60 }, + { kind: 'spacer', id: 'c', size: 60 } + ] + } + }; + + const result = createPaginatedLayoutEngine(createNullMeasureDelegate()).layout(document); + expect(result.pages).toHaveLength(2); + expect(result.pages[0]!.bodyBox).toEqual({ + x: 10, + y: 30, + width: 180, + height: 165 + }); + expect(result.pages[0]!.fragments.map((fragment) => `${fragment.artifactKind ?? 'body'}:${fragment.nodeId ?? fragment.nodeKind}`)).toEqual([ + 'background:bg', + 'header:header', + 'body:a', + 'body:b', + 'footer:footer', + 'foreground:fg' + ]); + expect(result.pages[1]!.fragments.some((fragment) => fragment.nodeId === 'c')).toBe(true); + }); + + it('honors explicit page breaks in flow documents', () => { + const document: LayoutDocument = { + kind: 'flow', + id: 'doc-break', + page: { + width: 200, + height: 180, + unit: 'px' + }, + body: { + kind: 'stack', + children: [ + { kind: 'text', id: 'intro', text: 'Intro' }, + { kind: 'pageBreak', id: 'break-1' }, + { kind: 'text', id: 'after-break', text: 'After break' } + ] + } + }; + + const result = createPaginatedLayoutEngine().layout(document); + expect(result.pages).toHaveLength(2); + expect(result.pages[0]!.fragments.some((fragment) => fragment.nodeId === 'intro')).toBe(true); + expect(result.pages[1]!.fragments.some((fragment) => fragment.nodeId === 'after-break')).toBe(true); + }); + + it('uses measurement delegates for measured nodes', () => { + const document: LayoutDocument = { + kind: 'flow', + id: 'doc-measured', + page: { + width: 300, + height: 220, + unit: 'px' + }, + body: { + kind: 'stack', + children: [ + { + kind: 'measured', + id: 'hero', + measureKey: 'hero', + content: { kind: 'hero' } + } + ] + } + }; + + const engine = createPaginatedLayoutEngine({ + measure(request: LayoutMeasureRequest): LayoutMeasuredContent | null { + if (request.node.kind !== 'measured') { + return null; + } + return { + intrinsicSize: { + minWidth: request.availableSpace.width, + maxWidth: request.availableSpace.width, + minHeight: 96 + }, + preferredRect: { + x: 0, + y: 0, + width: request.availableSpace.width, + height: 96 + }, + renderData: { measured: true } + }; + } + }); + + const result = engine.layout(document); + const fragment = result.pages[0]!.fragments.find((entry) => entry.nodeId === 'hero'); + expect(fragment?.rect.height).toBe(96); + expect(fragment?.renderData).toEqual({ measured: true }); + }); + + it('supports string-literal unions for node ids', () => { + type NodeId = 'intro' | 'body' | 'summary'; + const document: LayoutDocument = { + kind: 'flow', + id: 'typed-doc', + page: { + width: 300, + height: 220, + unit: 'px' + }, + body: { + kind: 'stack', + id: 'body', + children: [ + { + kind: 'text', + id: 'intro', + text: 'Intro copy' + }, + { + kind: 'text', + id: 'summary', + text: 'Summary copy' + } + ] + } + }; + + const result = createPaginatedLayoutEngine().layout(document); + const nodeIds = result.pages.flatMap((page) => page.fragments.map((fragment) => fragment.nodeId)).filter(Boolean); + expect(nodeIds).toEqual(['intro', 'summary']); + }); +}); diff --git a/packages/layout-pagination/tsconfig.json b/packages/layout-pagination/tsconfig.json new file mode 100644 index 0000000..f543bbd --- /dev/null +++ b/packages/layout-pagination/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6618681..929a896 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: vitrine-gui: specifier: workspace:* version: link:../gui + vitrine-layout-pagination: + specifier: workspace:* + version: link:../layout-pagination + vitrine-layout-pagination-adapter: + specifier: workspace:* + version: link:../layout-pagination-vitrine devDependencies: '@types/node': specifier: ^22.10.5 @@ -55,6 +61,28 @@ importers: specifier: workspace:* version: link:../core + packages/layout-pagination: + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.11) + + packages/layout-pagination-vitrine: + dependencies: + vitrine: + specifier: workspace:* + version: link:../core + vitrine-layout-pagination: + specifier: workspace:* + version: link:../layout-pagination + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/tables: devDependencies: typescript: