From 0b01ef41461d6c0ff528dff444e24c024a2e83af Mon Sep 17 00:00:00 2001 From: Adrian Kowalczewski Date: Thu, 18 Dec 2025 14:53:27 +0100 Subject: [PATCH] Accessibility: Fixed Link annotation is not nested inside a Link structure element (#1664) * Accessibility: Fixed Link annotation is not nested inside a Link structure element * Accessibility: Fixed Link annotation is not nested inside a Link structure element * Changelog * Fix links leakage into subsequent structures * Prettier --------- Co-authored-by: akowalczewski --- .gitignore | 1 + CHANGELOG.md | 1 + examples/accessible-links.js | 87 ++++++++++++++++++++++++ examples/accessible-links.pdf | Bin 0 -> 15906 bytes examples/kitchen-sink-accessible.js | 4 +- examples/kitchen-sink-accessible.pdf | Bin 686795 -> 687286 bytes lib/mixins/annotations.js | 15 ++++ lib/mixins/markings.js | 7 ++ lib/mixins/text.js | 16 ++++- lib/structure_annotation.js | 7 ++ lib/structure_element.js | 36 ++++++++++ tests/unit/annotations.spec.js | 37 ++++++++++ tests/unit/markings.spec.js | 17 +++++ tests/unit/structure_annotation.spec.js | 66 ++++++++++++++++++ tests/unit/text.spec.js | 86 +++++++++++++++++++++++ 15 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 examples/accessible-links.js create mode 100644 examples/accessible-links.pdf create mode 100644 lib/structure_annotation.js create mode 100644 tests/unit/structure_annotation.spec.js diff --git a/.gitignore b/.gitignore index 247e36b..416d4bc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ playground/ build/ js/ .vscode +.idea coverage package-lock.json /examples/browserify/bundle.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 06fcded..26da550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Unreleased - Fix garbled text copying in Chrome/Edge for PDFs with >256 unique characters (#1659) +- Fix Link accessibility issues ### [v0.17.2] - 2025-08-30 diff --git a/examples/accessible-links.js b/examples/accessible-links.js new file mode 100644 index 0000000..bc4e2cb --- /dev/null +++ b/examples/accessible-links.js @@ -0,0 +1,87 @@ +var PDFDocument = require('../'); +var fs = require('fs'); + +// Create a new PDFDocument +var doc = new PDFDocument({ + autoFirstPage: true, + bufferPages: true, + pdfVersion: '1.5', + // @ts-ignore PDF/UA needs to be enforced for PAC accessibility checker + subset: 'PDF/UA', + tagged: true, + displayTitle: true, + lang: 'en-US', + fontSize: 12, +}); + +doc.pipe(fs.createWriteStream('accessible-links.pdf')); + +// Set some meta data +doc.info['Title'] = 'Test Document'; +doc.info['Author'] = 'Devon Govett'; + +// Initialise document logical structure +var struct = doc.struct('Document'); +doc.addStructure(struct); + +// Register a font name for use later +doc.registerFont('Palatino', 'fonts/PalatinoBold.ttf'); + +// Set the font and draw some text +struct.add( + doc.struct('P', () => { + doc + .font('Palatino') + .fontSize(25) + .text('Some text with an embedded font! ', 100, 100); + }), +); + +// Add another page +doc.addPage(); + +// Add some text with annotations +var linkSection = doc.struct('Sect'); +struct.add(linkSection); + +var paragraph = doc.struct('P'); +linkSection.add(paragraph); + +paragraph.add( + doc.struct('Span', () => { + doc + .font('Palatino') + .fillColor('black') + .text('This is some text before ', 100, 100, { + continued: true, + }); + }), +); + +paragraph.add( + doc.struct( + 'Link', + { + alt: 'Here is a link! ', + }, + () => { + doc.fillColor('blue').text('Here is a link!', { + link: 'http://google.com/', + underline: true, + continued: true, + }); + }, + ), +); + +paragraph.add( + doc.struct('Span', () => { + doc.fillColor('black').text(' and this is text after the link.'); + }), +); + +paragraph.end(); +linkSection.end(); + +// End and flush the document +doc.end(); diff --git a/examples/accessible-links.pdf b/examples/accessible-links.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fc9722a6e4867336c5e398b2a1888ed9f5e3e0b9 GIT binary patch literal 15906 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 KS>-h33H}dgZ@Gp5 literal 0 HcmV?d00001 diff --git a/examples/kitchen-sink-accessible.js b/examples/kitchen-sink-accessible.js index bf0a824..06b25d8 100644 --- a/examples/kitchen-sink-accessible.js +++ b/examples/kitchen-sink-accessible.js @@ -7,7 +7,9 @@ var doc = new PDFDocument({ pdfVersion: '1.5', lang: 'en-US', tagged: true, - displayTitle: true + displayTitle: true, + // @ts-ignore PDF/UA needs to be enforced for PAC accessibility checker + subset: 'PDF/UA', }); doc.pipe(fs.createWriteStream('kitchen-sink-accessible.pdf')); diff --git a/examples/kitchen-sink-accessible.pdf b/examples/kitchen-sink-accessible.pdf index 002f9b523da6bf86d78fab985120d2802599a0af..91a2f54f00dedbe6ba1602ff9552e9b4e413ea26 100644 GIT binary patch delta 1583 zcmah}U1%It6!z@K+T2vBq|sIdZ`TH!LOS=}xp!vnvRTAbu|JyFR^JLvI2 zx@o{{p-|t%7r6=|zWAmOf_6;RfNyTvrckAig0zYVdGtYi&@)|21~?@3Bi?!JjKbll58@)}kgoJZAn_fma^2&P0vY`Dog2AZ*4vaD0rGO2j&Q zaWSOU)Qcxh6KiVb0+os~iucpARPx?!xoi$zg8lJ(9k{$}NO8d=my&Z8KXV0^ZS&zZ z*fV%4tj$Jql-p)y9STGL{EI99&|~qy_wnUbAn>#`dHmR!FrtpLm6U|FpgwbfIIfvq z0~)VhgOQxMy9UGYuiwEhFzO~7&tZr4H)_@M^&q5{7=j=P(KpIrgR)Fbj# zM=-8Y_GHl-CCL@dBwU|J^rI(T#F(u8HOJsP&%hqxn z^Ekc=Ga_Rxo++t4CzArXW+Xx_v-`9(uU{m?9WTv}F~L&`a&8NxwSzHY_(RHI(#`lt zGCS=-E|q(~rOpErau-Xd6FNF$F7661r3H?}5gE)=Ds5miX7Wqxk1lGM;xgHbpUcT* zLf_)t_Z^=HGb;3$TTwO>DsaL;YUyhh$TBVc@0N37NA`K delta 1300 zcmai!Pe@cj9LM?Xn&s3X(ybPP-XS3a+4;@Pdov>%gCmi$~>YN)Sxq-3sr7QWdg70`TL}qjm4hN6lL8d9?>F{R8oLYeWMlQk1 zJ{6{r?b?#oYn$$6xD%!?EyD{l{{fnVdn@o1T4W)~1;ig6udS(Hj7 z3o0qh(sk0CMu8?fPFhpUHOI$EUz$l8%#M=`q^K~@tK?BXy0QCIYswQ$DV1cXVgpgF zkb36+1kp9cMRZ#GQA)e;e^pt=y(D8I=17=leT>{il%B#Uvw1GBJ2$j#yM+Q{s|cBl zS!|iaWMfw+g?R4VP0~_Jbk{PzOKYdXgmt!iqLJ;M7*cDDtELpi-+^k|4~tXbMy){P zobm)yUTkM*%hA{{h@MQoD@idbIA?+6ZWLN(A9GS6L6V1+zo5{shC>HKd>{qJzCGF+}y%YKn~%0*X^*4C3}ddMHca6#Ds diff --git a/lib/mixins/annotations.js b/lib/mixins/annotations.js index 51a72c5..ad9b704 100644 --- a/lib/mixins/annotations.js +++ b/lib/mixins/annotations.js @@ -1,3 +1,5 @@ +import PDFAnnotationReference from '../structure_annotation'; + export default { annotate(x, y, w, h, options) { options.Type = 'Annot'; @@ -19,6 +21,9 @@ export default { options.Dest = new String(options.Dest); } + const structParent = options.structParent; + delete options.structParent; + // Capitalize keys for (let key in options) { const val = options[key]; @@ -27,6 +32,12 @@ export default { const ref = this.ref(options); this.page.annotations.push(ref); + + if (structParent && typeof structParent.add === 'function') { + const annotRef = new PDFAnnotationReference(ref); + structParent.add(annotRef); + } + ref.end(); return this; }, @@ -77,6 +88,10 @@ export default { options.A.end(); } + if (options.structParent && !options.Contents) { + options.Contents = new String(''); + } + return this.annotate(x, y, w, h, options); }, diff --git a/lib/mixins/markings.js b/lib/mixins/markings.js index a0592e2..7a1c5f0 100644 --- a/lib/mixins/markings.js +++ b/lib/mixins/markings.js @@ -99,6 +99,13 @@ export default { endMarkedContent() { this.page.markings.pop(); this.addContent('EMC'); + if (this._textOptions) { + delete this._textOptions.link; + delete this._textOptions.goTo; + delete this._textOptions.destination; + delete this._textOptions.underline; + delete this._textOptions.strike; + } return this; }, diff --git a/lib/mixins/text.js b/lib/mixins/text.js index be34348..8290653 100644 --- a/lib/mixins/text.js +++ b/lib/mixins/text.js @@ -531,7 +531,21 @@ export default { // create link annotations if the link option is given if (options.link != null) { - this.link(x, y, renderedWidth, this.currentLineHeight(), options.link); + const linkOptions = {}; + if ( + this._currentStructureElement && + this._currentStructureElement.dictionary.data.S === 'Link' + ) { + linkOptions.structParent = this._currentStructureElement; + } + this.link( + x, + y, + renderedWidth, + this.currentLineHeight(), + options.link, + linkOptions, + ); } if (options.goTo != null) { this.goTo(x, y, renderedWidth, this.currentLineHeight(), options.goTo); diff --git a/lib/structure_annotation.js b/lib/structure_annotation.js new file mode 100644 index 0000000..fe5ddbf --- /dev/null +++ b/lib/structure_annotation.js @@ -0,0 +1,7 @@ +class PDFAnnotationReference { + constructor(annotationRef) { + this.annotationRef = annotationRef; + } +} + +export default PDFAnnotationReference; diff --git a/lib/structure_element.js b/lib/structure_element.js index 4f62d7a..9f9cf19 100644 --- a/lib/structure_element.js +++ b/lib/structure_element.js @@ -4,6 +4,7 @@ By Ben Schmidt */ import PDFStructureContent from './structure_content'; +import PDFAnnotationReference from './structure_annotation'; class PDFStructureElement { constructor(document, type, options = {}, children = null) { @@ -71,6 +72,10 @@ class PDFStructureElement { this._addContentToParentTree(child); } + if (child instanceof PDFAnnotationReference) { + this._addAnnotationToParentTree(child.annotationRef); + } + if (typeof child === 'function' && this._attached) { // _contentForClosure() adds the content to the parent tree child = this._contentForClosure(child); @@ -90,6 +95,15 @@ class PDFStructureElement { }); } + _addAnnotationToParentTree(annotRef) { + const parentTreeKey = this.document.createStructParentTreeNextKey(); + + annotRef.data.StructParent = parentTreeKey; + + const parentTree = this.document.getStructParentTree(); + parentTree.add(parentTreeKey, this.dictionary); + } + setParent(parentRef) { if (this.dictionary.data.P) { throw new Error(`Structure element added to more than one parent`); @@ -137,13 +151,25 @@ class PDFStructureElement { return ( child instanceof PDFStructureElement || child instanceof PDFStructureContent || + child instanceof PDFAnnotationReference || typeof child === 'function' ); } _contentForClosure(closure) { const content = this.document.markStructureContent(this.dictionary.data.S); + + const prevStructElement = this.document._currentStructureElement; + this.document._currentStructureElement = this; + + const wasEnded = this._ended; + this._ended = false; + closure(); + + this._ended = wasEnded; + + this.document._currentStructureElement = prevStructElement; this.document.endMarkedContent(); this._addContentToParentTree(content); @@ -209,6 +235,16 @@ class PDFStructureElement { } }); } + + if (child instanceof PDFAnnotationReference) { + const pageRef = this.document.page.dictionary; + const objr = { + Type: 'OBJR', + Obj: child.annotationRef, + Pg: pageRef, + }; + this.dictionary.data.K.push(objr); + } } } diff --git a/tests/unit/annotations.spec.js b/tests/unit/annotations.spec.js index 2210f7c..34f2a97 100644 --- a/tests/unit/annotations.spec.js +++ b/tests/unit/annotations.spec.js @@ -178,4 +178,41 @@ describe('Annotations', () => { ]); }); }); + + describe('annotations with structure parent', () => { + test('should add structParent to link annotations', () => { + document = new PDFDocument({ + info: { CreationDate: new Date(Date.UTC(2018, 1, 1)) }, + compress: false, + tagged: true, + }); + + const docData = logData(document); + + const linkElement = document.struct('Link'); + document.addStructure(linkElement); + + document.link(100, 100, 100, 20, 'http://example.com', { + structParent: linkElement, + }); + + linkElement.end(); + document.end(); + + const dataStr = docData.join('\n'); + expect(dataStr).toContain('/StructParent 0'); + expect(dataStr).toContain('/Contents ()'); + }); + + test('should work without structParent (backwards compatibility)', () => { + const docData = logData(document); + + document.link(100, 100, 100, 20, 'http://example.com'); + document.end(); + + const dataStr = docData.join('\n'); + expect(dataStr).toContain('/Subtype /Link'); + expect(dataStr).not.toContain('/StructParent'); + }); + }); }); diff --git a/tests/unit/markings.spec.js b/tests/unit/markings.spec.js index 6486c17..c07fedf 100644 --- a/tests/unit/markings.spec.js +++ b/tests/unit/markings.spec.js @@ -525,6 +525,23 @@ EMC document.struct('Foo', [1]); }).toThrow(); }); + + test('_currentStructureElement tracking with closures', () => { + const section = document.struct('Sect'); + document.addStructure(section); + + let capturedStructElement = null; + + const paragraph = document.struct('P', () => { + capturedStructElement = document._currentStructureElement; + }); + + section.add(paragraph); + section.end(); + document.end(); + + expect(capturedStructElement).toBe(paragraph); + }); }); describe('accessible document', () => { diff --git a/tests/unit/structure_annotation.spec.js b/tests/unit/structure_annotation.spec.js new file mode 100644 index 0000000..28faa82 --- /dev/null +++ b/tests/unit/structure_annotation.spec.js @@ -0,0 +1,66 @@ +import PDFDocument from '../../lib/document'; +import PDFAnnotationReference from '../../lib/structure_annotation'; +import { logData } from './helpers'; + +describe('PDFAnnotationReference', () => { + let document; + + beforeEach(() => { + document = new PDFDocument({ + info: { CreationDate: new Date(Date.UTC(2018, 1, 1)) }, + compress: false, + tagged: true, + }); + }); + + test('should add annotation reference to structure element with StructParent', () => { + const docData = logData(document); + + const linkElement = document.struct('Link'); + document.addStructure(linkElement); + + const annotRef = document.ref({ + Type: 'Annot', + Subtype: 'Link', + Rect: [100, 100, 200, 120], + }); + + linkElement.add(new PDFAnnotationReference(annotRef)); + linkElement.end(); + annotRef.end(); + document.end(); + + const dataStr = docData.join('\n'); + expect(dataStr).toContain('/Type /OBJR'); + expect(dataStr).toContain('/StructParent 0'); + }); + + test('should handle multiple annotations with different StructParent values', () => { + const docData = logData(document); + + const section = document.struct('Sect'); + document.addStructure(section); + + const link1 = document.struct('Link'); + const link2 = document.struct('Link'); + section.add(link1); + section.add(link2); + + const annotRef1 = document.ref({ Type: 'Annot', Subtype: 'Link' }); + const annotRef2 = document.ref({ Type: 'Annot', Subtype: 'Link' }); + + link1.add(new PDFAnnotationReference(annotRef1)); + link2.add(new PDFAnnotationReference(annotRef2)); + + link1.end(); + link2.end(); + section.end(); + annotRef1.end(); + annotRef2.end(); + document.end(); + + const dataStr = docData.join('\n'); + expect(dataStr).toContain('/StructParent 0'); + expect(dataStr).toContain('/StructParent 1'); + }); +}); diff --git a/tests/unit/text.spec.js b/tests/unit/text.spec.js index 6a1153c..8d6cc7e 100644 --- a/tests/unit/text.spec.js +++ b/tests/unit/text.spec.js @@ -193,4 +193,90 @@ Q expect(docData).toContainText({ text }); }); }); + + describe('text with structure parent links', () => { + beforeEach(() => { + document = new PDFDocument({ + info: { CreationDate: new Date(Date.UTC(2018, 1, 1)) }, + compress: false, + tagged: true, + }); + }); + + test('should auto-link text inside Link structure element', () => { + const docData = logData(document); + + const linkElement = document.struct('Link', () => { + document.text('Click here', 100, 100, { + link: 'http://example.com', + }); + }); + + document.addStructure(linkElement); + linkElement.end(); + document.end(); + + const dataStr = docData.join('\n'); + expect(dataStr).toContain('/S /Link'); + expect(dataStr).toContain('/StructParent'); + }); + + test('should not add StructParent outside Link structure', () => { + const docData = logData(document); + + document.text('Click here', 100, 100, { + link: 'http://example.com', + }); + + document.end(); + + const dataStr = docData.join('\n'); + expect(dataStr).toContain('/Subtype /Link'); + expect(dataStr).not.toContain('/StructParent'); + }); + + test('should not leak link options to subsequent structure elements with continued text', () => { + const docData = logData(document); + + const paragraph = document.struct('P'); + document.addStructure(paragraph); + + paragraph.add( + document.struct('Span', () => { + document.text('This is some text before ', 100, 100, { + continued: true, + }); + }), + ); + + paragraph.add( + document.struct('Link', () => { + document.text('Here is a link!', { + link: 'http://google.com/', + underline: true, + continued: true, + }); + }), + ); + + paragraph.add( + document.struct('Span', () => { + document.text(' and this is text after the link.'); + }), + ); + + paragraph.end(); + document.end(); + + const dataStr = docData.join('\n'); + + // Count how many link annotations exist - should be exactly 1 + const linkMatches = dataStr.match(/\/Subtype \/Link/g); + expect(linkMatches).toBeTruthy(); + expect(linkMatches.length).toBe(1); + + expect(dataStr).toContain('/S /Span'); + expect(dataStr).toContain('/S /Link'); + }); + }); });