From 4f6300042c7e89484af6b6ce5529029f0c4d7b4d Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Mon, 18 May 2026 03:08:23 +0200 Subject: [PATCH] Fix portrait image flow and drop-cap spacing --- public/js/layout-renderer-module.js | 95 ++++++++++++++++++++++++-- public/js/sentence-queue-module.js | 14 ++-- public/js/ui-display-handler-module.js | 19 +++++- 3 files changed, 112 insertions(+), 16 deletions(-) diff --git a/public/js/layout-renderer-module.js b/public/js/layout-renderer-module.js index af2ef73..0d41aaa 100644 --- a/public/js/layout-renderer-module.js +++ b/public/js/layout-renderer-module.js @@ -123,12 +123,6 @@ class LayoutRendererModule extends BaseModule { } }); - // 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'; @@ -137,6 +131,26 @@ class LayoutRendererModule extends BaseModule { 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 + }); + }); + 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)]; @@ -277,6 +291,75 @@ class LayoutRendererModule extends BaseModule { return paragraph; } + 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; + 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; + 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++) { diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index ea75b25..ab611f3 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -873,23 +873,21 @@ class SentenceQueueModule extends BaseModule { computed.fontFamily ].filter(Boolean).join(' '); const metrics = context.measureText(dropCapText); - inkRight = Math.max( - metrics.width || 0, - metrics.actualBoundingBoxRight || 0 - ); + inkRight = Number.isFinite(metrics.actualBoundingBoxRight) && metrics.actualBoundingBoxRight > 0 + ? metrics.actualBoundingBoxRight + : (metrics.width || 0); } } catch (error) { console.warn('SentenceQueue: Could not measure drop-cap canvas ink bounds', error); } probeParagraph.remove(); - const measuredAdvance = Math.max( + const fallbackAdvance = Math.max( Number.isFinite(rect.width) && rect.width > 0 ? rect.width : 0, Number.isFinite(probe.offsetWidth) && probe.offsetWidth > 0 ? probe.offsetWidth : 0, - Number.isFinite(probe.scrollWidth) && probe.scrollWidth > 0 ? probe.scrollWidth : 0, - inkRight + Number.isFinite(probe.scrollWidth) && probe.scrollWidth > 0 ? probe.scrollWidth : 0 ); - const glyphAdvance = measuredAdvance > 0 ? measuredAdvance : lineHeight * 1.34; + const glyphAdvance = inkRight > 0 ? inkRight : (fallbackAdvance > 0 ? fallbackAdvance : lineHeight * 1.34); return glyphAdvance + this.measureNormalTextGap(container, lineHeight); } diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 5f992fe..e942fd3 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -87,6 +87,7 @@ class UIDisplayHandlerModule extends BaseModule { 'dedupeRenderedWindow', 'reflowTextBlocksForActiveExclusions', 'blockIntersectsExclusions', + 'getFlowLineFromItems', 'insertStoredElement', 'handleHistoryWheel', 'handleManualScrollStart', @@ -957,8 +958,8 @@ class UIDisplayHandlerModule extends BaseModule { try { await this.ensureLiveTailWindow(); await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false }); - this.layoutFlowLine = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0)); this.rebuildLayoutExclusions(this.renderedItems); + this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems); const element = await this.renderStoryBlock(sentence, { animate: true, playback: true, placement: 'append' }); if (!element) return null; sentence.element = element; @@ -1246,6 +1247,20 @@ class UIDisplayHandlerModule extends BaseModule { return this.layoutExclusions.some(exclusion => start < exclusion.endLine && end > exclusion.startLine); } + getFlowLineFromItems(items = this.renderedItems) { + const source = Array.isArray(items) ? items : []; + return source.reduce((max, item) => { + const type = String(item?.kind || item?.type || '').toLowerCase(); + const size = String(item?.metadata?.imageLayout?.size || item?.metadata?.size || item?.size || '').toLowerCase(); + if (type === 'image' && size === 'portrait') { + return max; + } + const start = Number(item?.lineStart ?? item?.metadata?.lineStart); + const count = Math.max(0, Number(item?.lineCount ?? item?.metadata?.lineCount ?? 0)); + return Number.isFinite(start) && count > 0 ? Math.max(max, start + count) : max; + }, 0); + } + async reflowTextBlocksForActiveExclusions(token = this.renderWindowToken) { if (!this.layoutExclusions.length || !this.paragraphContainer) return; const candidates = this.renderedItems.filter(item => this.blockIntersectsExclusions(item)); @@ -2037,7 +2052,7 @@ class UIDisplayHandlerModule extends BaseModule { this.historyWindowEndId = 0; this.windowOriginLine = 0; } - this.layoutFlowLine = Math.max(0, Number(this.storyHistory.renderedLineCount || 0)); + this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems); this.activeCenterBlockId = latestRendered || null; }