diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a94d8f..b9d28037 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 751bb6fc..96bae558 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 b7e3d7e5..ffd6b7fa 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', () => {