Skip to content

Width Tables of Built-in Fonts are Imprecise — Causes Centering Errors #3984

Description

@rona-101

Width Tables of Built-in Fonts are Imprecise — Causes Centering Errors


Summary

Text centering is off when using built-in standard PDF fonts. Investigation revealed two separate bugs working together.


Bug 1: Built-in font width tables have insufficient precision

The Adobe standard defines character widths in a 1000-unit em square. jsPDF stores these widths as integers in a 100-unit em table, then computes: entry × fontSize / 100. Because the entries are integers, any value that requires a decimal at 1/100th-em resolution gets truncated. For example, a is 55.6 units in a 100-unit em table (equivalent to 556 in the standard 1000-unit table), but is stored as 55, losing the .6. This truncation error affects every character whose correct 100-unit value is not a whole number.

Example at 22pt:

Character jsPDF entry jsPDF width Correct entry (1000-unit) Correct width Error
a 55 12.100pt 556 12.232pt −0.132pt
A 72 15.840pt 722 15.884pt −0.044pt
09 55 12.100pt 556 12.232pt −0.132pt
E 66 14.520pt 667 14.674pt −0.154pt

These errors accumulate across a string. A 20-character string can accumulate 1pt or more of total error, causing visibly off-center text.

The correct Adobe Helvetica Bold standard widths (1000-unit em) are:

 ' ':278, '!':333, '"':474, '#':556, '$':556, '%':889, '&':722,
 "'":278, '(':333, ')':333, '*':389, '+':584, ',':278, '-':333, '.':278, '/':278,
 '0'–'9': 556 each,
 ':':333, ';':333, '<':584, '=':584, '>':584, '?':611, '@':975,
 'A':722, 'B':722, 'C':722, 'D':722, 'E':667, 'F':611, 'G':778, 'H':722,
 'I':278, 'J':556, 'K':722, 'L':611, 'M':833, 'N':722, 'O':778, 'P':667,
 'Q':778, 'R':722, 'S':667, 'T':611, 'U':722, 'V':667, 'W':944,
 'X':667, 'Y':667, 'Z':611,
 'a':556, 'b':611, 'c':556, 'd':611, 'e':556, 'f':333, 'g':611, 'h':611,
 'i':278, 'j':278, 'k':556, 'l':278, 'm':889, 'n':611, 'o':611, 'p':611,
 'q':611, 'r':389, 's':556, 't':333, 'u':611, 'v':556, 'w':778,
 'x':556, 'y':556, 'z':500

These were verified by extracting actual character positions from generated PDFs using pdfplumber, then comparing to the Adobe Type 1 specification.

Fix: Store widths as the correct 1000-unit Adobe values divided by 10 (non-integer), and divide by 100 as now — or switch the divisor to 1000 and store the full integer values directly.


Bug 2: getTextWidth() subtracts kerning that doc.text() never applies

getTextWidth() subtracts kerning pair adjustments from the total string width. However, doc.text() does not apply those same kerning adjustments when actually placing characters on the page.

This means getTextWidth() returns a value smaller than the actual rendered string width for any string containing a kerning pair, making it unsuitable for computing centered or right-aligned text positions. Combined with Bug 1, the two errors compound each other for strings that contain kerning pairs.

Example:

doc.setFont('helvetica', 'bold');
doc.setFontSize(22);

// 'T ' is a kerning pair (kern value 10 → 2.200pt at 22pt)
doc.getTextWidth('Car: T records');  // returns 147.620pt
// But actual rendered width in PDF:  150.370pt
// Difference: 2.750pt
//   — 0.550pt from width table imprecision (Bug 1)
//   — 2.200pt from kerning subtracted by getTextWidth but not applied by doc.text (Bug 2)

Verified by extracting exact x0/x1 character positions from generated PDFs using pdfplumber.

Fix: Either apply kerning consistently in both getTextWidth() and doc.text(), or remove it from both. Currently the inconsistency means neither approach produces correct centered text.


Impact

Any developer using getTextWidth() to compute a centered or right-aligned text position will get incorrect results. The combination of both bugs means centering errors of 1pt or more are common for typical strings.


Workaround

Compute text width by summing individual character widths using the correct Adobe 1000-unit values, bypassing both getTextWidth() and the internal width table entirely:

const HELVETICA_BOLD_WIDTHS = {
  32:278, 33:333, 34:474, 35:556, 36:556, 37:889, 38:722, 39:278,
  40:333, 41:333, 42:389, 43:584, 44:278, 45:333, 46:278, 47:278,
  48:556, 49:556, 50:556, 51:556, 52:556, 53:556, 54:556, 55:556, 56:556, 57:556,
  58:333, 59:333, 60:584, 61:584, 62:584, 63:611, 64:975,
  65:722, 66:722, 67:722, 68:722, 69:667, 70:611, 71:778, 72:722,
  73:278, 74:556, 75:722, 76:611, 77:833, 78:722, 79:778, 80:667,
  81:778, 82:722, 83:667, 84:611, 85:722, 86:667, 87:944, 88:667, 89:667, 90:611,
  97:556, 98:611, 99:556, 100:611, 101:556, 102:333, 103:611, 104:611,
  105:278, 106:278, 107:556, 108:278, 109:889, 110:611, 111:611, 112:611,
  113:611, 114:389, 115:556, 116:333, 117:611, 118:556, 119:778, 120:556,
  121:556, 122:500,
};

function getCorrectTextWidth(text, fontSize) {
  let total = 0;
  for (const ch of text) {
    total += (HELVETICA_BOLD_WIDTHS[ch.charCodeAt(0)] || 556) * fontSize / 1000;
  }
  return total;
}

// Use instead of doc.getTextWidth(text):
const tx = cellX + (cellWidth - getCorrectTextWidth(line, fontSize)) / 2;
doc.text(line, tx, ty);

This produces centering accurate to within 0.001pt, verified via pdfplumber across 30+ strings at font sizes from 14pt to 22pt.


jsPDF versions tested: 2.5.1 (CDN) and 4.2.1 (latest) — bug confirmed present in both
Browser tested: Safari on macOS

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions