Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ playground/
build/
js/
.vscode
.idea
coverage
package-lock.json
/examples/browserify/bundle.js
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 87 additions & 0 deletions examples/accessible-links.js
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kitchen-sink is a term meaning 'Everything', so kitchen-sink-accessible-tiny doesn't really make sense. I think accessible-links.js (matching the output pdf) is a better name.

Original file line number Diff line number Diff line change
@@ -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 added examples/accessible-links.pdf
Binary file not shown.
4 changes: 3 additions & 1 deletion examples/kitchen-sink-accessible.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
Binary file modified examples/kitchen-sink-accessible.pdf
Binary file not shown.
15 changes: 15 additions & 0 deletions lib/mixins/annotations.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import PDFAnnotationReference from '../structure_annotation';

export default {
annotate(x, y, w, h, options) {
options.Type = 'Annot';
Expand All @@ -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];
Expand All @@ -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;
},
Expand Down Expand Up @@ -77,6 +88,10 @@ export default {
options.A.end();
}

if (options.structParent && !options.Contents) {
options.Contents = new String('');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just started looking into the validation errors around links, and then I saw your PR, great work!

When I monkey patch your changes into my code, I am still getting this error:
Screenshot 2025-12-04 at 17 04 47

In what instances will the options.Contents be defined here? Because as far as I can see, the linkOptions will either be {} or { structParent: ... } - does this not mean that options.Contents is never defined and options.Contents always set to an empty string.

If I pass the alt text from the doc.struct('Link', {alt: 'alt text here'} used in your example, then this solves the problem, like this:

options.Contents = new String(options.structParent.dictionary.data.Alt || ''); - But I confess that I've only just started looking into this library, so it's still new to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @tr1stan what are you using to check the issue? I've tested on Acrobat PRO (tho I ran out of free trial just today) and on PAC

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, I've been using this:
https://pdfix.net/products/pdfix-desktop-lite/

This is in a document I'm rendering, not on your test document though, but I can't see anything that would test differently between the two.

}

return this.annotate(x, y, w, h, options);
},

Expand Down
7 changes: 7 additions & 0 deletions lib/mixins/markings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +103 to +107
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is necessary delete those?

If possible we should avoid deleting properties

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentioned that in the comment - _textOptions are inheriting previous properties, they were inheriting link properties as well 🤔 Fix removes the properties that shouldn't leak between structure elements

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, these properties shouldn't end up in the _textOptions, but that would require larger refactor

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be fair there are other places that follows this pattern.

}
return this;
},

Expand Down
16 changes: 15 additions & 1 deletion lib/mixins/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions lib/structure_annotation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class PDFAnnotationReference {
constructor(annotationRef) {
this.annotationRef = annotationRef;
}
}

export default PDFAnnotationReference;
36 changes: 36 additions & 0 deletions lib/structure_element.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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`);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
37 changes: 37 additions & 0 deletions tests/unit/annotations.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
17 changes: 17 additions & 0 deletions tests/unit/markings.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading