Skip to content

Commit cbce714

Browse files
authored
feat(pdfkit): add table mixin (#3386)
* feat(pdfkit): add table mixin * chore: add changesets
1 parent 143af59 commit cbce714

11 files changed

Lines changed: 1622 additions & 18 deletions

File tree

.changeset/stale-pens-train.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@react-pdf/pdfkit": patch
3+
"@react-pdf/renderer": patch
4+
---
5+
6+
Add pdfkit table mixin as part of unification plan

packages/pdfkit/src/document.js

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import AcroFormMixin from './mixins/acroform';
2121
import AttachmentsMixin from './mixins/attachments';
2222
import LineWrapper from './line_wrapper';
2323
import SubsetMixin from './mixins/subsets';
24+
import TableMixin from './mixins/table';
2425
import MetadataMixin from './mixins/metadata';
2526

2627
class PDFDocument extends stream.Readable {
@@ -63,23 +64,29 @@ class PDFDocument extends stream.Readable {
6364
const Pages = this.ref({
6465
Type: 'Pages',
6566
Count: 0,
66-
Kids: []
67+
Kids: [],
6768
});
6869

6970
const Names = this.ref({
70-
Dests: new PDFNameTree()
71+
Dests: new PDFNameTree(),
7172
});
7273

7374
this._root = this.ref({
7475
Type: 'Catalog',
7576
Pages,
76-
Names
77+
Names,
7778
});
7879

7980
if (this.options.lang) {
8081
this._root.data.Lang = new String(this.options.lang);
8182
}
8283

84+
if (this.options.pageLayout) {
85+
const layout = this.options.pageLayout;
86+
this._root.data.PageLayout =
87+
layout.charAt(0).toUpperCase() + layout.slice(1);
88+
}
89+
8390
// The current page
8491
this.page = null;
8592

@@ -92,13 +99,14 @@ class PDFDocument extends stream.Readable {
9299
this.initImages();
93100
this.initOutline();
94101
this.initMarkings(options);
102+
this.initTables();
95103
this.initSubset(options);
96104

97105
// Initialize the metadata
98106
this.info = {
99107
Producer: 'PDFKit',
100108
Creator: 'PDFKit',
101-
CreationDate: new Date()
109+
CreationDate: new Date(),
102110
};
103111

104112
if (this.options.info) {
@@ -110,7 +118,7 @@ class PDFDocument extends stream.Readable {
110118

111119
if (this.options.displayTitle) {
112120
this._root.data.ViewerPreferences = this.ref({
113-
DisplayDocTitle: true
121+
DisplayDocTitle: true,
114122
});
115123
}
116124

@@ -186,7 +194,7 @@ class PDFDocument extends stream.Readable {
186194
throw new Error(
187195
`switchToPage(${n}) out of bounds, current buffer covers pages ${
188196
this._pageBufferStart
189-
} to ${this._pageBufferStart + this._pageBuffer.length - 1}`
197+
} to ${this._pageBufferStart + this._pageBuffer.length - 1}`,
190198
);
191199
}
192200

@@ -220,7 +228,7 @@ class PDFDocument extends stream.Readable {
220228
if (!this._root.data.Names.data.EmbeddedFiles) {
221229
// disabling /Limits for this tree fixes attachments not showing in Adobe Reader
222230
this._root.data.Names.data.EmbeddedFiles = new PDFNameTree({
223-
limits: false
231+
limits: false,
224232
});
225233
}
226234

@@ -234,7 +242,7 @@ class PDFDocument extends stream.Readable {
234242
}
235243
let data = {
236244
JS: new String(js),
237-
S: 'JavaScript'
245+
S: 'JavaScript',
238246
};
239247
this._root.data.Names.data.JavaScript.add(name, data);
240248
}
@@ -255,7 +263,7 @@ class PDFDocument extends stream.Readable {
255263
}
256264

257265
this.push(data);
258-
return (this._offset += data.length);
266+
this._offset += data.length;
259267
}
260268

261269
addContent(data) {
@@ -267,7 +275,7 @@ class PDFDocument extends stream.Readable {
267275
this._offsets[ref.id - 1] = ref.offset;
268276
if (--this._waiting === 0 && this._ended) {
269277
this._finalize();
270-
return (this._ended = false);
278+
this._ended = false;
271279
}
272280
}
273281

@@ -317,9 +325,9 @@ class PDFDocument extends stream.Readable {
317325
}
318326

319327
if (this._waiting === 0) {
320-
return this._finalize();
328+
this._finalize();
321329
} else {
322-
return (this._ended = true);
330+
this._ended = true;
323331
}
324332
}
325333

@@ -340,7 +348,7 @@ class PDFDocument extends stream.Readable {
340348
Size: this._offsets.length + 1,
341349
Root: this._root,
342350
Info: this._info,
343-
ID: [this._id, this._id]
351+
ID: [this._id, this._id],
344352
};
345353
if (this._security) {
346354
trailer.Encrypt = this._security.dictionary;
@@ -354,7 +362,7 @@ class PDFDocument extends stream.Readable {
354362
this._write('%%EOF');
355363

356364
// end the stream
357-
return this.push(null);
365+
this.push(null);
358366
}
359367

360368
toString() {
@@ -378,6 +386,7 @@ mixin(MarkingsMixin);
378386
mixin(AcroFormMixin);
379387
mixin(AttachmentsMixin);
380388
mixin(SubsetMixin);
389+
mixin(TableMixin);
381390

382391
PDFDocument.LineWrapper = LineWrapper;
383392

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import PDFTable from '../table/index';
2+
3+
export default {
4+
initTables() {
5+
this._tableIndex = 0;
6+
},
7+
/**
8+
* @param {Table} [opts]
9+
* @returns {PDFTable} returns the table object unless `data` is set,
10+
* then it returns the underlying document
11+
*/
12+
table(opts) {
13+
return new PDFTable(this, opts);
14+
},
15+
};
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import PDFStructureElement from '../structure_element';
2+
import PDFDocument from '../document';
3+
4+
/**
5+
* Add accessibility to a table
6+
*
7+
* @this PDFTable
8+
* @memberOf PDFTable
9+
* @private
10+
*/
11+
export function accommodateTable() {
12+
const structParent = this.opts.structParent;
13+
if (structParent) {
14+
this._tableStruct = this.document.struct('Table');
15+
this._tableStruct.dictionary.data.ID = this._id;
16+
if (structParent instanceof PDFStructureElement) {
17+
structParent.add(this._tableStruct);
18+
} else if (structParent instanceof PDFDocument) {
19+
structParent.addStructure(this._tableStruct);
20+
}
21+
this._headerRowLookup = {};
22+
this._headerColumnLookup = {};
23+
}
24+
}
25+
26+
/**
27+
* Cleanup accessibility on a table
28+
*
29+
* @this PDFTable
30+
* @memberOf PDFTable
31+
* @private
32+
*/
33+
export function accommodateCleanup() {
34+
if (this._tableStruct) this._tableStruct.end();
35+
}
36+
37+
/**
38+
* Render a row with all its accessibility features
39+
*
40+
* @this PDFTable
41+
* @memberOf PDFTable
42+
* @param {SizedNormalizedTableCellStyle[]} row
43+
* @param {number} rowIndex
44+
* @param {Function} renderCell
45+
* @private
46+
*/
47+
export function accessibleRow(row, rowIndex, renderCell) {
48+
const rowStruct = this.document.struct('TR');
49+
rowStruct.dictionary.data.ID = new String(`${this._id}-${rowIndex}`);
50+
this._tableStruct.add(rowStruct);
51+
row.forEach((cell) => renderCell(cell, rowStruct));
52+
rowStruct.end();
53+
}
54+
55+
/**
56+
* Render a cell with all its accessibility features
57+
*
58+
* @this PDFTable
59+
* @memberOf PDFTable
60+
* @param {SizedNormalizedTableCellStyle} cell
61+
* @param {PDFStructureElement} rowStruct
62+
* @param {Function} callback
63+
* @private
64+
*/
65+
export function accessibleCell(cell, rowStruct, callback) {
66+
const doc = this.document;
67+
68+
const cellStruct = doc.struct(cell.type, { title: cell.title });
69+
cellStruct.dictionary.data.ID = cell.id;
70+
71+
rowStruct.add(cellStruct);
72+
73+
const padding = cell.padding;
74+
const border = cell.border;
75+
const attributes = {
76+
O: 'Table',
77+
Width: cell.width,
78+
Height: cell.height,
79+
Padding: [padding.top, padding.bottom, padding.left, padding.right],
80+
RowSpan: cell.rowSpan > 1 ? cell.rowSpan : undefined,
81+
ColSpan: cell.colSpan > 1 ? cell.colSpan : undefined,
82+
BorderThickness: [border.top, border.bottom, border.left, border.right],
83+
};
84+
85+
// Claim row Headers
86+
if (cell.type === 'TH') {
87+
if (cell.scope === 'Row' || cell.scope === 'Both') {
88+
for (let i = 0; i < cell.rowSpan; i++) {
89+
if (!this._headerRowLookup[cell.rowIndex + i]) {
90+
this._headerRowLookup[cell.rowIndex + i] = [];
91+
}
92+
this._headerRowLookup[cell.rowIndex + i].push(cell.id);
93+
}
94+
attributes.Scope = cell.scope;
95+
}
96+
if (cell.scope === 'Column' || cell.scope === 'Both') {
97+
for (let i = 0; i < cell.colSpan; i++) {
98+
if (!this._headerColumnLookup[cell.colIndex + i]) {
99+
this._headerColumnLookup[cell.colIndex + i] = [];
100+
}
101+
this._headerColumnLookup[cell.colIndex + i].push(cell.id);
102+
}
103+
attributes.Scope = cell.scope;
104+
}
105+
}
106+
107+
// Find any cells which are marked as headers for this cell
108+
const Headers = new Set(
109+
[
110+
...Array.from(
111+
{ length: cell.colSpan },
112+
(_, i) => this._headerColumnLookup[cell.colIndex + i],
113+
).flat(),
114+
...Array.from(
115+
{ length: cell.rowSpan },
116+
(_, i) => this._headerRowLookup[cell.rowIndex + i],
117+
).flat(),
118+
].filter(Boolean),
119+
);
120+
if (Headers.size) attributes.Headers = Array.from(Headers);
121+
122+
const normalizeColor = doc._normalizeColor;
123+
if (cell.backgroundColor != null) {
124+
attributes.BackgroundColor = normalizeColor(cell.backgroundColor);
125+
}
126+
const hasBorder = [border.top, border.bottom, border.left, border.right];
127+
if (hasBorder.some((x) => x)) {
128+
const borderColor = cell.borderColor;
129+
attributes.BorderColor = [
130+
hasBorder[0] ? normalizeColor(borderColor.top) : null,
131+
hasBorder[1] ? normalizeColor(borderColor.bottom) : null,
132+
hasBorder[2] ? normalizeColor(borderColor.left) : null,
133+
hasBorder[3] ? normalizeColor(borderColor.right) : null,
134+
];
135+
}
136+
137+
// Remove any undefined attributes
138+
Object.keys(attributes).forEach(
139+
(key) => attributes[key] === undefined && delete attributes[key],
140+
);
141+
cellStruct.dictionary.data.A = doc.ref(attributes);
142+
cellStruct.add(callback);
143+
cellStruct.end();
144+
cellStruct.dictionary.data.A.end();
145+
}

packages/pdfkit/src/table/index.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { normalizeRow, normalizeTable } from './normalize';
2+
import { measure, ensure } from './size';
3+
import { renderRow } from './render';
4+
import { accommodateCleanup, accommodateTable } from './accessibility';
5+
6+
class PDFTable {
7+
/**
8+
* @param {PDFDocument} document
9+
* @param {Table} [opts]
10+
*/
11+
constructor(document, opts = {}) {
12+
this.document = document;
13+
this.opts = Object.freeze(opts);
14+
15+
normalizeTable.call(this);
16+
accommodateTable.call(this);
17+
18+
this._currRowIndex = 0;
19+
this._ended = false;
20+
21+
// Render cells if present
22+
if (opts.data) {
23+
for (const row of opts.data) this.row(row);
24+
return this.end();
25+
}
26+
}
27+
28+
/**
29+
* Render a new row in the table
30+
*
31+
* @param {Iterable<TableCell>} row - The cells to render
32+
* @param {boolean} lastRow - Whether this row is the last row
33+
* @returns {this} returns the table, unless lastRow is `true` then returns the `PDFDocument`
34+
*/
35+
row(row, lastRow = false) {
36+
if (this._ended) {
37+
throw new Error(`Table was marked as ended on row ${this._currRowIndex}`);
38+
}
39+
40+
// Convert the iterable into an array
41+
row = Array.from(row);
42+
// Transform row
43+
row = normalizeRow.call(this, row, this._currRowIndex);
44+
if (this._currRowIndex === 0) ensure.call(this, row);
45+
const { newPage, toRender } = measure.call(this, row, this._currRowIndex);
46+
if (newPage) this.document.continueOnNewPage();
47+
const yPos = renderRow.call(this, toRender, this._currRowIndex);
48+
49+
// Position document at base of new row
50+
this.document.x = this._position.x;
51+
this.document.y = yPos;
52+
53+
if (lastRow) return this.end();
54+
55+
this._currRowIndex++;
56+
return this;
57+
}
58+
59+
/**
60+
* Indicates to the table that it is finished,
61+
* allowing the table to flush its cell buffer (which should be empty unless there is rowSpans)
62+
*
63+
* @returns {PDFDocument} the document
64+
*/
65+
end() {
66+
// Flush any remaining cells
67+
while (this._rowBuffer?.size) this.row([]);
68+
this._ended = true;
69+
accommodateCleanup.call(this);
70+
return this.document;
71+
}
72+
}
73+
74+
export default PDFTable;

0 commit comments

Comments
 (0)