From 18dbe54a59fbb206b02130b7b2445c0305047e85 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:03:24 -0400 Subject: [PATCH] =?UTF-8?q?feat(book):=20J4.e=20=E2=80=94=20real=20PDF=20t?= =?UTF-8?q?ext=20for=20threaded=20text=20frames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF export now emits selectable + searchable PDF text on top of the threaded-frame raster, the same way the existing reflow-entry path does. Result: text in threaded frames is searchable in any PDF viewer, copy-pasteable, and accessibility-tree-visible — not just pixels. What changed: - _drawFrameLinesToCanvas accepts optional textOps + fontWarnings args. When provided, each rendered line gets pushed as a textOp describing {text, fontKey, sizePt, color, align, leftIn, widthIn, topInchesFromTrim}. Unsupported font families (anything outside pdf-lib's standard 14) are collected into fontWarnings. - Empty / whitespace-only lines skipped (no glyphs to emit). - renderBookDesignedPageToCanvas accepts textOps + fontWarnings and threads them to _drawFrameLinesToCanvas. - exportBookToPdf populates textOps for designed pages too — same pattern the reflow branch already used. The existing pdf-lib drawText loop after the PNG embed handles both paths since the op shape is identical. Limitations: - Bold / italic per-line not yet supported — typography stores family + size + color + alignment + line-height + paragraph gap, but not weight or style flags. Adding weight: 'bold' / italic to the typography schema is a small follow-up. - Only the 14 standard PDF fonts get real-text emission. Custom families fall back to raster-only (with a fontWarning surfaced in the export status). Co-Authored-By: Claude Opus 4.7 --- src/index.html | 47 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/index.html b/src/index.html index 98f70dc..625cd10 100644 --- a/src/index.html +++ b/src/index.html @@ -29140,7 +29140,7 @@ // Render one designed page (master underlay + page layers) to a // print-DPI canvas. Token substitution is applied to master text // layers so `{page-number}` etc resolve correctly per page. - async function renderBookDesignedPageToCanvas(entry, dpi, ctxToken) { + async function renderBookDesignedPageToCanvas(entry, dpi, ctxToken, textOps = null, fontWarnings = null) { const book = state.book || {}; const spec = book.spec || {}; const trimW = Number(spec.trimW) || 6; @@ -29200,7 +29200,7 @@ 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, localTypography); + _drawFrameLinesToCanvas(ctx, slice, layer, dpi, bleed, localTypography, textOps, fontWarnings); } return canvas; } @@ -29209,7 +29209,14 @@ // _paintFrameLines but writes onto a Canvas2D context (the PDF // export's per-page raster). Coordinates: frame is sheet-relative // (includes bleed); canvas is trim-only, so subtract bleed. - function _drawFrameLinesToCanvas(ctx, frameSlice, frameLayer, dpi, bleed, typography) { + // + // J4.e (#4): when `textOps` is provided (PDF export path), also + // emit selectable + searchable real-text descriptors that + // exportBookToPdf overlays on top of the raster via pdf-lib's + // drawText. This is what the reflow path has been doing — now + // threaded frames participate too. `fontWarnings` collects + // unsupported font families for user feedback. + function _drawFrameLinesToCanvas(ctx, frameSlice, frameLayer, dpi, bleed, typography, textOps = null, fontWarnings = null) { const padding = Number(frameLayer.shape?.padding) || 0; const innerX = (Number(frameLayer.x) || 0) + padding - bleed; const innerY = (Number(frameLayer.y) || 0) + padding - bleed; @@ -29224,14 +29231,42 @@ : typography.alignment === 'right' ? 'right' : 'left'; const lines = frameSlice.lines || []; const breaksAfter = frameSlice.paragraphBreaksAfter || new Set(); + // J4.e: probe font + record warning. Typography defaults to + // 'Georgia, serif'; that lacks bold/italic flags here — threaded + // typography doesn't carry per-frame weights yet, so we pass + // false/false. Bold/italic per-line is a later enhancement. + const collect = Array.isArray(textOps); + const fontKey = collect ? _resolveStandardFontKey(typography.fontFamily, false, false) : null; + if (collect && !fontKey && fontWarnings) fontWarnings.add(typography.fontFamily); + const lineHIn = lineH / dpi; + const innerYIn = innerY; let yPx = innerY * dpi; + let yLineIdx = 0; // independent of breaks for textOps positioning for (let i = 0; i < lines.length; i++) { let xPx; if (typography.alignment === 'center') xPx = (innerX + innerW / 2) * dpi; else if (typography.alignment === 'right') xPx = (innerX + innerW) * dpi; else xPx = innerX * dpi; ctx.fillText(lines[i], xPx, yPx); + // Real-text op for the PDF text layer. Coords are inches + // from the trim corner (exportBookToPdf converts to pdf-lib's + // bottom-up point grid). Empty lines skipped — no glyphs. + if (collect && fontKey && lines[i] && lines[i].trim()) { + const topInchesFromTrim = innerYIn + (yPx - innerY * dpi) / dpi; + textOps.push({ + text: lines[i], + fontKey, + sizePt: typography.fontSize, + color: typography.color, + align: typography.alignment === 'center' ? 'center' : + typography.alignment === 'right' ? 'right' : 'left', + leftIn: innerX, + widthIn: innerW, + topInchesFromTrim, + }); + } yPx += lineH; + yLineIdx++; if (breaksAfter.has(i)) yPx += paragraphGapPx; } } @@ -29589,7 +29624,11 @@ textOps = []; canvas = await renderBookReflowPhysicalPageToCanvas(entry, pag, slot.indexInEntry, dpi, ctxToken, textOps, fallbackFontFamilies); } else { - canvas = await renderBookDesignedPageToCanvas(entry, dpi, ctxToken); + // J4.e: designed-page export also collects textOps for + // any threaded text frames on the page — overlaid as + // selectable/searchable PDF text on top of the raster. + textOps = []; + canvas = await renderBookDesignedPageToCanvas(entry, dpi, ctxToken, textOps, fallbackFontFamilies); } } catch (err) { warnings.push(`Page ${(slot.pageNumber != null) ? slot.pageNumber : '?'}: ${err.message || err}`);