feat(book): J4.e — real PDF text for threaded text frames

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-06-05 09:03:24 -04:00
1 parent 75fc29533e
commit 18dbe54a59
1 file changed
+43 -4
+43 -4
View File
@@ -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}`);