Skip to content

Commit c8b9ab0

Browse files
make kitchen-sink-accessible example fully PDF/UA compliant (#1707)
* make kitchen-sink-accessible example fully PDF/UA compliant * added unit test for structure element and updated docs * updated changelog
1 parent ac41e3a commit c8b9ab0

File tree

8 files changed

+253
-18
lines changed

8 files changed

+253
-18
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- Bump node version requirement to 20+
66
- Bump minimum supported browsers to Firefox 115, iOS/Safari 16
77
- Fix text with input x as null
8+
- Fix PDF/UA compliance issues in kitchen-sink-accessible example
9+
- Add bbox and placement options to PDFStructureElement for PDF/UA compliance
810

911
### [v0.18.0] - 2026-03-14
1012

docs/accessibility.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ When creating a structure element, you can provide options:
236236
* `alt` - alternative text for an image or other visual content
237237
* `expanded` - the expanded form of an abbreviation or acronym
238238
* `actual` - the actual text the content represents (e.g. if it is rendered as vector graphics)
239+
* `bbox` - bounding box of the element's content in PDFKit coordinates `[left, top, right, bottom]`, required for `Figure` elements in PDF/UA
240+
* `placement` - layout placement of the element: `'Block'` (the default when `bbox` is set) or `'Inline'`
239241

240242
Example of a structure tree with options specified:
241243

@@ -256,7 +258,8 @@ Example of a structure tree with options specified:
256258
]),
257259
]),
258260
doc.struct('Figure', {
259-
alt: 'photo of a concrete path with tactile paving'
261+
alt: 'photo of a concrete path with tactile paving',
262+
bbox: [100, 200, 500, 600]
260263
}, [
261264
photoStructureContent
262265
])
@@ -359,7 +362,7 @@ Non-structure tags:
359362
* `Reference` - content in a document that refers to other content (e.g. page number in an index)
360363
* `BibEntry` - bibliography entry; may have a `Lbl` (see "block" elements)
361364
* `Code` - code
362-
* `Link` - hyperlink; should contain a link annotation
365+
* `Link` - hyperlink
363366
* `Annot` - annotation (other than a link)
364367
* `Ruby` - Chinese/Japanese pronunciation/explanation
365368
* `RB` - Ruby base text
@@ -371,6 +374,16 @@ Non-structure tags:
371374

372375
"Illustration" elements (should have `alt` and/or `actualtext` set):
373376

374-
* `Figure` - figure
377+
* `Figure` - figure, should also have `bbox` set
375378
* `Formula` - formula
376379
* `Form` - form widget
380+
381+
## Limitations
382+
383+
### Built-in fonts
384+
385+
PDFKit ships with the 14 standard PDF fonts (Helvetica, Times-Roman, Courier, etc.) as AFM metric files only.
386+
Because of this, these fonts cannot be embedded in the PDF output. Both PDF/UA and PDF/A require all fonts to
387+
be embedded, so using any of the built-in fonts will result in a non-compliant document.
388+
If you need to produce a compliant PDF, use embedded TrueType or OpenType fonts instead by loading them from
389+
a file with `doc.font('path/to/font.ttf')`.

docs/guide.pdf

14.9 KB
Binary file not shown.

docs/vector.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,26 @@ that you don't have to call `fillColor` or `strokeColor` beforehand. The
184184
.fillOpacity(0.8)
185185
.fillAndStroke("red", "#900")
186186

187+
Note that if you are producing a PDF/UA-compliant PDF, `fillColor` and `strokeColor`
188+
must be called before beginning path construction (i.e. before `moveTo`, `path`, `rect`,
189+
`circle` and similar methods). The PDF spec (ISO 32000-2) does not allow color space operators
190+
to be emitted during path construction, and passing a color directly to `fill`, `stroke` or
191+
`fillAndStroke` can produce a non-compliant PDF. The safest approach is to always set
192+
colors before defining the path:
193+
194+
// good: emits color operators before path
195+
doc.fillColor('red')
196+
.moveTo(100, 150)
197+
.lineTo(100, 250)
198+
.lineTo(200, 250)
199+
.fill();
200+
201+
// not good: may emit color operators throughout path construction
202+
doc.moveTo(100, 150)
203+
.lineTo(100, 250)
204+
.lineTo(200, 250)
205+
.fill('red');
206+
187207
This example produces the following output:
188208

189209
![5](images/color.png "100")

