Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 23 additions & 1 deletion lib/table/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/table.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading