From 67bc73648e0e847ea5cfca8ea3e4020011d25213 Mon Sep 17 00:00:00 2001 From: Logan Besecker <3487677+lbesecker195@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:29:21 -0700 Subject: [PATCH] Fix accents cut off in the first line of table cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text mask of a table cell started exactly at the top padding, where the first line begins at the font ascender. Accented capitals such as Ä and Õ rise above the ascender, so their accents were clipped in the first line only. Extend the mask into the top padding by the height of the font bbox above the ascender, without going past the cell. Fixes #1720 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + lib/table/render.js | 24 ++++++++++++++++++- tests/unit/table.spec.js | 51 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a94d8f0..b9d28037b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Add a `hidden` option to form annotation methods, for a field that should start hidden (e.g. one an interactive action reveals later) instead of the usual default of visible and printable - Fix annotations placed under `doc.rotate()` marking the wrong area, because `_convertRect` derived each corner's y from the already transformed x and mapped only two of the four corners, so the rectangle a viewer makes interactive did not follow the rotated content. Fixes #1153 - Add `onClick`, `onMouseDown`, `onMouseEnter`, `onMouseExit`, `onFocus` and `onBlur` options to form annotation methods, for the JavaScript a field runs on each of those events. Each accepts a string or a plain function, whose source text is written into the action +- Fix the accents of capitals such as Ä and Õ being cut off in the first line of a table cell, because the text mask started exactly at the top padding while those glyphs rise above the font ascender where the first line begins. Fixes #1720 ### [v0.20.2] - 2026-08-29 diff --git a/lib/table/render.js b/lib/table/render.js index 751bb6fcf..96bae5584 100644 --- a/lib/table/render.js +++ b/lib/table/render.js @@ -134,7 +134,13 @@ function renderCellText(cell) { // Create text mask to cut off any overflowing text // Mask cuts off at the padding not the actual cell, this is intentional! - doc.save().rect(x, y, Aw, Ah).clip(); + // The first line starts at the font's ascender, but accented capitals can + // rise above it, so the mask extends into the top padding by that amount. + const maskOvershoot = Math.min(glyphOvershoot(doc), cell.padding.top); + doc + .save() + .rect(x, y - maskOvershoot, Aw, Ah + maskOvershoot) + .clip(); doc.fillColor(cell.textColor).strokeColor(cell.textStrokeColor); if (cell.textStroke > 0) doc.lineWidth(cell.textStroke); @@ -147,6 +153,22 @@ function renderCellText(cell) { if (cell.font) doc.font(rollbackFont, rollbackFontFamily, rollbackFontSize); } +/** + * How far the glyphs of the current font can rise above its ascender + * + * @param {PDFDocument} doc + * @returns {number} + * @private + */ +function glyphOvershoot(doc) { + const font = doc._font; + const bbox = font?.bbox; + if (!bbox) return 0; + // Standard fonts store the bbox in 1000 units, embedded fonts in font units + const top = Array.isArray(bbox) ? bbox[3] : bbox.maxY * font.scale; + return (Math.max(0, top - font.ascender) / 1000) * doc._fontSize; +} + /** * @this PDFTable * @memberOf PDFTable diff --git a/tests/unit/table.spec.js b/tests/unit/table.spec.js index b7e3d7e5b..ffd6b7fa4 100644 --- a/tests/unit/table.spec.js +++ b/tests/unit/table.spec.js @@ -69,6 +69,57 @@ describe('table', () => { expect(spy).toHaveBeenCalledWith(REGULAR, 'Condensed'); }); }); + + describe('text mask', () => { + // Returns the rect of each clip applied while rendering + function textMasks(document, render) { + const rect = vi.spyOn(document, 'rect'); + const clip = vi.spyOn(document, 'clip'); + render(); + return clip.mock.invocationCallOrder.map((order) => { + const i = rect.mock.invocationCallOrder.findLastIndex((o) => o < order); + return rect.mock.calls[i]; + }); + } + + test('leaves room for glyphs that rise above the ascender', () => { + // Accented capitals such as Ä and Õ rise above the Helvetica ascender + // (718), up to the top of its bbox (931). The first line starts at the + // ascender, so a mask at the top padding cut off their accents. + const document = new PDFDocument({ margin: 0 }); + const [[, y, , height]] = textMasks(document, () => + document.table().row(['ÕÜÖÄ'], true), + ); + const padding = 3; // default 0.25em at 12pt + const overshoot = ((931 - 718) / 1000) * 12; + expect(y).toBeCloseTo(padding - overshoot); + // the bottom of the mask still stops at the padding + expect(y + height).toBeCloseTo(document.y - padding); + }); + + test('does not extend past the top of the cell', () => { + const document = new PDFDocument({ margin: 0 }); + const [[, y, , height]] = textMasks(document, () => + document.table({ defaultStyle: { padding: 1 } }).row(['ÕÜÖÄ'], true), + ); + expect(y).toBeCloseTo(0); + expect(y + height).toBeCloseTo(document.y - 1); + }); + + test('uses the bbox of an embedded font', () => { + const document = new PDFDocument({ + margin: 0, + font: 'tests/fonts/Roboto-Regular.ttf', + }); + const [[, y]] = textMasks(document, () => + document.table().row(['ÕÜÖÄ'], true), + ); + const { font } = document._font; + const overshoot = ((font.bbox.maxY - font.ascent) / font.unitsPerEm) * 12; + expect(overshoot).toBeGreaterThan(0); + expect(y).toBeCloseTo(3 - overshoot); + }); + }); }); describe('utils', () => {