examples/kitchen-sink-accessible.js

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ struct.add(
3939
var imageSection = doc.struct('Sect');
4040
struct.add(imageSection);
4141

42+
doc.outline.addItem('PNG and JPEG images:'); // add a bookmark for the image section's H1
4243
imageSection.add(
4344
doc.struct('H1', () => {
4445
doc.fontSize(18).text('PNG and JPEG images: ');
@@ -49,7 +50,8 @@ imageSection.add(
4950
doc.struct(
5051
'Figure',
5152
{
52-
alt: 'Promotional image of an Apple laptop. '
53+
alt: 'Promotional image of an Apple laptop. ',
54+
bbox: [100, 160, 512, 387]
5355
},
5456
() => {
5557
doc.image('images/test.png', 100, 160, {
@@ -64,7 +66,8 @@ imageSection.add(
6466
'Figure',
6567
{
6668
alt:
67-
'Photograph of a path flanked by blossoming trees with surrounding hedges. '
69+
'Photograph of a path flanked by blossoming trees with surrounding hedges. ',
70+
bbox: [190, 400, 415, 700]
6871
},
6972
() => {
7073
doc.image('images/test.jpeg', 190, 400, {
@@ -83,6 +86,7 @@ doc.addPage();
8386
var vectorSection = doc.struct('Sect');
8487
struct.add(vectorSection);
8588

89+
doc.outline.addItem('Vector graphics:'); // add a bookmark for the vector graphics section's H1
8690
vectorSection.add(
8791
doc.struct('H1', () => {
8892
doc.fontSize(25).text('Here are some vector graphics... ', 100, 100);
@@ -93,15 +97,19 @@ vectorSection.add(
9397
doc.struct(
9498
'Figure',
9599
{
96-
alt: 'Orange triangle. '
100+
alt: 'Orange triangle. ',
101+
bbox: [100, 150, 200, 250]
97102
},
98103
() => {
104+
// we set fill color before path construction to comply with ISO 32000-2 Figure 9
99105
doc
100106
.save()
107+
.fillColor('#FF8800')
101108
.moveTo(100, 150)
102109
.lineTo(100, 250)
103110
.lineTo(200, 250)
104-
.fill('#FF8800');
111+
.fill()
112+
.restore();
105113
}
106114
)
107115
);
@@ -110,10 +118,12 @@ vectorSection.add(
110118
doc.struct(
111119
'Figure',
112120
{
113-
alt: 'Purple circle. '
121+
alt: 'Purple circle. ',
122+
bbox: [230, 150, 330, 250]
114123
},
115124
() => {
116-
doc.circle(280, 200, 50).fill('#7722FF');
125+
// we set fill color before path construction to comply with ISO 32000-2 Figure 9
126+
doc.save().fillColor('#7722FF').circle(280, 200, 50).fill().restore();
117127
}
118128
)
119129
);
@@ -122,16 +132,20 @@ vectorSection.add(
122132
doc.struct(
123133
'Figure',
124134
{
125-
alt: 'Red star with hollow center. '
135+
alt: 'Red star with hollow center. ',
136+
bbox: [360, 128, 504, 266]
126137
},
127138
() => {
139+
// we set fill color before path construction to comply with ISO 32000-2 Figure 9
128140
doc
141+
.save()
142+
.fillColor('red')
129143
.scale(0.6)
130144
.translate(470, 140)
131145
// render an SVG path
132146
.path('M 250,75 L 323,301 131,161 369,161 177,301 z')
133147
// fill using the even-odd winding rule
134-
.fill('red', 'even-odd')
148+
.fill('even-odd')
135149
.restore();
136150
}
137151
)
@@ -143,6 +157,7 @@ vectorSection.end();
143157
var wrappedSection = doc.struct('Sect');
144158
struct.add(wrappedSection);
145159

160+
doc.outline.addItem('PNG and JPEG images:'); // add a bookmark for the wrapped text section's H1
146161
wrappedSection.add(
147162
doc.struct('H1', () => {
148163
doc
@@ -155,7 +170,7 @@ wrappedSection.add(
155170

156171
var loremIpsum =
157172
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam in suscipit purus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Vivamus nec hendrerit felis. Morbi aliquam facilisis risus eu lacinia. Sed eu leo in turpis fringilla hendrerit. Ut nec accumsan nisl. Suspendisse rhoncus nisl posuere tortor tempus et dapibus elit porta. Cras leo neque, elementum a rhoncus ut, vestibulum non nibh. Phasellus pretium justo turpis. Etiam vulputate, odio vitae tincidunt ultricies, eros odio dapibus nisi, ut tincidunt lacus arcu eu elit. Aenean velit erat, vehicula eget lacinia ut, dignissim non tellus. Aliquam nec lacus mi, sed vestibulum nunc. Suspendisse potenti. Curabitur vitae sem turpis. Vestibulum sed neque eget dolor dapibus porttitor at sit amet sem. Fusce a turpis lorem. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae;\nMauris at ante tellus. Vestibulum a metus lectus. Praesent tempor purus a lacus blandit eget gravida ante hendrerit. Cras et eros metus. Sed commodo malesuada eros, vitae interdum augue semper quis. Fusce id magna nunc. Curabitur sollicitudin placerat semper. Cras et mi neque, a dignissim risus. Nulla venenatis porta lacus, vel rhoncus lectus tempor vitae. Duis sagittis venenatis rutrum. Curabitur tempor massa tortor.';
158-
doc.text(loremIpsum, {
173+
doc.font('Palatino').text(loremIpsum, {
159174
width: 412,
160175
align: 'justify',
161176
indent: 30,
@@ -172,6 +187,7 @@ doc.addPage();
172187
var tigerSection = doc.struct('Sect');
173188
struct.add(tigerSection);
174189

190+
doc.outline.addItem('Tiger line art:'); // add a bookmark for the tiger section's H1
175191
tigerSection.add(
176192
doc.struct('H1', () => {
177193
doc
@@ -185,26 +201,34 @@ tigerSection.add(
185201
doc.struct(
186202
'Figure',
187203
{
188-
alt: 'Tiger line art. '
204+
alt: 'Tiger line art. ',
205+
bbox: [30, 140, 540, 680]
189206
},
190207
() => {
191208
var i, len, part;
192209
// Render each path that makes up the tiger image
193210
for (i = 0, len = tiger.length; i < len; i++) {
194211
part = tiger[i];
195212
doc.save();
196-
doc.path(part.path); // render an SVG path
213+
// we set fill color before path construction to comply with ISO 32000-2 Figure 9
214+
if (part.fill !== 'none') {
215+
doc.fillColor(part.fill);
216+
}
217+
if (part.stroke !== 'none') {
218+
doc.strokeColor(part.stroke);
219+
}
197220
if (part['stroke-width']) {
198221
doc.lineWidth(part['stroke-width']);
199222
}
223+
doc.path(part.path); // render an SVG path
200224
if (part.fill !== 'none' && part.stroke !== 'none') {
201-
doc.fillAndStroke(part.fill, part.stroke);
225+
doc.fillAndStroke();
202226
} else {
203227
if (part.fill !== 'none') {
204-
doc.fill(part.fill);
228+
doc.fill();
205229
}
206230
if (part.stroke !== 'none') {
207-
doc.stroke(part.stroke);
231+
doc.stroke();
208232
}
209233
}
210234
doc.restore();
@@ -222,7 +246,10 @@ doc.addPage();
222246
var linkSection = doc.struct('Sect');
223247
struct.add(linkSection);
224248

225-
linkSection.add(
249+
var linkParagraph = doc.struct('P');
250+
linkSection.add(linkParagraph);
251+
252+
linkParagraph.add(
226253
doc.struct(
227254
'Link',
228255
{
@@ -237,6 +264,7 @@ linkSection.add(
237264
)
238265
);
239266

267+
linkParagraph.end();
240268
linkSection.end();
241269

242270
// Add a list with a font loaded from a TrueType collection file
3.6 KB
Binary file not shown.

lib/structure_element.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ class PDFStructureElement {
4040
if (typeof options.actual !== 'undefined') {
4141
data.ActualText = new String(options.actual);
4242
}
43+
if (
44+
typeof options.bbox !== 'undefined' ||
45+
typeof options.placement !== 'undefined'
46+
) {
47+
const attrs = { O: 'Layout' };
48+
attrs.Placement =
49+
typeof options.placement !== 'undefined' ? options.placement : 'Block';
50+
if (typeof options.bbox !== 'undefined') {
51+
const height = this.document.page.height;
52+
attrs.BBox = [
53+
options.bbox[0],
54+
height - options.bbox[3],
55+
options.bbox[2],
56+
height - options.bbox[1],
57+
];
58+
}
59+
data.A = attrs;
60+
}
4361

4462
this._children = [];
4563

0 commit comments

Comments
 (0)