From 3d74689eb0146a9eed2f75ea54f357604b311fb3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:47:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(book):=20J4.d.2=20=E2=80=94=20per-frame=20?= =?UTF-8?q?typography=20+=20144=20dpi=20tile=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paired improvements: 1. Pages-overview tile thumbnails now render at 144 dpi instead of 72. At typical grid widths (~150 px tiles) the 72 dpi raster shrank text to ~3 px tall — illegible. 144 dpi quadruples the pixel count per tile (~16 ms → ~60 ms per tile on the work I profiled informally) but text becomes readable. A content-hash cache is the next perf step if grids get large. 2. Text-frame typography is now per-frame, not per-thread. Each frame in the chain can override font / size / line-height / alignment / color / paragraph gap. The head's typography is the fallback when a frame doesn't set its own. - _paginateParagraphsIntoFrames reads typography fresh on every frame advance (and at paragraph start) so size differences flow correctly — when text crosses into a 14pt frame from a 10pt frame, line heights adjust mid-flow. - Painters (_paintFrameLines DOM + _drawFrameLinesToCanvas canvas) receive each frame's own typography rather than the head's. - Inspector typography controls are now editable on every frame (not just the head). Writes go to layer.typography on the selected frame. The status hint distinguishes head ('Applies to this frame. The head\\'s typography is also the chain\\'s fallback.') from mid-thread ('Applies to this frame only — overrides the head\\'s default.'). Known limitation: when a paragraph spills across a typography boundary, the spilled lines were wrapped at the source frame's width. They place + render at the destination's typography but keep the source frame's line breaks. Re-wrap-on-advance is J4.d.3. Co-Authored-By: Claude Opus 4.7 --- src/index.html | 115 +++++++++++++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 46 deletions(-) diff --git a/src/index.html b/src/index.html index 6bf8b74..e360766 100644 --- a/src/index.html +++ b/src/index.html @@ -10651,7 +10651,13 @@ // exist). thumb.style.background = p.background || book.spec?.background || '#ffffff'; const ctxToken = bookTokenContext(book, p.id, 0); - renderBookDesignedPageToCanvas(p, 72, ctxToken).then(canvas => { + // 144 dpi instead of 72 — at typical grid widths (~150 px + // tile) the 72 dpi raster's text scaled below ~3 px, which + // was illegible. 144 dpi quadruples the pixel count + // (~16 ms → ~60 ms per tile) but text becomes readable in + // the thumb. Cache by content hash is the next perf step + // if grids get big. + renderBookDesignedPageToCanvas(p, 144, ctxToken).then(canvas => { if (!thumb.isConnected) return; const img = document.createElement('img'); img.src = canvas.toDataURL('image/png'); @@ -23332,12 +23338,17 @@ // breaks within a frame add `paragraphSpacing` inches of gap. // // Returns { perFrame: [{ lines: [...] }, ...], hasMoreText, ... } - function _paginateParagraphsIntoFrames(paragraphs, chain, typography) { + function _paginateParagraphsIntoFrames(paragraphs, chain, fallbackTypography) { + // fallbackTypography is the head's typography — used when a + // mid-thread frame doesn't have its own. J4.d.2: each frame can + // override its own font, size, line-height, alignment, color, + // and paragraph gap. The paginator re-reads typography on every + // frame advance so size differences flow correctly. const perFrame = chain.map(() => ({ lines: [], paragraphBreaksAfter: new Set() })); const ctx = document.createElement('canvas').getContext('2d'); - ctx.font = `${typography.fontSize * 96 / 72}px ${typography.fontFamily}`; - const lineHIn = (typography.fontSize * 96 / 72 / 96) * typography.lineHeight; - const paragraphGapIn = Math.max(0, typography.paragraphSpacing); + const typoFor = (i) => _getFrameTypography(chain[i] || null) || fallbackTypography; + const lineHFor = (t) => (t.fontSize * 96 / 72 / 96) * t.lineHeight; + const setFontFor = (t) => { ctx.font = `${t.fontSize * 96 / 72}px ${t.fontFamily}`; }; let frameIdx = 0; let yCursor = 0; // inches consumed in current frame const advanceFrame = () => { @@ -23346,11 +23357,16 @@ }; for (let pi = 0; pi < paragraphs.length; pi++) { if (frameIdx >= chain.length) break; - // Wrap paragraph into lines for the CURRENT frame's width. + // Wrap paragraph into lines for the CURRENT frame's width + // AND typography. If the paragraph spills into a frame with + // different typography mid-flow, the spilled lines were + // computed at the old width/font — minor visual artifact but + // they still place. (J4.d.3 would re-wrap on advance.) const frame = chain[frameIdx]; const padding = Number(frame.shape?.padding) || 0; const innerW = Math.max(0.1, (Number(frame.w) || 0) - 2 * padding); - const innerH = Math.max(0.1, (Number(frame.h) || 0) - 2 * padding); + const startTypo = typoFor(frameIdx); + setFontFor(startTypo); const words = paragraphs[pi].split(/\s+/).filter(Boolean); let line = ''; const linesForPara = []; @@ -23366,37 +23382,38 @@ if (line) linesForPara.push(line); // Place lines into frames, advancing as each frame fills. + // Line height tracks the CURRENT frame's typography — when + // we cross a boundary into a frame with a different size, + // the per-line advance picks up the new height. + let curLineH = lineHFor(typoFor(frameIdx)); for (let li = 0; li < linesForPara.length; li++) { if (frameIdx >= chain.length) break; - let curFrame = chain[frameIdx]; - let curInnerH = Math.max(0.1, (Number(curFrame.h) || 0) - 2 * (Number(curFrame.shape?.padding) || 0)); - // Recompute line height for the current frame in case we - // moved across a boundary where typography changes (J4.d.2; - // today typography is uniform across the chain). - if (yCursor + lineHIn > curInnerH && perFrame[frameIdx].lines.length > 0) { + const curFrame = chain[frameIdx]; + const curInnerH = Math.max(0.1, (Number(curFrame.h) || 0) - 2 * (Number(curFrame.shape?.padding) || 0)); + if (yCursor + curLineH > curInnerH && perFrame[frameIdx].lines.length > 0) { advanceFrame(); if (frameIdx >= chain.length) break; - // Re-wrap remaining lines if the new frame has a different - // width — for now we keep the same wrap (same typography), - // so the line just slots into the next frame. + curLineH = lineHFor(typoFor(frameIdx)); } if (frameIdx >= chain.length) break; perFrame[frameIdx].lines.push(linesForPara[li]); - yCursor += lineHIn; + yCursor += curLineH; } - // Paragraph break after this paragraph — adds vertical - // spacing in the current frame. + // Paragraph break after this paragraph — gap from the + // typography of the frame where the break lands. if (pi < paragraphs.length - 1 && frameIdx < chain.length) { perFrame[frameIdx].paragraphBreaksAfter.add(perFrame[frameIdx].lines.length - 1); - yCursor += paragraphGapIn; + yCursor += Math.max(0, typoFor(frameIdx).paragraphSpacing); } } - const consumed = paragraphs.length; - // Compute whether any text didn't make it. + // Overset stats: probe against the head frame's typography + // + width. Approximation when typography varies per frame, but + // close enough for the "X of Y lines fit" warning. let totalLinesPlaced = 0; for (const pf of perFrame) totalLinesPlaced += pf.lines.length; - // Re-wrap full text against head width for a hasMoreText probe. const headFrame = chain[0]; + const headTypo = typoFor(0); + setFontFor(headTypo); const headInnerW = Math.max(0.1, (Number(headFrame?.w) || 0) - 2 * (Number(headFrame?.shape?.padding) || 0)); let totalLinesNeeded = 0; for (const p of paragraphs) { @@ -23838,12 +23855,12 @@ }; } - // J4.d — typography controls. They edit the HEAD frame's - // typography (the whole thread inherits via _getFrameTypography - // in the paginator). On a mid-thread frame we still surface - // the head's values but disable editing — keeps state singular - // per thread. - const typo = _getFrameTypography(head); + // J4.d.2 — typography is now per-frame. Each frame in the + // chain can override its own font / size / line-height / + // alignment / color / paragraph gap. The chain still reads + // the head's typography as a fallback for any field a frame + // doesn't set explicitly. + const typo = _getFrameTypography(layer); const fontSel = document.getElementById('bookTextFrameFont'); if (fontSel && !fontSel.dataset.populated) { // Reuse the existing font list the text tool uses. @@ -23867,20 +23884,20 @@ const paraIn = document.getElementById('bookTextFrameParaSpace'); const alignSel = document.getElementById('bookTextFrameAlign'); const typoHint = document.getElementById('bookTextFrameTypoHint'); - const editable = isHead; - if (fontSel) { fontSel.value = typo.fontFamily; fontSel.disabled = !editable; } - if (sizeIn) { sizeIn.value = typo.fontSize; sizeIn.disabled = !editable; } - if (colorIn) { colorIn.value = typo.color; colorIn.disabled = !editable; } - if (lhIn) { lhIn.value = typo.lineHeight; lhIn.disabled = !editable; } - if (paraIn) { paraIn.value = typo.paragraphSpacing; paraIn.disabled = !editable; } - if (alignSel) { alignSel.value = typo.alignment; alignSel.disabled = !editable; } - if (typoHint) typoHint.textContent = editable - ? 'Applies to every frame in this thread.' - : 'Edit typography on the head frame; mid-thread frames inherit.'; + if (fontSel) { fontSel.value = typo.fontFamily; fontSel.disabled = false; } + if (sizeIn) { sizeIn.value = typo.fontSize; sizeIn.disabled = false; } + if (colorIn) { colorIn.value = typo.color; colorIn.disabled = false; } + if (lhIn) { lhIn.value = typo.lineHeight; lhIn.disabled = false; } + if (paraIn) { paraIn.value = typo.paragraphSpacing; paraIn.disabled = false; } + if (alignSel) { alignSel.value = typo.alignment; alignSel.disabled = false; } + if (typoHint) typoHint.textContent = isHead + ? 'Applies to this frame. The head\'s typography is also the chain\'s fallback.' + : 'Applies to this frame only — overrides the head\'s default.'; const writeTypo = (patch) => { pushHistory('book: edit text-frame typography'); - head.typography = { ..._getFrameTypography(head), ...patch }; + // Writes to THIS layer, not the head — per-frame override. + layer.typography = { ..._getFrameTypography(layer), ...patch }; render(); }; if (fontSel) fontSel.onchange = () => writeTypo({ fontFamily: fontSel.value }); @@ -24131,12 +24148,15 @@ // formatting via the inspector). const entry = (state.book?.pages || []).find(p => p.id === head.sourceChapter); const paragraphs = _flattenChapterToParagraphs(entry?.chapterMarkdown || ''); - const typography = _getFrameTypography(head); // head defines style for the thread - const paginated = _paginateParagraphsIntoFrames(paragraphs, chain, typography); + // J4.d.2: head's typography is the chain's default; each + // frame paints with its OWN typography (overrides allowed). + const fallbackTypography = _getFrameTypography(head); + const localTypography = _getFrameTypography(layer); + const paginated = _paginateParagraphsIntoFrames(paragraphs, chain, fallbackTypography); const sliceForThisFrame = paginated.perFrame[myIndex]; const padding = Number(layer.shape?.padding) || 0; if (sliceForThisFrame && sliceForThisFrame.lines.length) { - _paintFrameLines(el, sliceForThisFrame, Number(layer.w) || 0, Number(layer.h) || 0, padding, typography); + _paintFrameLines(el, sliceForThisFrame, Number(layer.w) || 0, Number(layer.h) || 0, padding, localTypography); if (myIndex === chain.length - 1 && paginated.hasMoreText) { const overset = document.createElement('div'); overset.style.cssText = 'position:absolute;bottom:6px;right:6px;background:#fee2e2;color:#991b1b;border:1px solid #fca5a5;border-radius:3px;padding:1px 6px;font-size:.62rem;font-weight:700;pointer-events:none;z-index:9;letter-spacing:.05em'; @@ -28964,13 +28984,16 @@ const entry = (book.pages || []).find(p => p.id === head.sourceChapter); if (!entry) continue; const paragraphs = _flattenChapterToParagraphs(entry.chapterMarkdown || ''); - const typography = _getFrameTypography(head); + // J4.d.2 — head typography is the fallback; each frame's own + // typography wins when present. + const fallbackTypography = _getFrameTypography(head); + const localTypography = _getFrameTypography(layer); let paginated; - try { paginated = _paginateParagraphsIntoFrames(paragraphs, chain, typography); } + try { paginated = _paginateParagraphsIntoFrames(paragraphs, chain, fallbackTypography); } catch (err) { console.warn('PDF thread paginate failed', err); continue; } const slice = paginated.perFrame[myIndex]; if (!slice || !slice.lines.length) continue; - _drawFrameLinesToCanvas(ctx, slice, layer, dpi, bleed, typography); + _drawFrameLinesToCanvas(ctx, slice, layer, dpi, bleed, localTypography); } return canvas; }