/** * Paragraph Layout Module * Implements the Knuth and Plass line breaking algorithm for optimal typography * and connects it to the text rendering pipeline. */ import { BaseModule } from './base-module.js'; class ParagraphLayoutModule extends BaseModule { constructor() { super('paragraph-layout', 'Paragraph Layout'); this.dependencies = ['text-processor']; this.ruler = null; this.rulerStack = []; this.updateConfig({ maxLineWidth: 600, hyphenationEnabled: true, defaultFontSize: '1rem', defaultFontFamily: "'EB Garamond', serif", defaultLineHeight: 1.5, debugMode: false }); this.bindMethods([ 'calculateLayout', 'measureText', 'setDebugMode', 'updateFont', 'processTextForLayout', 'initializeTextMeasurement', 'setupEventListeners', 'loadLayoutDependencies' ]); } async initialize() { try { this.reportProgress(20, "Initializing paragraph layout"); const textProcessor = this.getModule('text-processor'); if (!textProcessor) { console.warn("Paragraph Layout: Text Processor not found, will use unprocessed text"); } await this.loadLayoutDependencies(); this.initializeTextMeasurement(); this.setupEventListeners(); this.reportProgress(100, "Paragraph layout ready"); return true; } catch (error) { console.error("Error initializing Paragraph Layout:", error); return false; } } async loadLayoutDependencies() { try { this.reportProgress(30, "Loading layout dependencies"); await this.loadScript('/js/linked-list.js'); await this.loadScript('/js/linebreak.js'); await this.loadScript('/js/knuth-and-plass.js'); this.reportProgress(50, "Layout dependencies loaded"); return true; } catch (error) { console.error("Error loading layout dependencies:", error); return false; } } initializeTextMeasurement() { let ruler = document.getElementById('ruler'); if (!ruler) { ruler = document.createElement('span'); ruler.id = 'ruler'; document.body.appendChild(ruler); } Object.assign(ruler.style, { visibility: 'hidden', position: 'absolute', top: '-8000px', left: '-8000px', width: 'auto', display: 'inline', textIndent: '0', textAlign: 'left', hyphens: 'none', marginBlockEnd: '0' }); this.ruler = ruler; this.rulerStack = [ruler]; this.updateFont(this.config.defaultFontSize, this.config.defaultFontFamily); } setupEventListeners() { this.addEventListener(document, 'ui:font:change', (event) => { if (event.detail) { const { fontSize, fontFamily } = event.detail; if (fontSize || fontFamily) { this.updateFont( fontSize || this.config.defaultFontSize, fontFamily || this.config.defaultFontFamily ); } } }); this.addEventListener(document, 'ui:hyphenation:toggle', (event) => { if (event.detail && typeof event.detail.enabled === 'boolean') { this.updateConfig({ hyphenationEnabled: event.detail.enabled }); } }); } updateFont(fontSize, fontFamily) { if (!this.ruler) { console.warn("Text measurement ruler not initialized"); return; } if (fontSize) this.updateConfig({ defaultFontSize: fontSize }); if (fontFamily) this.updateConfig({ defaultFontFamily: fontFamily }); this.ruler.style.fontSize = fontSize; this.ruler.style.fontFamily = fontFamily; this.ruler.style.fontFeatureSettings = "'kern' on, 'liga' on, 'onum' on, 'clig' on, 'hlig' on"; if (this.config.debugMode) { console.log(`Font updated: ${fontSize} ${fontFamily}`); } } measureText(text) { if (!this.ruler) return 0; if (!text) return 0; if (text.substr(0, 2) === ' 1) { const child = this.rulerStack.pop(); const parent = this.rulerStack[this.rulerStack.length - 1]; if (child.parentElement === parent) { parent.removeChild(child); } } return 0; } if (text.substr(0, 1) === '<') { const template = document.createElement('div'); template.innerHTML = text; const child = template.firstChild; if (child) { const parent = this.rulerStack[this.rulerStack.length - 1]; this.rulerStack.push(child); parent.appendChild(child); } return 0; } if (text === '|') return 0; if (text === ' ') text = '\u00A0'; const parent = this.rulerStack[this.rulerStack.length - 1]; const textNode = document.createTextNode(text); parent.appendChild(textNode); const rect = parent.getClientRects()[0] || parent.getBoundingClientRect(); const width = rect ? rect.width : 0; parent.removeChild(textNode); return width; } processTextForLayout(text) { if (!text) return ''; let processedText = text; const textProcessor = this.getModule('text-processor'); if (textProcessor && typeof textProcessor.process === 'function') { processedText = textProcessor.process(processedText, { smartypants: true, hyphenate: this.config.hyphenationEnabled, hyphenSelector: '.hyphenatePipe' }); } else if (this.config.debugMode) { console.log("Text processor not available, skipping text processing"); } return processedText; } calculateLayout(text, options = {}) { if (!text) return null; try { if (typeof window.kap !== 'function') { console.error("Paragraph Layout: kap function not available. Make sure knuth-and-plass.js is loaded."); return null; } const processedText = this.processTextForLayout(text); const layoutOptions = { width: options.width || this.config.maxLineWidth, fontSize: options.fontSize || this.config.defaultFontSize, fontFamily: options.fontFamily || this.config.defaultFontFamily, lineHeight: options.lineHeight || this.config.defaultLineHeight, lineHeightPx: options.lineHeightPx, tolerance: options.tolerance || 3, demerits: options.demerits || { line: 10, flagged: 100, fitness: 3000 } }; this.updateFont(layoutOptions.fontSize, layoutOptions.fontFamily); const numericFontSize = parseFloat(layoutOptions.fontSize) || 16; const lineHeightPx = layoutOptions.lineHeightPx || (numericFontSize * layoutOptions.lineHeight); const measure = options.measures || [layoutOptions.width]; if (this.config.debugMode) { console.log("Paragraph Layout: Calculating layout for text", { text: processedText, measure, options: layoutOptions }); } const layout = window.kap( processedText, this.measureText.bind(this), measure, this.config.hyphenationEnabled, layoutOptions.tolerance, layoutOptions.demerits ); if (!layout || !layout.breaks || !layout.nodes) { console.warn("Paragraph Layout: Failed to calculate layout for text"); return null; } if (this.config.debugMode) { console.log("Paragraph Layout: Layout calculated", { breaks: layout.breaks.length, nodes: layout.nodes.length }); } return { ...layout, originalText: text, processedText, width: layoutOptions.width, lineHeight: layoutOptions.lineHeight, lineHeightPx, fontSize: layoutOptions.fontSize, fontFamily: layoutOptions.fontFamily }; } catch (error) { console.error("Error calculating layout:", error); return null; } } setDebugMode(enabled) { this.updateConfig({ debugMode: enabled }); console.log(`Paragraph Layout: Debug mode ${enabled ? 'enabled' : 'disabled'}`); } } const ParagraphLayout = new ParagraphLayoutModule(); if (window.moduleRegistry) { window.moduleRegistry.register(ParagraphLayout); } export { ParagraphLayout }; window.ParagraphLayout = ParagraphLayout;