From 777e39a650376da0c00db9cb091f438dea4d37dc Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sun, 7 Jun 2026 09:56:56 +0200 Subject: [PATCH] Correct WebGL book page projection --- public/js/book-page-format-module.js | 19 +++- public/js/book-pagination-module.js | 125 +++++++++++++++++++++- public/js/book-texture-renderer-module.js | 41 +++++-- public/js/loader.js | 2 +- public/js/procedural-book-model.js | 18 ++-- public/js/webgl-book-lab.js | 2 +- 6 files changed, 179 insertions(+), 28 deletions(-) diff --git a/public/js/book-page-format-module.js b/public/js/book-page-format-module.js index e48de44..019f58e 100644 --- a/public/js/book-page-format-module.js +++ b/public/js/book-page-format-module.js @@ -9,16 +9,16 @@ class BookPageFormatModule extends BaseModule { super('book-page-format', 'Book Page Format'); this.dependencies = []; this.format = Object.freeze({ - id: 'us-mass-market-hardcover', + id: 'us-mass-market-paperback', trim: Object.freeze({ widthIn: 4.25, - heightIn: 6.375 + heightIn: 6.87 }), margins: Object.freeze({ topIn: 0.46, bottomIn: 0.58, - innerIn: 0.62, - outerIn: 0.86 + innerIn: 0.56, + outerIn: 0.44 }), typography: Object.freeze({ fontFamily: '"EB Garamond", "EB Garamond 12", serif', @@ -69,6 +69,16 @@ class BookPageFormatModule extends BaseModule { width: Math.max(1, width - margins.outer - margins.inner), height: Math.max(1, height - margins.top - margins.bottom) }; + const contentBySide = { + left: { + ...content, + x: margins.outer + }, + right: { + ...content, + x: margins.inner + } + }; const linesPerPage = Math.max(1, Number(this.format.typography.linesPerPage || 25)); const typographyLineHeightPx = content.height / linesPerPage; const bodyFontSizePx = typographyLineHeightPx / Math.max(1, Number(this.format.typography.bodyLineRatio || 1.5)); @@ -78,6 +88,7 @@ class BookPageFormatModule extends BaseModule { aspectRatio: this.getAspectRatio(), margins, content, + contentBySide, linesPerPage, bodyFontSizePx, typographyLineHeightPx, diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index 6681d30..7baa1b3 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -25,6 +25,11 @@ class BookPaginationModule extends BaseModule { 'layoutTextBlock', 'getDropCapText', 'extractDropCapText', + 'measureDropCapReservation', + 'measureNormalTextGap', + 'calculateDropCapLayout', + 'extractLayoutLine', + 'extractRemainingLayoutText', 'extractLines', 'countLineWords', 'getLineGeometry', @@ -136,7 +141,7 @@ class BookPaginationModule extends BaseModule { const bottomSpaceLines = role === 'chapter-heading' || role === 'section-heading' ? 1 : 0; const lineHeightPx = Math.max(1, Number(this.metrics.typographyLineHeightPx || 1)); const fontPx = Math.max(1, Number(this.metrics.bodyFontSizePx || lineHeightPx / 1.5)); - const dropCapWidth = dropCap ? lineHeightPx * 1.72 : 0; + const dropCapWidth = dropCap ? this.measureDropCapReservation(dropCapText, fontPx, lineHeightPx) : 0; const indent = (isHeading || block.isFirstParagraphInChapter || block.metadata?.isFirstParagraphInChapter || block.addTopSpace) ? 0 : lineHeightPx * 1.5; @@ -147,14 +152,17 @@ class BookPaginationModule extends BaseModule { : [Math.max(120, this.metrics.content.width - indent), this.metrics.content.width, this.metrics.content.width]; const lineOffsets = isHeading ? [0] : dropCap ? [dropCapWidth, dropCapWidth, 0] : [indent, 0, 0]; - const layout = this.paragraphLayout.calculateLayout(text, { + const layoutOptions = { measures, fontSize: `${fontPx}px`, fontFamily: typography.fontFamily, fontFeatureSettings: '"kern" on, "liga" on, "onum" on, "pnum" on, "dlig" on, "clig" on, "calt" on', lineHeightPx, lineHeight: lineHeightPx / fontPx - }); + }; + const layout = dropCap + ? this.calculateDropCapLayout(text, measures, lineOffsets, layoutOptions) + : this.paragraphLayout.calculateLayout(text, layoutOptions); if (!layout) return null; return { @@ -184,7 +192,118 @@ class BookPaginationModule extends BaseModule { return String(text || '').replace(dropCap, '').trimStart(); } + measureDropCapReservation(dropCapText, fontPx, lineHeightPx) { + if (!dropCapText) return lineHeightPx * 1.34; + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) return lineHeightPx * 1.34; + + const dropCapFontPx = Math.round(fontPx * 2.68); + context.font = `${dropCapFontPx}px "EB Garamond Initials", ${this.metrics.typography.fontFamily}`; + const metrics = context.measureText(dropCapText); + const inkRight = Number.isFinite(metrics.actualBoundingBoxRight) && metrics.actualBoundingBoxRight > 0 + ? metrics.actualBoundingBoxRight + : (metrics.width || 0); + return Math.max(inkRight, lineHeightPx * 1.08) + this.measureNormalTextGap(fontPx); + } + + measureNormalTextGap(fontPx) { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) return fontPx * 0.75; + context.font = `${fontPx}px ${this.metrics.typography.fontFamily}`; + const gap = context.measureText('\u2002').width; + return Number.isFinite(gap) && gap > 0 ? gap : fontPx * 0.75; + } + + calculateDropCapLayout(text, measures, lineOffsets, layoutOptions) { + const firstLineOptions = { + ...layoutOptions, + measures: [measures[0], Math.max(measures[0] * 20, 10000)], + fontVariantCaps: 'all-small-caps', + fontFeatureSettings: '"smcp" on, "c2sc" on, "kern" on, "liga" on, "onum" on, "pnum" on' + }; + const firstLayout = this.paragraphLayout.calculateLayout(text, firstLineOptions); + if (!firstLayout?.breaks || firstLayout.breaks.length < 2) { + return this.paragraphLayout.calculateLayout(text, layoutOptions); + } + + const firstLine = this.extractLayoutLine(firstLayout, 0, { + measure: measures[0], + offset: lineOffsets[0], + smallCaps: true + }); + const remainingText = this.extractRemainingLayoutText(firstLayout, firstLayout.breaks[1].position); + const remainingLayout = this.paragraphLayout.calculateLayout(remainingText, { + ...layoutOptions, + measures: [measures[1], ...measures.slice(2)] + }); + const remainingLines = []; + if (remainingLayout?.breaks?.length > 1) { + for (let lineIndex = 0; lineIndex < remainingLayout.breaks.length - 1; lineIndex += 1) { + remainingLines.push(this.extractLayoutLine(remainingLayout, lineIndex, { + measure: measures[Math.min(lineIndex + 1, measures.length - 1)], + offset: lineOffsets[Math.min(lineIndex + 1, lineOffsets.length - 1)] || 0, + smallCaps: false + })); + } + } + + return { + lines: [firstLine, ...remainingLines].filter(Boolean), + processedText: text, + lineHeight: layoutOptions.lineHeight, + lineHeightPx: layoutOptions.lineHeightPx, + fontSize: layoutOptions.fontSize, + fontFamily: layoutOptions.fontFamily + }; + } + + extractLayoutLine(layout, lineIndex, metadata = {}) { + const startBreak = layout.breaks?.[lineIndex]; + const endBreak = layout.breaks?.[lineIndex + 1]; + if (!startBreak || !endBreak || !Array.isArray(layout.nodes)) return null; + const nodes = []; + for (let index = startBreak.position; index <= endBreak.position; index += 1) { + const node = layout.nodes[index]; + if (!node) continue; + if (node.type === 'glue' && (index === startBreak.position || index === endBreak.position)) continue; + const forcedBreak = window.linebreak?.infinity ? -window.linebreak.infinity : -100000; + if (node.type === 'penalty' && node.penalty <= forcedBreak) continue; + nodes.push({ ...node }); + } + const endNode = layout.nodes[endBreak.position]; + return { + nodes, + measure: metadata.measure, + offset: metadata.offset || 0, + ratio: endBreak.ratio || 0, + isFinal: lineIndex === layout.breaks.length - 2, + smallCaps: Boolean(metadata.smallCaps), + hyphenated: Boolean(endNode?.type === 'penalty' && endNode.penalty === 100), + align: 'justify' + }; + } + + extractRemainingLayoutText(layout, breakPosition) { + if (!Array.isArray(layout.nodes)) return ''; + const fragments = []; + for (let index = breakPosition + 1; index < layout.nodes.length; index += 1) { + const node = layout.nodes[index]; + if (!node) continue; + if (node.type === 'box' || node.type === 'tag') { + fragments.push(node.value || ''); + } else if (node.type === 'glue' && node.width > 0) { + fragments.push(' '); + } else if (node.type === 'penalty' && node.penalty === 100) { + fragments.push('|'); + } + } + return fragments.join('').replace(/\s+/g, ' ').trimStart(); + } + extractLines(layout, options = {}) { + if (Array.isArray(layout?.lines)) return layout.lines; const lines = []; const breaks = Array.isArray(layout.breaks) ? layout.breaks : []; const nodes = Array.isArray(layout.nodes) ? layout.nodes : []; diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 8c46eb9..60dbaf3 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -40,6 +40,7 @@ class BookTextureRendererModule extends BaseModule { 'drawPageLines', 'drawLine', 'drawWord', + 'getPageContent', 'buildLineSegments', 'startRevealAnimation', 'fastForwardAnimations', @@ -145,30 +146,35 @@ class BookTextureRendererModule extends BaseModule { ctx.textBaseline = 'alphabetic'; if ('fontKerning' in ctx) ctx.fontKerning = 'normal'; if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal'; - lines.forEach(line => this.drawLine(ctx, line)); + lines.forEach(line => this.drawLine(ctx, line, side)); ctx.restore(); } - drawLine(ctx, lineRecord = {}) { + drawLine(ctx, lineRecord = {}, side = 'left') { const metrics = this.metrics; + const content = this.getPageContent(side); const fontPx = Math.max(1, Number(lineRecord.fontPx || 22)); const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30)); const fontStyle = lineRecord.fontStyle === 'italic' ? 'italic ' : ''; const line = lineRecord.line || {}; const nodes = Array.isArray(line.nodes) ? line.nodes : []; - const baseY = metrics.content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx; + const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx; const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0); const naturalWidth = nodes.reduce((sum, node) => { if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0); return sum; }, 0); const centerOffset = line.align === 'center' - ? Math.max(0, (metrics.content.width - naturalWidth) / 2) + ? Math.max(0, (content.width - naturalWidth) / 2) : Number(line.offset || 0); - let x = metrics.content.x + centerOffset; - let wordIndex = 0; + let x = content.x + centerOffset; + const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps); + const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null; + const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : null; - ctx.font = `${fontStyle}${lineRecord.smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; + if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal'; + if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px'; + ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; if (lineRecord.dropCapText) { ctx.save(); const alpha = this.getWordAlpha(lineRecord, 0); @@ -176,20 +182,33 @@ class BookTextureRendererModule extends BaseModule { ctx.restore(); } else { ctx.globalAlpha *= alpha; - ctx.font = `${Math.round(lineHeightPx * 2.14)}px "EB Garamond Initials", ${metrics.typography.fontFamily}`; + ctx.font = `${Math.round(fontPx * 2.68)}px "EB Garamond Initials", ${metrics.typography.fontFamily}`; ctx.textBaseline = 'top'; ctx.fillText( String(lineRecord.dropCapText), - metrics.content.x, - metrics.content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) - (lineHeightPx * 0.05) + content.x, + content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25) ); ctx.restore(); } - ctx.font = `${fontStyle}${lineRecord.smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; + if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal'; + if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px'; + ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; } this.buildLineSegments(ctx, nodes, line, ratio).forEach((segment) => { this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex); }); + if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal'; + if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px'; + } + + getPageContent(side = 'left') { + return this.metrics?.contentBySide?.[side] || this.metrics?.content || { + x: 0, + y: 0, + width: this.metrics?.width || 1, + height: this.metrics?.height || 1 + }; } buildLineSegments(ctx, nodes = [], line = {}, ratio = 0) { diff --git a/public/js/loader.js b/public/js/loader.js index 39f0a2d..1cbd73b 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260606-webgl-texture-refresh-fix'; +const MODULE_CACHE_BUSTER = '20260607-webgl-page-uv-endpoints'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/procedural-book-model.js b/public/js/procedural-book-model.js index 577b273..e3bd10e 100644 --- a/public/js/procedural-book-model.js +++ b/public/js/procedural-book-model.js @@ -6,10 +6,10 @@ export const PROCEDURAL_BOOK = { PAGE_COUNT_STEP: 10, PAGE_LINE_SEGMENTS: 48, PAGE_DEPTH: 2.24, - PAGE_WIDTH: 2.24 * 2 / 3, + PAGE_WIDTH: 2.24 * (4.25 / 6.87), COVER_DEPTH: 2.30, OPEN_SEAM_GAP: 0.003, - PAGE_TEXTURE_FORE_EDGE_INSET_RATIO: 0.12, + PAGE_TEXTURE_FORE_EDGE_INSET_RATIO: 0.105, PROFILE: { tableY: 0, coverThickness: 0.03, @@ -566,12 +566,14 @@ function createLoftedLineBody(model, lines, depth) { v: (z + depth * 0.5) / depth }); const topCapUv = (point, z, col, row) => { - const side = lines[row]?.side ?? 1; - const pageDistance = side > 0 - ? point.x - model.spineHalf - : -model.spineHalf - point.x; - const textureInset = model.pageWidth * PROCEDURAL_BOOK.PAGE_TEXTURE_FORE_EDGE_INSET_RATIO; - const pageU = THREE.MathUtils.clamp(pageDistance / Math.max(0.001, model.pageWidth - textureInset), 0, 1); + const line = lines[row] || {}; + const side = line.side ?? 1; + const anchor = line.anchor || smoothLines[row][0] || { x: side * model.spineHalf }; + const endpoint = line.endpoint || smoothLines[row].at(-1) || { x: side * model.foreEdgeX }; + const pageDistance = side * (point.x - anchor.x); + const totalDistance = Math.max(0.001, side * (endpoint.x - anchor.x)); + const textureInset = totalDistance * PROCEDURAL_BOOK.PAGE_TEXTURE_FORE_EDGE_INSET_RATIO; + const pageU = THREE.MathUtils.clamp(pageDistance / Math.max(0.001, totalDistance - textureInset), 0, 1); return { u: side < 0 ? 1 - pageU : pageU, v: 1 - ((z + depth * 0.5) / depth) diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index bf1af60..33cf745 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js'; import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js'; import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js'; -import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-webgl-texture-refresh-fix'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-page-uv-endpoints'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab';