/** * Layout Renderer Module * Renders calculated paragraph layouts into the DOM with proper animations */ import { BaseModule } from './base-module.js'; class LayoutRendererModule extends BaseModule { constructor() { super('layout-renderer', 'Layout Renderer'); // Module dependencies this.dependencies = []; // Configuration this.updateConfig({ animation: { defaultSpeed: 1.0, wordAnimationClass: 'animate-word' } }); // Bind methods this.bindMethods([ 'renderParagraph', 'initializeContainers', 'adjustJustification' ]); } /** * Initialize the module * @returns {Promise} - Resolves with success status */ async initialize() { try { this.reportProgress(10, "Initializing Layout Renderer"); this.reportProgress(100, "Layout Renderer ready"); return true; } catch (error) { console.error("Error initializing Layout Renderer:", error); return false; } } /** * Initialize default containers */ initializeContainers() { // Check if story container exists const storyContainer = document.getElementById('story'); if (!storyContainer) { console.log('Story container not found, creating it'); const container = document.createElement('div'); container.id = 'story'; document.body.appendChild(container); } } /** * Render a paragraph from layout data (pure DOM creation, no animation) * @param {Object} layoutData - Layout data containing breaks, nodes, and measures * @param {Object} options - Rendering options * @returns {HTMLElement} - The created paragraph element */ renderParagraph(layoutData, options = {}) { const { id = `p-${Date.now()}` } = options; const { breaks, nodes, measures, fontSize, fontFamily, lineHeightPx } = layoutData; if (!breaks || !nodes) { console.error('LayoutRenderer: Invalid layout data'); return null; } // Create paragraph container const paragraph = document.createElement('p'); paragraph.id = id; paragraph.className = [ layoutData.role ? `story-${layoutData.role}` : '', layoutData.addTopSpace ? 'story-textblock-start' : '', layoutData.dropCap ? 'story-dropcap-paragraph' : '' ].filter(Boolean).join(' '); paragraph.style.position = 'absolute'; paragraph.style.margin = '0'; paragraph.style.left = '0'; const globalLineStart = Math.max(0, Number(layoutData.lineStart || 0)); const windowOriginLine = Math.max(0, Number(layoutData.windowOriginLine || 0)); paragraph.style.top = `${(globalLineStart - windowOriginLine) * Number(lineHeightPx || 0)}px`; if (fontSize) paragraph.style.fontSize = fontSize; if (fontFamily) paragraph.style.fontFamily = fontFamily; // Calculate paragraph height const storyElement = document.getElementById('story'); if (!storyElement) { console.error('LayoutRenderer: Story container not found'); return null; } const pageWidth = Number(layoutData.pageWidth || storyElement.clientWidth); paragraph.style.width = `${pageWidth}px`; paragraph.style.maxWidth = '100%'; if (!Number.isFinite(Number(lineHeightPx)) || Number(lineHeightPx) <= 0) { throw new Error('LayoutRenderer: Missing canonical lineHeightPx for story layout.'); } const lineHeight = Number(lineHeightPx); const contentTopLines = Math.max(0, Number(layoutData.contentTopLines || 0)); const maxLineWidth = Array.isArray(measures) && measures.length > 0 ? Math.max(pageWidth, ...measures) : pageWidth; // Height should include all lines (breaks.length represents number of lines) const numLines = Math.max(1, breaks.length - 1); const totalLines = Math.max(1, Number(layoutData.lineCount || (numLines + contentTopLines))); paragraph.style.height = `${lineHeight * totalLines}px`; paragraph.dataset.heightLines = String(totalLines); paragraph.dataset.lineStart = String(globalLineStart); paragraph.dataset.lineCount = String(totalLines); console.log(`LayoutRenderer: Rendering paragraph ${id} - ${breaks.length} breaks (${numLines} lines), lineHeight: ${lineHeight}px, total height: ${lineHeight * numLines}px`); // Debug: log break ratios breaks.forEach((brk, idx) => { if (idx > 0) { console.log(` Line ${idx - 1}: break position ${brk.position}, ratio ${brk.ratio ? brk.ratio.toFixed(3) : 'undefined'}`); } }); // Position words according to layout with proper justification let wordCount = 0; let lastChild = null; let syllable = ""; const stack = [paragraph]; if (layoutData.dropCap && layoutData.dropCapText) { const dropCap = document.createElement('span'); dropCap.className = 'drop-cap story-drop-cap'; dropCap.textContent = layoutData.dropCapText; dropCap.style.top = `${contentTopLines * lineHeight}px`; paragraph.appendChild(dropCap); } for (let i = 1; i < breaks.length; i++) { const lineIndex = i - 1; const lineWidth = measures[Math.min(lineIndex, measures.length - 1)]; const currentBreak = breaks[i]; const isFinalLine = i === breaks.length - 1; const isCentered = layoutData.align === 'center' || layoutData.role === 'chapter-heading' || layoutData.role === 'section-heading'; const ratio = (isFinalLine || isCentered) ? 0 : (currentBreak.ratio || 0); const naturalLineWidth = isCentered ? this.measureNaturalLineWidth(nodes, breaks[i - 1].position, currentBreak.position) : lineWidth; const lineOffset = isCentered ? Math.max(0, (maxLineWidth - naturalLineWidth) / 2) : Array.isArray(layoutData.lineOffsets) ? (layoutData.lineOffsets[Math.min(lineIndex, layoutData.lineOffsets.length - 1)] || 0) : maxLineWidth - lineWidth; let currentLeft = 0; lastChild = null; // Iterate through nodes on this line (break positions are inclusive) for (let j = breaks[i-1].position; j <= currentBreak.position; j++) { const node = nodes[j]; if (node.type === 'box' && node.value !== '' && j < currentBreak.position) { const followsGlue = j > 0 && nodes[j - 1].type === 'glue'; const isTrailingPunctuation = /^[,.;:!?…)]$/.test(node.value) && !followsGlue; // Check if this box follows a penalty (hyphenation point) if (lastChild && isTrailingPunctuation) { syllable += node.value; lastChild.innerHTML = syllable; currentLeft += node.width; } else if (j > breaks[i-1].position + 1 && nodes[j-1].type === 'penalty' && lastChild) { // Combine with previous syllable using zero-width non-joiner syllable += '\u200c' + node.value; lastChild.innerHTML = syllable; currentLeft += node.width; } else { // Create new word span const word = document.createElement('span'); word.className = 'word'; word.style.position = 'absolute'; word.style.display = 'inline-block'; word.style.whiteSpace = 'nowrap'; word.dataset.line = String(lineIndex); word.dataset.lineStart = String(lineOffset); word.dataset.lineWidth = String(lineWidth); // Calculate position with proper line and justification const topPercent = ((contentTopLines + lineIndex) * lineHeight * 100) / parseFloat(paragraph.style.height); const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth; word.style.top = `${topPercent}%`; word.style.left = `${leftPercent}%`; word.style.opacity = '0'; // Hidden until animated word.style.visibility = 'hidden'; word.style.clipPath = 'inset(0 100% 0 0)'; syllable = node.value; word.innerHTML = syllable; lastChild = word; if (wordCount < 5 || (wordCount % 20 === 0)) { console.log(` Word ${wordCount} "${node.value}" at line ${lineIndex}, top: ${topPercent.toFixed(1)}%, left: ${leftPercent.toFixed(1)}%`); } wordCount++; stack[stack.length - 1].appendChild(word); currentLeft += node.width; } } else if (node.type === 'tag') { if (node.value.substr(0, 2) === ' 1) stack.pop(); } else { const template = document.createElement('div'); template.innerHTML = node.value; const tag = template.firstChild; if (tag) { tag.style.display = 'contents'; stack[stack.length - 1].appendChild(tag); stack.push(tag); } } } else if (node.type === 'glue' && j > breaks[i-1].position && node.width !== 0 && j <= currentBreak.position) { // Apply justification: adjust glue width based on line's ratio let adjustedWidth = node.width; if (ratio > 0) { // Line needs stretching adjustedWidth = node.width + (node.stretch * ratio); } else if (ratio < 0) { // Line needs shrinking adjustedWidth = node.width + (node.shrink * ratio); } // If ratio === 0, line fits perfectly, use natural width if (wordCount < 3) { // Debug first line's glue adjustments console.log(` Glue at position ${j}: natural=${node.width.toFixed(2)}px, adjusted=${adjustedWidth.toFixed(2)}px, ratio=${ratio.toFixed(3)}, left before: ${currentLeft.toFixed(2)}px`); } // Increment position by the adjusted glue width currentLeft += adjustedWidth; } else if (node.type === 'penalty' && node.penalty === 100 && j === currentBreak.position) { // Add hyphen at line break if (lastChild) { lastChild.innerHTML = `${lastChild.innerHTML}-`; continue; } const word = document.createElement('span'); word.className = 'word'; word.style.position = 'absolute'; word.style.display = 'inline-block'; word.style.whiteSpace = 'nowrap'; word.dataset.line = String(lineIndex); word.dataset.lineStart = String(lineOffset); word.dataset.lineWidth = String(lineWidth); const topPercent = ((contentTopLines + lineIndex) * lineHeight * 100) / parseFloat(paragraph.style.height); const leftPercent = ((lineOffset + currentLeft) * 100) / maxLineWidth; word.style.top = `${topPercent}%`; word.style.left = `${leftPercent}%`; word.style.opacity = '0'; word.style.visibility = 'hidden'; word.style.clipPath = 'inset(0 100% 0 0)'; word.innerHTML = "-"; stack[stack.length - 1].appendChild(word); } } } return paragraph; } measureNaturalLineWidth(nodes, startPosition, endPosition) { let width = 0; for (let j = startPosition; j <= endPosition; j++) { const node = nodes[j]; if (!node) continue; if (node.type === 'box' || node.type === 'glue') { width += node.width || 0; } else if (node.type === 'penalty' && node.penalty === 100 && j === endPosition) { width += node.width || 0; } } return width; } /** * Paragraph positions are already computed from browser DOM measurements. * Keep this hook for callers that still invoke it, but do not reflow the * prototype layout after rendering. * @param {HTMLElement} paragraph - Rendered paragraph element */ adjustJustification(paragraph) { return paragraph; } } // Create the singleton instance const LayoutRenderer = new LayoutRendererModule(); // Export the module export { LayoutRenderer }; // Register with the module registry if (window.moduleRegistry) { window.moduleRegistry.register(LayoutRenderer); } // Keep a reference in window for loader system window.LayoutRenderer = LayoutRenderer;