/** * 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', 'decorateInlineWord', 'applyGlossaryEntries', 'applyGlossaryEntriesToInline', 'normalizeGlossaryText', 'normalizeGlossaryToken', 'normalizeGlossaryCompact', 'buildGlossaryTermPatterns', 'buildCompactGlossaryTermPatterns', 'decorateGlossarySegment', 'decorateGlossaryRange', 'decorateGlossaryWord', 'renderGlossaryHtml', 'ensureGlossaryTooltip', 'showGlossaryTooltip', 'hideGlossaryTooltip', 'positionGlossaryTooltip', 'escapeRegExp' ]); } /** * 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'}`); } }); 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); } if (Array.isArray(layoutData.lines) && layoutData.lines.length > 0) { layoutData.lines.forEach((line, lineIndex) => { this.renderLine({ paragraph, line, lineIndex, contentTopLines, lineHeight, maxLineWidth }); }); this.applyGlossaryEntries(paragraph, layoutData.glossaryEntries); return paragraph; } // Position words according to layout with proper justification let wordCount = 0; let lastChild = null; let syllable = ""; const stack = [paragraph]; 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; this.decorateInlineWord(lastChild, stack); 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; this.decorateInlineWord(lastChild, stack); 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; this.decorateInlineWord(word, stack); 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 = "-"; this.decorateInlineWord(word, stack); stack[stack.length - 1].appendChild(word); } } } this.applyGlossaryEntries(paragraph, layoutData.glossaryEntries); return paragraph; } decorateInlineWord(word, stack = []) { if (!word || !Array.isArray(stack)) return; const glossaryElement = stack .slice() .reverse() .find(element => element?.classList?.contains('story-glossary-term')); if (!glossaryElement) return; const definition = glossaryElement.dataset.glossary || glossaryElement.getAttribute('title') || ''; word.classList.add('story-glossary-word'); word.dataset.glossary = definition; word.removeAttribute('title'); word.tabIndex = 0; word.setAttribute('aria-label', `${word.textContent}: ${definition}`); } applyGlossaryEntries(paragraph, entries = []) { if (!paragraph || !Array.isArray(entries) || entries.length === 0) return; const words = Array.from(paragraph.querySelectorAll('.word')) .map(element => ({ element, text: this.normalizeGlossaryText(element.textContent || '') })) .filter(word => word.text.length > 0 && word.text !== '-'); if (words.length === 0) return; let cursor = 0; const segments = []; let compactCursor = 0; const compactSegments = []; const fullText = words.map((word, index) => { if (index > 0) cursor += 1; const start = cursor; cursor += word.text.length; segments.push({ ...word, start, end: cursor }); const compactText = this.normalizeGlossaryCompact(word.text); if (compactText) { const compactStart = compactCursor; compactCursor += compactText.length; compactSegments.push({ ...word, start: compactStart, end: compactCursor }); } return word.text; }).join(' '); const compactFullText = words.map(word => this.normalizeGlossaryCompact(word.text)).join(''); entries .filter(entry => entry && entry.term && entry.definition) .forEach(entry => { this.buildGlossaryTermPatterns(entry.term).forEach((pattern) => { const matcher = new RegExp(`(^|\\s)(${pattern})(?=\\s|$|[.,;:!?])`, 'giu'); let match; while ((match = matcher.exec(fullText)) !== null) { const matchStart = match.index + match[1].length; const matchEnd = matchStart + match[2].length; segments .filter(segment => segment.end > matchStart && segment.start < matchEnd) .forEach(segment => this.decorateGlossarySegment(segment, entry, matchStart, matchEnd, 'text')); } }); this.buildCompactGlossaryTermPatterns(entry.term).forEach((pattern) => { const matcher = new RegExp(pattern, 'giu'); let match; while ((match = matcher.exec(compactFullText)) !== null) { const matchStart = match.index; const matchEnd = matchStart + match[0].length; compactSegments .filter(segment => segment.end > matchStart && segment.start < matchEnd) .forEach(segment => this.decorateGlossarySegment(segment, entry, matchStart, matchEnd, 'compact')); } }); }); } applyGlossaryEntriesToInline(container, entries = []) { if (!container || !Array.isArray(entries) || entries.length === 0) return; const filter = window.NodeFilter || NodeFilter; entries .filter(entry => entry && entry.term && entry.definition) .forEach(entry => { const walker = document.createTreeWalker(container, filter.SHOW_TEXT, { acceptNode: (node) => { if (!node?.nodeValue?.trim()) return filter.FILTER_REJECT; if (node.parentElement?.closest?.('.story-glossary-word')) return filter.FILTER_REJECT; return filter.FILTER_ACCEPT; } }); const textNodes = []; let node; while ((node = walker.nextNode())) { textNodes.push(node); } textNodes.forEach((textNode) => { if (!textNode.parentNode) return; const original = textNode.nodeValue || ''; if (!this.normalizeGlossaryText(original)) return; const matches = []; this.buildGlossaryTermPatterns(entry.term).forEach((pattern) => { const matcher = new RegExp(`(^|\\s)(${pattern})(?=\\s|$|[.,;:!?])`, 'giu'); let match; while ((match = matcher.exec(original)) !== null) { const matchStart = match.index + match[1].length; const matchEnd = matchStart + match[2].length; if (!matches.some(existing => existing.start === matchStart && existing.end === matchEnd)) { matches.push({ start: matchStart, end: matchEnd }); } } }); this.buildCompactGlossaryTermPatterns(entry.term).forEach((pattern) => { const compactOriginal = this.normalizeGlossaryCompact(original); if (!compactOriginal) return; const matcher = new RegExp(pattern, 'giu'); let match; while ((match = matcher.exec(compactOriginal)) !== null) { if (match.index !== 0 || match[0].length !== compactOriginal.length) continue; if (!matches.some(existing => existing.start === 0 && existing.end === original.length)) { matches.push({ start: 0, end: original.length }); } } }); matches.sort((a, b) => a.start - b.start); if (matches.length === 0) return; const parent = textNode.parentNode; let cursor = 0; matches.forEach((match) => { if (match.start < cursor) return; const before = original.slice(cursor, match.start); const matched = original.slice(match.start, match.end); if (before) parent.insertBefore(document.createTextNode(before), textNode); if (matched) { const gloss = document.createElement('span'); gloss.textContent = matched; this.decorateGlossaryWord(gloss, entry); parent.insertBefore(gloss, textNode); } cursor = match.end; }); const after = original.slice(cursor); if (after) parent.insertBefore(document.createTextNode(after), textNode); parent.removeChild(textNode); }); }); } normalizeGlossaryText(text) { return String(text || '') .normalize('NFC') .replace(/\u200c/g, '') .replace(/\u00ad/g, '') .replace(/-\s*$/g, '') .replace(/\s+/g, ' ') .trim(); } normalizeGlossaryToken(text) { return this.normalizeGlossaryText(text) .replace(/^[.,;:!?()[\]{}"'„“”‚‘’»«]+|[.,;:!?()[\]{}"'„“”‚‘’»«]+$/g, ''); } normalizeGlossaryCompact(text) { return this.normalizeGlossaryToken(text) .replace(/[-\s]+/g, '') .replace(/[.,;:!?()[\]{}"'„“”‚‘’»«]+/g, ''); } buildGlossaryTermPatterns(term) { const normalizedTerm = this.normalizeGlossaryText(term); if (!normalizedTerm) return []; const exact = normalizedTerm .split(/\s+/) .map(token => this.escapeRegExp(this.normalizeGlossaryToken(token))) .filter(Boolean) .join('\\s+'); if (!exact) return []; const inflected = normalizedTerm .split(/\s+/) .map((token, index, tokens) => { const normalized = this.normalizeGlossaryToken(token); if (!normalized) return ''; const escaped = this.escapeRegExp(normalized); const isLast = index === tokens.length - 1; return isLast ? `${escaped}(?:s|es|e|en|er|n)?` : `${escaped}(?:e|en|er|es|n)?`; }) .filter(Boolean) .join('\\s+'); return [...new Set([exact, inflected])]; } buildCompactGlossaryTermPatterns(term) { const tokens = this.normalizeGlossaryText(term) .split(/\s+/) .map(token => this.normalizeGlossaryCompact(token)) .filter(Boolean); if (tokens.length === 0) return []; const exact = tokens.map(token => this.escapeRegExp(token)).join(''); const inflected = tokens .map((token, index) => { const escaped = this.escapeRegExp(token); const isLast = index === tokens.length - 1; return isLast ? `${escaped}(?:s|es|e|en|er|n)?` : `${escaped}(?:e|en|er|es|n)?`; }) .join(''); return [...new Set([exact, inflected])]; } decorateGlossarySegment(segment, entry, matchStart, matchEnd, mode = 'text') { if (!segment?.element || !entry?.definition) return; const localStart = Math.max(0, matchStart - segment.start); const localEnd = Math.min(segment.end - segment.start, matchEnd - segment.start); if (localEnd <= localStart) return; const segmentLength = mode === 'compact' ? this.normalizeGlossaryCompact(segment.text).length : segment.text.length; if (localStart <= 0 && localEnd >= segmentLength) { this.decorateGlossaryWord(segment.element, entry); return; } if (mode === 'compact') { return; } this.decorateGlossaryRange(segment.element, entry, localStart, localEnd); } decorateGlossaryRange(word, entry, start, end) { if (!word || !entry?.definition) return; const text = word.textContent || ''; const safeStart = Math.max(0, Math.min(text.length, start)); const safeEnd = Math.max(safeStart, Math.min(text.length, end)); if (safeStart === 0 && safeEnd >= text.length) { this.decorateGlossaryWord(word, entry); return; } if (safeEnd <= safeStart) return; word.dataset.glossaryPartial = 'true'; const textNodes = []; const filter = window.NodeFilter || NodeFilter; const walker = document.createTreeWalker(word, filter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { textNodes.push(node); } let offset = 0; textNodes.forEach((textNode) => { const nodeText = textNode.nodeValue || ''; const nodeStart = offset; const nodeEnd = nodeStart + nodeText.length; offset = nodeEnd; const overlapStart = Math.max(safeStart, nodeStart); const overlapEnd = Math.min(safeEnd, nodeEnd); if (overlapEnd <= overlapStart || !textNode.parentNode) return; const localStart = overlapStart - nodeStart; const localEnd = overlapEnd - nodeStart; const before = nodeText.slice(0, localStart); const matched = nodeText.slice(localStart, localEnd); const after = nodeText.slice(localEnd); const parent = textNode.parentNode; if (before) { parent.insertBefore(document.createTextNode(before), textNode); } if (matched) { const gloss = document.createElement('span'); gloss.textContent = matched; this.decorateGlossaryWord(gloss, entry); parent.insertBefore(gloss, textNode); } if (after) { parent.insertBefore(document.createTextNode(after), textNode); } parent.removeChild(textNode); }); if (textNodes.length === 0) { const before = text.slice(0, safeStart); const matched = text.slice(safeStart, safeEnd); const after = text.slice(safeEnd); word.textContent = ''; if (before) word.appendChild(document.createTextNode(before)); const gloss = document.createElement('span'); gloss.textContent = matched; this.decorateGlossaryWord(gloss, entry); word.appendChild(gloss); if (after) word.appendChild(document.createTextNode(after)); } } decorateGlossaryWord(word, entry) { if (!word || !entry?.definition) return; word.classList.add('story-glossary-word'); word.dataset.glossaryTerm = entry.term || this.normalizeGlossaryText(word.textContent || ''); word.dataset.glossary = entry.definition; word.removeAttribute('title'); word.tabIndex = 0; word.setAttribute('aria-label', `${this.normalizeGlossaryText(word.textContent || '')}: ${entry.definition}`); if (word.dataset.glossaryBound === 'true') return; word.dataset.glossaryBound = 'true'; word.addEventListener('mouseenter', this.showGlossaryTooltip); word.addEventListener('focus', this.showGlossaryTooltip); word.addEventListener('mousemove', this.positionGlossaryTooltip); word.addEventListener('mouseleave', this.hideGlossaryTooltip); word.addEventListener('blur', this.hideGlossaryTooltip); } renderGlossaryHtml(text) { const markupParser = this.getModule('markup-parser'); if (markupParser && typeof markupParser.markdownToHtml === 'function') { return markupParser.markdownToHtml(String(text || '')); } return String(text || '') .replace(/&/g, '&') .replace(//g, '>'); } ensureGlossaryTooltip() { let tooltip = document.getElementById('story_glossary_tooltip'); if (tooltip) return tooltip; tooltip = document.createElement('div'); tooltip.id = 'story_glossary_tooltip'; tooltip.className = 'story-glossary-tooltip'; tooltip.setAttribute('role', 'tooltip'); tooltip.setAttribute('aria-hidden', 'true'); const header = document.createElement('div'); header.className = 'story-glossary-tooltip-header'; const title = document.createElement('h2'); title.className = 'story-glossary-tooltip-title'; header.appendChild(title); const body = document.createElement('div'); body.className = 'story-glossary-tooltip-body'; tooltip.append(header, body); document.body.appendChild(tooltip); return tooltip; } showGlossaryTooltip(event) { const word = event.currentTarget; if (!word) return; const tooltip = this.ensureGlossaryTooltip(); const title = tooltip.querySelector('.story-glossary-tooltip-title'); const body = tooltip.querySelector('.story-glossary-tooltip-body'); if (title) title.innerHTML = this.renderGlossaryHtml(word.dataset.glossaryTerm || this.normalizeGlossaryText(word.textContent || '')); if (body) body.innerHTML = this.renderGlossaryHtml(word.dataset.glossary || ''); tooltip.dataset.anchorId = word.id || ''; tooltip.__anchorElement = word; tooltip.classList.add('visible'); tooltip.setAttribute('aria-hidden', 'false'); this.positionGlossaryTooltip(event); } hideGlossaryTooltip() { const tooltip = document.getElementById('story_glossary_tooltip'); if (!tooltip) return; tooltip.classList.remove('visible'); tooltip.setAttribute('aria-hidden', 'true'); tooltip.__anchorElement = null; } positionGlossaryTooltip(event) { const tooltip = document.getElementById('story_glossary_tooltip'); if (!tooltip || !tooltip.classList.contains('visible')) return; const anchor = event?.currentTarget || tooltip.__anchorElement; if (!anchor || typeof anchor.getBoundingClientRect !== 'function') return; const anchorRect = anchor.getBoundingClientRect(); const margin = Math.max(8, window.innerWidth * 0.006); const tooltipRect = tooltip.getBoundingClientRect(); const preferredLeft = anchorRect.left + (anchorRect.width / 2) - (tooltipRect.width / 2); let left = Math.min( Math.max(margin, preferredLeft), Math.max(margin, window.innerWidth - tooltipRect.width - margin) ); let top = anchorRect.top - tooltipRect.height - margin; if (top < margin) { top = anchorRect.bottom + margin; } top = Math.min( Math.max(margin, top), Math.max(margin, window.innerHeight - tooltipRect.height - margin) ); tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; } escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } renderLine({ paragraph, line, lineIndex, contentTopLines, lineHeight, maxLineWidth }) { const lineWidth = Number(line.measure || maxLineWidth); const lineOffset = Number(line.offset || 0); const ratio = line.isFinal ? 0 : Number(line.ratio || 0); const stack = [paragraph]; let currentLeft = 0; let lastChild = null; let syllable = ''; for (let j = 0; j < line.nodes.length; j += 1) { const node = line.nodes[j]; if (!node) continue; if (node.type === 'box' && node.value !== '') { const followsGlue = j > 0 && line.nodes[j - 1].type === 'glue'; const isTrailingPunctuation = /^[,.;:!?…)]$/.test(node.value) && !followsGlue; if (lastChild && isTrailingPunctuation) { syllable += node.value; lastChild.innerHTML = syllable; this.decorateInlineWord(lastChild, stack); currentLeft += node.width || 0; continue; } const word = document.createElement('span'); word.className = ['word', line.styleClass || ''].filter(Boolean).join(' '); 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); word.style.top = `${((contentTopLines + lineIndex) * lineHeight * 100) / parseFloat(paragraph.style.height)}%`; word.style.left = `${((lineOffset + currentLeft) * 100) / maxLineWidth}%`; word.style.opacity = '0'; word.style.visibility = 'hidden'; word.style.clipPath = 'inset(0 100% 0 0)'; syllable = node.value; word.innerHTML = syllable; this.decorateInlineWord(word, stack); stack[stack.length - 1].appendChild(word); lastChild = word; currentLeft += node.width || 0; } else if (node.type === 'tag') { if (String(node.value || '').startsWith(' 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' && node.width !== 0) { let adjustedWidth = node.width || 0; if (ratio > 0) { adjustedWidth += (node.stretch || 0) * ratio; } else if (ratio < 0) { adjustedWidth += (node.shrink || 0) * ratio; } currentLeft += adjustedWidth; } else if (node.type === 'penalty' && node.penalty === 100 && line.hyphenated && j === line.nodes.length - 1 && lastChild) { lastChild.innerHTML = `${lastChild.innerHTML}-`; } } } 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;