Fix inflated word spacing in PDF description text
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s

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 <[email protected]>
This commit is contained in:
2026-06-13 21:42:26 -05:00
co-authored by Claude Sonnet 4.6
parent 0ead121327
commit f094bbbd73
2 changed files with 41 additions and 12 deletions
+29
View File
@@ -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 } = {}) { rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) {
const y = this.pageHeight - yTop - h; const y = this.pageHeight - yTop - h;
const ops = []; const ops = [];
+12 -12
View File
@@ -148,12 +148,12 @@ function exportPdf(ast, outDir, template = {}) {
ensure(item.lineHeight); ensure(item.lineHeight);
const textX = M + indentBase + item.indent; 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 }); if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, y, { size: item.size, font: 'F1', color });
let x = textX; const parts = line.map((word) => ({
for (const word of line) { text: word.text,
const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color); font: word.font,
pdf.text(word.text, x, y, { size: item.size, font: word.font, color: c }); color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color),
x += pdf.textWidth(word.text, item.size, word.font); }));
} pdf.textRun(parts, textX, y, item.size);
y += item.lineHeight; y += item.lineHeight;
}); });
y += 4; y += 4;
@@ -271,12 +271,12 @@ function exportPdf(ast, outDir, template = {}) {
item.lines.forEach((line, idx) => { item.lines.forEach((line, idx) => {
const textX = M + 10 + item.indent; const textX = M + 10 + item.indent;
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: 'F1' }); if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: 'F1' });
let x = textX; const parts = line.map((word) => ({
for (const word of line) { text: word.text,
const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]); font: word.font,
pdf.text(word.text, x, by, { size: item.size, font: word.font, color: c }); color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]),
x += pdf.textWidth(word.text, item.size, word.font); }));
} pdf.textRun(parts, textX, by, item.size);
by += item.lineHeight; by += item.lineHeight;
}); });
by += 4; by += 4;