From f094bbbd73e6b2a290b75fa35fddb5347c2b80bd Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 21:42:26 -0500 Subject: [PATCH] Fix inflated word spacing in PDF description text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeDescription/emitBlocks rendered each word as a separately positioned Tj, advancing by our approximate average-glyph-width estimate — which is much wider than real space/character widths, producing "This is bold" style gaps. Render each line as one continuous text object instead (PdfBuilder.textRun), so consecutive Tj operators advance using the viewer's real font metrics. Co-Authored-By: Claude Sonnet 4.6 --- core/pdf.js | 29 +++++++++++++++++++++++++++++ exporters/pdf.js | 24 ++++++++++++------------ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/core/pdf.js b/core/pdf.js index 89bc6f2..21febb7 100644 --- a/core/pdf.js +++ b/core/pdf.js @@ -102,6 +102,35 @@ class PdfBuilder { ); } + /** + * Render a line made of mixed-font/color segments (e.g. bold/italic runs) + * as one continuous text object: consecutive Tj operators without an + * intervening Tm advance the cursor by each font's real glyph widths, so + * spacing matches the viewer's metrics exactly (unlike our approximate + * textWidth(), which is only used for word-wrap decisions). + */ + textRun(parts, x, yTop, size) { + const y = this.pageHeight - yTop - size; + const ops = [`BT 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm`]; + let curFont = null; + let curColor = null; + for (const part of parts) { + if (!part.text) continue; + if (part.font !== curFont) { + ops.push(`/${part.font} ${size} Tf`); + curFont = part.font; + } + if (!curColor || part.color[0] !== curColor[0] || part.color[1] !== curColor[1] || part.color[2] !== curColor[2]) { + ops.push(`${col(part.color)} rg`); + curColor = part.color; + } + const clean = String(part.text).replace(/\t/g, ' '); + ops.push(`(${esc(toLatin1(clean))}) Tj`); + } + ops.push('ET'); + this.currentPage.ops.push(ops.join(' ')); + } + rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) { const y = this.pageHeight - yTop - h; const ops = []; diff --git a/exporters/pdf.js b/exporters/pdf.js index e5eb121..bf3d5e7 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -148,12 +148,12 @@ function exportPdf(ast, outDir, template = {}) { ensure(item.lineHeight); const textX = M + indentBase + item.indent; if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, y, { size: item.size, font: 'F1', color }); - let x = textX; - for (const word of line) { - const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color); - pdf.text(word.text, x, y, { size: item.size, font: word.font, color: c }); - x += pdf.textWidth(word.text, item.size, word.font); - } + const parts = line.map((word) => ({ + text: word.text, + font: word.font, + color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color), + })); + pdf.textRun(parts, textX, y, item.size); y += item.lineHeight; }); y += 4; @@ -271,12 +271,12 @@ function exportPdf(ast, outDir, template = {}) { item.lines.forEach((line, idx) => { const textX = M + 10 + item.indent; if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: 'F1' }); - let x = textX; - for (const word of line) { - const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]); - pdf.text(word.text, x, by, { size: item.size, font: word.font, color: c }); - x += pdf.textWidth(word.text, item.size, word.font); - } + const parts = line.map((word) => ({ + text: word.text, + font: word.font, + color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]), + })); + pdf.textRun(parts, textX, by, item.size); by += item.lineHeight; }); by += 4;