diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb05cb5..b4ceeb1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - Bump node version requirement to 20+ - Bump minimum supported browsers to Firefox 115, iOS/Safari 16 - Fix text with input x as null +- Fix PDF/UA compliance issues in kitchen-sink-accessible example +- Add bbox and placement options to PDFStructureElement for PDF/UA compliance ### [v0.18.0] - 2026-03-14 diff --git a/docs/accessibility.md b/docs/accessibility.md index 79de1e41..ec078763 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -236,6 +236,8 @@ When creating a structure element, you can provide options: * `alt` - alternative text for an image or other visual content * `expanded` - the expanded form of an abbreviation or acronym * `actual` - the actual text the content represents (e.g. if it is rendered as vector graphics) + * `bbox` - bounding box of the element's content in PDFKit coordinates `[left, top, right, bottom]`, required for `Figure` elements in PDF/UA + * `placement` - layout placement of the element: `'Block'` (the default when `bbox` is set) or `'Inline'` Example of a structure tree with options specified: @@ -256,7 +258,8 @@ Example of a structure tree with options specified: ]), ]), doc.struct('Figure', { - alt: 'photo of a concrete path with tactile paving' + alt: 'photo of a concrete path with tactile paving', + bbox: [100, 200, 500, 600] }, [ photoStructureContent ]) @@ -359,7 +362,7 @@ Non-structure tags: * `Reference` - content in a document that refers to other content (e.g. page number in an index) * `BibEntry` - bibliography entry; may have a `Lbl` (see "block" elements) * `Code` - code - * `Link` - hyperlink; should contain a link annotation + * `Link` - hyperlink * `Annot` - annotation (other than a link) * `Ruby` - Chinese/Japanese pronunciation/explanation * `RB` - Ruby base text @@ -371,6 +374,16 @@ Non-structure tags: "Illustration" elements (should have `alt` and/or `actualtext` set): - * `Figure` - figure + * `Figure` - figure, should also have `bbox` set * `Formula` - formula * `Form` - form widget + +## Limitations + +### Built-in fonts + +PDFKit ships with the 14 standard PDF fonts (Helvetica, Times-Roman, Courier, etc.) as AFM metric files only. +Because of this, these fonts cannot be embedded in the PDF output. Both PDF/UA and PDF/A require all fonts to +be embedded, so using any of the built-in fonts will result in a non-compliant document. +If you need to produce a compliant PDF, use embedded TrueType or OpenType fonts instead by loading them from +a file with `doc.font('path/to/font.ttf')`. diff --git a/docs/guide.pdf b/docs/guide.pdf index 7c675884..f03bbaf6 100644 Binary files a/docs/guide.pdf and b/docs/guide.pdf differ diff --git a/docs/vector.md b/docs/vector.md index 07138d65..5ed05f11 100644 --- a/docs/vector.md +++ b/docs/vector.md @@ -184,6 +184,26 @@ that you don't have to call `fillColor` or `strokeColor` beforehand. The .fillOpacity(0.8) .fillAndStroke("red", "#900") +Note that if you are producing a PDF/UA-compliant PDF, `fillColor` and `strokeColor` +must be called before beginning path construction (i.e. before `moveTo`, `path`, `rect`, +`circle` and similar methods). The PDF spec (ISO 32000-2) does not allow color space operators +to be emitted during path construction, and passing a color directly to `fill`, `stroke` or +`fillAndStroke` can produce a non-compliant PDF. The safest approach is to always set +colors before defining the path: + + // good: emits color operators before path + doc.fillColor('red') + .moveTo(100, 150) + .lineTo(100, 250) + .lineTo(200, 250) + .fill(); + + // not good: may emit color operators throughout path construction + doc.moveTo(100, 150) + .lineTo(100, 250) + .lineTo(200, 250) + .fill('red'); + This example produces the following output: ![5](images/color.png "100") diff --git a/examples/kitchen-sink-accessible.js b/examples/kitchen-sink-accessible.js index 06b25d8e..d66f4064 100644 --- a/examples/kitchen-sink-accessible.js +++ b/examples/kitchen-sink-accessible.js @@ -39,6 +39,7 @@ struct.add( var imageSection = doc.struct('Sect'); struct.add(imageSection); +doc.outline.addItem('PNG and JPEG images:'); // add a bookmark for the image section's H1 imageSection.add( doc.struct('H1', () => { doc.fontSize(18).text('PNG and JPEG images: '); @@ -49,7 +50,8 @@ imageSection.add( doc.struct( 'Figure', { - alt: 'Promotional image of an Apple laptop. ' + alt: 'Promotional image of an Apple laptop. ', + bbox: [100, 160, 512, 387] }, () => { doc.image('images/test.png', 100, 160, { @@ -64,7 +66,8 @@ imageSection.add( 'Figure', { alt: - 'Photograph of a path flanked by blossoming trees with surrounding hedges. ' + 'Photograph of a path flanked by blossoming trees with surrounding hedges. ', + bbox: [190, 400, 415, 700] }, () => { doc.image('images/test.jpeg', 190, 400, { @@ -83,6 +86,7 @@ doc.addPage(); var vectorSection = doc.struct('Sect'); struct.add(vectorSection); +doc.outline.addItem('Vector graphics:'); // add a bookmark for the vector graphics section's H1 vectorSection.add( doc.struct('H1', () => { doc.fontSize(25).text('Here are some vector graphics... ', 100, 100); @@ -93,15 +97,19 @@ vectorSection.add( doc.struct( 'Figure', { - alt: 'Orange triangle. ' + alt: 'Orange triangle. ', + bbox: [100, 150, 200, 250] }, () => { + // we set fill color before path construction to comply with ISO 32000-2 Figure 9 doc .save() + .fillColor('#FF8800') .moveTo(100, 150) .lineTo(100, 250) .lineTo(200, 250) - .fill('#FF8800'); + .fill() + .restore(); } ) ); @@ -110,10 +118,12 @@ vectorSection.add( doc.struct( 'Figure', { - alt: 'Purple circle. ' + alt: 'Purple circle. ', + bbox: [230, 150, 330, 250] }, () => { - doc.circle(280, 200, 50).fill('#7722FF'); + // we set fill color before path construction to comply with ISO 32000-2 Figure 9 + doc.save().fillColor('#7722FF').circle(280, 200, 50).fill().restore(); } ) ); @@ -122,16 +132,20 @@ vectorSection.add( doc.struct( 'Figure', { - alt: 'Red star with hollow center. ' + alt: 'Red star with hollow center. ', + bbox: [360, 128, 504, 266] }, () => { + // we set fill color before path construction to comply with ISO 32000-2 Figure 9 doc + .save() + .fillColor('red') .scale(0.6) .translate(470, 140) // render an SVG path .path('M 250,75 L 323,301 131,161 369,161 177,301 z') // fill using the even-odd winding rule - .fill('red', 'even-odd') + .fill('even-odd') .restore(); } ) @@ -143,6 +157,7 @@ vectorSection.end(); var wrappedSection = doc.struct('Sect'); struct.add(wrappedSection); +doc.outline.addItem('PNG and JPEG images:'); // add a bookmark for the wrapped text section's H1 wrappedSection.add( doc.struct('H1', () => { doc @@ -155,7 +170,7 @@ wrappedSection.add( var loremIpsum = '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.'; -doc.text(loremIpsum, { +doc.font('Palatino').text(loremIpsum, { width: 412, align: 'justify', indent: 30, @@ -172,6 +187,7 @@ doc.addPage(); var tigerSection = doc.struct('Sect'); struct.add(tigerSection); +doc.outline.addItem('Tiger line art:'); // add a bookmark for the tiger section's H1 tigerSection.add( doc.struct('H1', () => { doc @@ -185,7 +201,8 @@ tigerSection.add( doc.struct( 'Figure', { - alt: 'Tiger line art. ' + alt: 'Tiger line art. ', + bbox: [30, 140, 540, 680] }, () => { var i, len, part; @@ -193,18 +210,25 @@ tigerSection.add( for (i = 0, len = tiger.length; i < len; i++) { part = tiger[i]; doc.save(); - doc.path(part.path); // render an SVG path + // we set fill color before path construction to comply with ISO 32000-2 Figure 9 + if (part.fill !== 'none') { + doc.fillColor(part.fill); + } + if (part.stroke !== 'none') { + doc.strokeColor(part.stroke); + } if (part['stroke-width']) { doc.lineWidth(part['stroke-width']); } + doc.path(part.path); // render an SVG path if (part.fill !== 'none' && part.stroke !== 'none') { - doc.fillAndStroke(part.fill, part.stroke); + doc.fillAndStroke(); } else { if (part.fill !== 'none') { - doc.fill(part.fill); + doc.fill(); } if (part.stroke !== 'none') { - doc.stroke(part.stroke); + doc.stroke(); } } doc.restore(); @@ -222,7 +246,10 @@ doc.addPage(); var linkSection = doc.struct('Sect'); struct.add(linkSection); -linkSection.add( +var linkParagraph = doc.struct('P'); +linkSection.add(linkParagraph); + +linkParagraph.add( doc.struct( 'Link', { @@ -237,6 +264,7 @@ linkSection.add( ) ); +linkParagraph.end(); linkSection.end(); // Add a list with a font loaded from a TrueType collection file diff --git a/examples/kitchen-sink-accessible.pdf b/examples/kitchen-sink-accessible.pdf index 91a2f54f..078b93e8 100644 Binary files a/examples/kitchen-sink-accessible.pdf and b/examples/kitchen-sink-accessible.pdf differ diff --git a/lib/structure_element.js b/lib/structure_element.js index 9f9cf19e..01644379 100644 --- a/lib/structure_element.js +++ b/lib/structure_element.js @@ -40,6 +40,24 @@ class PDFStructureElement { if (typeof options.actual !== 'undefined') { data.ActualText = new String(options.actual); } + if ( + typeof options.bbox !== 'undefined' || + typeof options.placement !== 'undefined' + ) { + const attrs = { O: 'Layout' }; + attrs.Placement = + typeof options.placement !== 'undefined' ? options.placement : 'Block'; + if (typeof options.bbox !== 'undefined') { + const height = this.document.page.height; + attrs.BBox = [ + options.bbox[0], + height - options.bbox[3], + options.bbox[2], + height - options.bbox[1], + ]; + } + data.A = attrs; + } this._children = []; diff --git a/tests/unit/structure_element.spec.js b/tests/unit/structure_element.spec.js new file mode 100644 index 00000000..530ae89a --- /dev/null +++ b/tests/unit/structure_element.spec.js @@ -0,0 +1,154 @@ +import PDFDocument from '../../lib/document'; +import { logData } from './helpers'; + +describe('PDFStructureElement', () => { + let document; + + beforeEach(() => { + document = new PDFDocument({ + info: { CreationDate: new Date(Date.UTC(2018, 1, 1)) }, + compress: false, + }); + }); + + describe('layout attributes', () => { + test('bbox converts PDFKit coordinates to PDF', () => { + const docData = logData(document); + + document.addStructure( + document.struct('Figure', { + alt: 'A triangle.', + bbox: [100, 150, 200, 250], + }), + ); + + document.end(); + + // PDFKit y-down [left, top, right, bottom] to PDF y-up [left, bottom, right, top] + // default is Letter which is 792 points tall, so: + // from [100, 150, 200, 250] to [100, 792-250, 200, 792-150] gives us [100, 542, 200, 642] + expect(docData).toContainChunk([ + `8 0 obj`, + `<< +/S /Figure +/Alt (A triangle.) +/A << +/O /Layout +/Placement /Block +/BBox [100 542 200 642] +>> +/P 9 0 R +/K [] +>>`, + `endobj`, + ]); + }); + + test('bbox converts PDFKit coordinates to PDF on A4 pages', () => { + const a4doc = new PDFDocument({ + info: { CreationDate: new Date(Date.UTC(2018, 1, 1)) }, + compress: false, + size: 'A4', + }); + const docData = logData(a4doc); + + a4doc.addStructure( + a4doc.struct('Figure', { + bbox: [100, 150, 200, 250], + }), + ); + + a4doc.end(); + + // PDFKit y-down [left, top, right, bottom] to PDF y-up [left, bottom, right, top] + // A4 is 841.89 points tall, so: + // from [100, 150, 200, 250] to [100, 841.89-250, 200, 841.89-150] gives us [100, 591.89, 200, 691.89] + expect(docData).toContainChunk([ + `8 0 obj`, + `<< +/S /Figure +/A << +/O /Layout +/Placement /Block +/BBox [100 591.89 200 691.89] +>> +/P 9 0 R +/K [] +>>`, + `endobj`, + ]); + }); + + test('placement option overrides the Block default', () => { + const docData = logData(document); + + document.addStructure( + document.struct('Figure', { + bbox: [100, 150, 200, 250], + placement: 'Inline', + }), + ); + + document.end(); + + expect(docData).toContainChunk([ + `8 0 obj`, + `<< +/S /Figure +/A << +/O /Layout +/Placement /Inline +/BBox [100 542 200 642] +>> +/P 9 0 R +/K [] +>>`, + `endobj`, + ]); + }); + + test('placement without bbox creates layout attribute with no BBox', () => { + const docData = logData(document); + + document.addStructure( + document.struct('Figure', { + placement: 'Inline', + }), + ); + + document.end(); + + expect(docData).toContainChunk([ + `8 0 obj`, + `<< +/S /Figure +/A << +/O /Layout +/Placement /Inline +>> +/P 9 0 R +/K [] +>>`, + `endobj`, + ]); + }); + + test('no layout attribute when neither bbox nor placement is specified', () => { + const docData = logData(document); + + document.addStructure(document.struct('Figure')); + + document.end(); + + expect(docData).toContainChunk([ + `8 0 obj`, + `<< +/S /Figure +/P 9 0 R +/K [] +>>`, + `endobj`, + ]); + }); + }); +});