/** * 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'); // Module dependencies this.dependencies = ['text-processor']; // Caching canvas context for text measurements this.textMeasureCtx = null; // Configuration - use parent's config system this.updateConfig({ maxLineWidth: 600, hyphenationEnabled: true, defaultFontSize: '1rem', defaultFontFamily: "'EB Garamond', serif", defaultLineHeight: 1.5, debugMode: false }); // Bind methods using parent's bindMethods utility this.bindMethods([ 'calculateLayout', 'measureText', 'setDebugMode', 'updateFont', 'processTextForLayout', 'initializeTextMeasurement', 'setupEventListeners', 'loadLayoutDependencies' ]); } async initialize() { try { this.reportProgress(20, "Initializing paragraph layout"); // Get text processor using parent's getModule method const textProcessor = this.getModule('text-processor'); if (!textProcessor) { console.warn("Paragraph Layout: Text Processor not found, will use fallback processing"); } // Load required dependencies await this.loadLayoutDependencies(); // Create off-screen canvas for text measurements this.initializeTextMeasurement(); // Set up event listeners for config changes this.setupEventListeners(); this.reportProgress(100, "Paragraph layout ready"); return true; } catch (error) { console.error("Error initializing Paragraph Layout:", error); return false; } } /** * Load required dependencies for layout calculations */ async loadLayoutDependencies() { try { this.reportProgress(30, "Loading layout dependencies"); // Load LinkedList.js first as it's required by linebreak.js await this.loadScript('/js/linked-list.js'); // Load linebreak.js which is required by knuth-and-plass.js await this.loadScript('/js/linebreak.js'); // Load knuth-and-plass.js which contains the kap function 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; } } /** * Initialize text measurement canvas */ initializeTextMeasurement() { // Create off-screen canvas for text measurements const canvas = document.createElement('canvas'); canvas.width = 2000; canvas.height = 100; this.textMeasureCtx = canvas.getContext('2d'); // Set default font this.updateFont(this.config.defaultFontSize, this.config.defaultFontFamily); } setupEventListeners() { // Use parent's addEventListener for automatic cleanup 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 ); } } }); // Listen for config changes this.addEventListener(document, 'ui:hyphenation:toggle', (event) => { if (event.detail && typeof event.detail.enabled === 'boolean') { this.updateConfig({ hyphenationEnabled: event.detail.enabled }); } }); } /** * Update the font for text measurements * @param {string} fontSize - Font size (with units) * @param {string} fontFamily - Font family */ updateFont(fontSize, fontFamily) { if (!this.textMeasureCtx) { console.warn("Text measurement context not initialized"); return; } // Update config if values are provided if (fontSize) this.updateConfig({ defaultFontSize: fontSize }); if (fontFamily) this.updateConfig({ defaultFontFamily: fontFamily }); // Set font on measurement context this.textMeasureCtx.font = `${fontSize} ${fontFamily}`; if (this.config.debugMode) { console.log(`Font updated: ${fontSize} ${fontFamily}`); } } /** * Measure text width using canvas context * @param {string} text - Text to measure * @returns {number} - Text width in pixels */ measureText(text) { if (!this.textMeasureCtx) return 0; if (!text) return 0; const metrics = this.textMeasureCtx.measureText(text); return metrics.width; } /** * Process text for layout (apply hyphenation and smartypants) * @param {string} text - Text to process * @returns {string} - Processed text */ processTextForLayout(text) { if (!text) return ''; let processedText = text; const textProcessor = this.getModule('text-processor'); // Apply text processing if available if (textProcessor) { // Apply smartypants (typographic punctuation) if available if (typeof textProcessor.applySmartypants === 'function') { processedText = textProcessor.applySmartypants(processedText); } // Apply hyphenation if enabled and available if (this.config.hyphenationEnabled && typeof textProcessor.hyphenateText === 'function') { processedText = textProcessor.hyphenateText(processedText); } } else if (this.config.debugMode) { console.log("Text processor not available, skipping text processing"); } return processedText; } /** * Calculate layout for a paragraph using Knuth and Plass algorithm * @param {string} text - Text to layout * @param {Object} options - Layout options * @returns {Object} - Layout data with line breaks */ calculateLayout(text, options = {}) { if (!text) return null; try { // Check if the kap function is available if (typeof window.kap !== 'function') { console.error("Paragraph Layout: kap function not available. Make sure knuth-and-plass.js is loaded."); return null; } // Process text for layout (hyphenation, etc) const processedText = this.processTextForLayout(text); // Prepare options by merging with defaults 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, tolerance: options.tolerance || 3, // Tolerance for line breaking algorithm demerits: options.demerits || { line: 10, // Demerits for each line break flagged: 100, // Demerits for flagged break points (like hyphens) fitness: 3000 // Demerits for consecutive lines with very different looseness/tightness } }; // Update font for measurement this.updateFont(layoutOptions.fontSize, layoutOptions.fontFamily); // Create measure array - this is crucial for proper line breaking // The first value is the full width, subsequent values can be for indented lines const measure = [layoutOptions.width]; if (this.config.debugMode) { console.log("Paragraph Layout: Calculating layout for text", { text: processedText, measure, options: layoutOptions }); } // Use the global Knuth and Plass algorithm function with proper parameters const layout = window.kap( processedText, this.measureText.bind(this), measure, this.config.hyphenationEnabled, layoutOptions.tolerance, layoutOptions.demerits ); // If layout failed, return null 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 data with original text for reference return { ...layout, originalText: text, processedText: processedText, width: layoutOptions.width, lineHeight: layoutOptions.lineHeight }; } catch (error) { console.error("Error calculating layout:", error); return null; } } /** * Set debug mode * @param {boolean} enabled - Whether debug mode should be enabled */ setDebugMode(enabled) { // Use parent's updateConfig method this.updateConfig({ debugMode: enabled }); console.log(`Paragraph Layout: Debug mode ${enabled ? 'enabled' : 'disabled'}`); } } // Create the singleton instance const ParagraphLayout = new ParagraphLayoutModule(); // Register with the module registry if (window.moduleRegistry) { window.moduleRegistry.register(ParagraphLayout); } // Export the module export { ParagraphLayout }; // Keep a reference in window for loader system window.ParagraphLayout = ParagraphLayout;