Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

### Unreleased

### [v0.17.2] - 2025-08-30

- Fix rendering lists that spans across pages

### [v0.17.1] - 2025-05-02

- Fix null values in table cells rendering as `[object Object]`
Expand Down
13 changes: 13 additions & 0 deletions jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"allowJs": true,
// Don't force external @types; our ambient file is included below
"baseUrl": "."
},
"include": [
"tests/**/*.js",
"types/**/*.d.ts"
]
}
5 changes: 2 additions & 3 deletions lib/line_wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ class LineWrapper extends EventEmitter {
// if we've reached the edge of the page,
// continue on a new page or column
if (PDFNumber(this.document.y + lh) > this.maxY) {
this.emit('sectionEnd', options, this);
const shouldContinue = this.nextSection();

// stop if we reached the maximum height
Expand All @@ -293,6 +294,7 @@ class LineWrapper extends EventEmitter {
buffer = '';
return false;
}
this.emit('sectionStart', options, this);
}

// reset the space left and buffer
Expand Down Expand Up @@ -335,8 +337,6 @@ class LineWrapper extends EventEmitter {
}

nextSection(options) {
this.emit('sectionEnd', options, this);

if (++this.column > this.columns) {
// if a max height was specified by the user, we're done.
// otherwise, the default is to make a new page at the bottom.
Expand All @@ -359,7 +359,6 @@ class LineWrapper extends EventEmitter {
this.emit('columnBreak', options, this);
}

this.emit('sectionStart', options, this);
return true;
}
}
Expand Down
44 changes: 25 additions & 19 deletions lib/mixins/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ import { cosine, sine } from '../utils';

const { number } = PDFObject;

/**
* Format a list label based on the list type
* @param {number} n
* @param {'numbered' | 'lettered'} listType
* @returns {string}
*/
function formatListLabel(n, listType) {
if (listType === 'numbered') {
return `${n}.`;
}

// lettered
var letter = String.fromCharCode(((n - 1) % 26) + 65);
var times = Math.floor((n - 1) / 26 + 1);
var text = Array(times + 1).join(letter);
return `${text}.`;
}

export default {
initText() {
this._line = this._line.bind(this);
Expand Down Expand Up @@ -252,7 +270,7 @@ export default {
return height;
},

list(list, x, y, options, wrapper) {
list(list, x, y, options) {
options = this._initOptions(x, y, options);

const listType = options.listType || 'bullet';
Expand Down Expand Up @@ -289,20 +307,8 @@ export default {

flatten(list);

const label = function (n) {
switch (listType) {
case 'numbered':
return `${n}.`;
case 'lettered':
var letter = String.fromCharCode(((n - 1) % 26) + 65);
var times = Math.floor((n - 1) / 26 + 1);
var text = Array(times + 1).join(letter);
return `${text}.`;
}
};

const drawListItem = function (listItem, i) {
wrapper = new LineWrapper(this, options);
const wrapper = new LineWrapper(this, options);
wrapper.on('line', this._line);

level = 1;
Expand Down Expand Up @@ -345,7 +351,7 @@ export default {
break;
case 'numbered':
case 'lettered':
var text = label(numbers[i - 1]);
var text = formatListLabel(numbers[i - 1], listType);
this._fragment(text, this.x - indent, this.y, options);
break;
}
Expand Down Expand Up @@ -435,12 +441,12 @@ export default {

_line(text, options = {}, wrapper) {
this._fragment(text, this.x, this.y, options);
const lineGap = options.lineGap || this._lineGap || 0;

if (!wrapper) {
this.x += this.widthOfString(text, options);
} else {
if (wrapper) {
const lineGap = options.lineGap || this._lineGap || 0;
this.y += this.currentLineHeight(true) + lineGap;
} else {
this.x += this.widthOfString(text, options);
}
},

Expand Down
13 changes: 6 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"document",
"vector"
],
"version": "0.17.1",
"version": "0.17.2",
"homepage": "http://pdfkit.org/",
"author": {
"name": "Devon Govett",
Expand Down Expand Up @@ -39,7 +39,6 @@
"gh-pages": "^6.2.0",
"globals": "^15.14.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-image-snapshot": "^6.4.0",
"markdown": "~0.5.0",
"pdfjs-dist": "^2.14.305",
Expand All @@ -66,9 +65,9 @@
"docs": "npm run pdf-guide && npm run website && npm run browserify-example",
"lint": "eslint {lib,tests}/**/*.js",
"prettier": "prettier lib tests docs",
"test": "jest -i --env=node",
"test:visual": "jest visual/ -i --env=node",
"test:unit": "jest unit/ --env=node"
"test": "jest",
"test:visual": "jest visual/",
"test:unit": "jest unit/"
},
"main": "js/pdfkit.js",
"module": "js/pdfkit.es.js",
Expand All @@ -81,7 +80,7 @@
"node >= v18.0.0"
],
"jest": {
"testEnvironment": "jest-environment-jsdom",
"testEnvironment": "node",
"testPathIgnorePatterns": [
"/node_modules/",
"<rootDir>/examples/"
Expand All @@ -90,4 +89,4 @@
"<rootDir>/tests/unit/setupTests.js"
]
}
}
}
127 changes: 87 additions & 40 deletions tests/unit/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @property {string} text
* @property {string} font
* @property {number} fontSize
* @property {number} x
* @property {number} y
*
* @typedef {string | Buffer} PDFDataItem
* @typedef {Array<PDFDataItem>} PDFData
Expand Down Expand Up @@ -68,54 +70,99 @@ function getObjects(data) {
}

/**
* @param {Buffer} textStream
* @return {TextStream | undefined}
* Parse all text objects (multiple TJ) in a decoded stream.
* @param {string} decodedStream
* @return {TextStream[]}
*/
function parseTextStream(textStream) {
const decodedStream = textStream.toString('utf8');
function parseTextStreams(decodedStream) {
const tjRegex = /\[([^\]]+)\]\s+TJ/g;
const fontRegex = /\/([A-Za-z0-9]+)\s+(\d+(?:\.\d+)?)\s+Tf/g;
const tmRegex =
/([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+Tm/g;
const cmRegex =
/([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+cm/g;
/** @type {TextStream[]} */
const results = [];
let tjMatch;

// Extract font and font size
const fontMatch = decodedStream.match(/\/([A-Za-z0-9]+)\s+(\d+)\s+Tf/);

if (!fontMatch) {
return undefined;
}

const font = fontMatch[1];
const fontSize = parseInt(fontMatch[2], 10);
while ((tjMatch = tjRegex.exec(decodedStream)) !== null) {
const tjIndex = tjMatch.index;
let fMatch;
let lastFontName;
let lastFontSize;
fontRegex.lastIndex = 0;
while (
(fMatch = fontRegex.exec(decodedStream)) !== null &&
fMatch.index < tjIndex
) {
lastFontName = fMatch[1];
lastFontSize = parseFloat(fMatch[2]);
}
if (!lastFontName || !lastFontSize) continue;

// Extract hex strings inside TJ array
const tjMatch = decodedStream.match(/\[([^\]]+)\]\s+TJ/);
if (!tjMatch) {
return undefined;
}
let text = '';
// Find the nearest preceding text matrix (Tm) and current transformation (cm)
let tmMatch;
let lastTm = undefined;
tmRegex.lastIndex = 0;
while (
(tmMatch = tmRegex.exec(decodedStream)) !== null &&
tmMatch.index < tjIndex
) {
lastTm = tmMatch;
}
// Default to origin if no Tm found
let tx = 0;
let ty = 0;
if (lastTm) {
tx = parseFloat(lastTm[5]);
ty = parseFloat(lastTm[6]);
}

// this is a simplified version
// the correct way is to retrieve the encoding from /Resources /Font dictionary and decode using it
// https://stackoverflow.com/a/29468049/5724645
// Find the nearest preceding cm (CTM)
let cmMatch;
let lastCm = undefined;
cmRegex.lastIndex = 0;
while (
(cmMatch = cmRegex.exec(decodedStream)) !== null &&
cmMatch.index < tjIndex
) {
lastCm = cmMatch;
}
// Apply transform: [a b c d e f] to point (tx, ty)
let x = tx;
let y = ty;
if (lastCm) {
const a = parseFloat(lastCm[1]);
const b = parseFloat(lastCm[2]);
const c = parseFloat(lastCm[3]);
const d = parseFloat(lastCm[4]);
const e = parseFloat(lastCm[5]);
const f = parseFloat(lastCm[6]);
x = a * tx + c * ty + e;
y = b * tx + d * ty + f;
}

// Match all hex strings like <...>
const hexMatches = [...tjMatch[1].matchAll(/<([0-9a-fA-F]+)>/g)];
for (const m of hexMatches) {
// Convert hex to string
const hex = m[1];
for (let i = 0; i < hex.length; i += 2) {
const code = parseInt(hex.substr(i, 2), 16);
let char = String.fromCharCode(code);
// Handle special cases
if (code === 0x0a) {
char = '\n'; // Newline
} else if (code === 0x0d) {
char = '\r'; // Carriage return
} else if (code === 0x85) {
char = '...';
const arrayContent = tjMatch[1];
let text = '';
const hexMatches = [...arrayContent.matchAll(/<([0-9a-fA-F]+)>/g)];
for (const m of hexMatches) {
const hex = m[1];
for (let i = 0; i < hex.length; i += 2) {
// this is a simplified version
// the correct way is to retrieve the encoding from /Resources /Font dictionary and decode using it
// https://stackoverflow.com/a/29468049/5724645
const code = parseInt(hex.substring(i, i + 2), 16);
let char = String.fromCharCode(code);
if (code === 0x0a) char = '\n';
else if (code === 0x0d) char = '\r';
else if (code === 0x85) char = '...';
text += char;
}
text += char;
}
results.push({ text, font: lastFontName, fontSize: lastFontSize, x, y });
}

return { text, font, fontSize };
return results;
}

export { logData, joinTokens, parseTextStream, getObjects };
export { logData, joinTokens, parseTextStreams, getObjects };
Loading
Loading