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 <akowalczewski@gn.com>
This commit is contained in:
Adrian Kowalczewski 2025-12-18 14:53:27 +01:00 committed by GitHub
parent d0108157f1
commit 0b01ef4146
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 378 additions and 2 deletions

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ playground/
build/
js/
.vscode
.idea
coverage
package-lock.json
/examples/browserify/bundle.js

View File

@ -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

View File

@ -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();

Binary file not shown.

View File

@ -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'));

Binary file not shown.

View File

@ -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);
},

View File

@ -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;
},

View File

@ -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);

View File

@ -0,0 +1,7 @@
class PDFAnnotationReference {
constructor(annotationRef) {
this.annotationRef = annotationRef;
}
}
export default PDFAnnotationReference;

View File

@ -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);
}
}
}

View File

@ -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');
});
});
});

View File

@ -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', () => {

View File

@ -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');
});
});

View File

@ -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');
});
});
});