Pdf-lib: How to create a table of contents (document outline)?

Created on 15 Jun 2019  路  10Comments  路  Source: Hopding/pdf-lib

Hi @Hopding,

Again thanks for your awesome lib 馃憤

Do you think it's possible to create a table of contents in a PDF with your libs ? If it's possible I would love to see how to do that.

Thanks 馃憤

BTW you should add a way to support your work 馃槈

Most helpful comment

Yes, this is definitely possible to do. As with page links, pdf-lib doesn't have a high level API for it yet. So it requires a bit of lower-level code. But it works all the same!

I created an example script to demonstrate how to do it. Here's the resulting PDF, along with a screenshot previewing the outline panel: with_outline.pdf
Screen Shot 2019-06-16 at 7 52 26 AM

And here's the script itself:

// ...imports omitted...

const PAGE_WIDTH = 500;
const PAGE_HEIGHT = 750;

const getPageRefs = (pdfDoc) => {
  const refs = [];
  pdfDoc.catalog.Pages.traverse((kid, ref) => {
    if (kid instanceof PDFPage) refs.push(ref);
  });
  return refs;
};

const createOutlineItem = (pdfDoc, title, parent, nextOrPrev, page, isLast = false) =>
  PDFDictionary.from(
    {
      Title: PDFString.fromString(title),
      Parent: parent,
      [isLast ? 'Prev' : 'Next']: nextOrPrev,
      Dest: PDFArray.fromArray(
        [
          page,
          PDFName.from('XYZ'),
          PDFNull.instance,
          PDFNull.instance,
          PDFNull.instance,
        ],
        pdfDoc.index,
      ),
    },
    pdfDoc.index,
  );

const pdfDoc = PDFDocumentFactory.create();

const [fontRef, font] = pdfDoc.embedStandardFont(StandardFonts.Helvetica);

const contentStream1 = pdfDoc.register(
  pdfDoc.createContentStream(
    drawText(font.encodeText('PAGE 1'), {
      font: 'Helvetica',
      size: 50,
      x: 175,
      y: PAGE_HEIGHT - 100,
    }),
  ),
);
const contentStream2 = pdfDoc.register(
  pdfDoc.createContentStream(
    drawText(font.encodeText('PAGE 2'), {
      font: 'Helvetica',
      size: 50,
      x: 175,
      y: PAGE_HEIGHT - 100,
    }),
  ),
);
const contentStream3 = pdfDoc.register(
  pdfDoc.createContentStream(
    drawText(font.encodeText('PAGE 3'), {
      font: 'Helvetica',
      size: 50,
      x: 175,
      y: PAGE_HEIGHT - 100,
    }),
  ),
);

const page1 = pdfDoc
  .createPage([PAGE_WIDTH, PAGE_HEIGHT])
  .addFontDictionary('Helvetica', fontRef)
  .addContentStreams(contentStream1);
const page2 = pdfDoc
  .createPage([PAGE_WIDTH, PAGE_HEIGHT])
  .addFontDictionary('Helvetica', fontRef)
  .addContentStreams(contentStream2);
const page3 = pdfDoc
  .createPage([PAGE_WIDTH, PAGE_HEIGHT])
  .addFontDictionary('Helvetica', fontRef)
  .addContentStreams(contentStream3);

pdfDoc.addPage(page1);
pdfDoc.addPage(page2);
pdfDoc.addPage(page3);

const pageRefs = getPageRefs(pdfDoc);

const outlinesDictRef = pdfDoc.index.nextObjectNumber();
const outlineItem1Ref = pdfDoc.index.nextObjectNumber();
const outlineItem2Ref = pdfDoc.index.nextObjectNumber();
const outlineItem3Ref = pdfDoc.index.nextObjectNumber();

const outlineItem1 = createOutlineItem(
  pdfDoc,
  'Page 1',
  outlinesDictRef,
  outlineItem2Ref,
  pageRefs[0],
);

const outlineItem2 = createOutlineItem(
  pdfDoc,
  'Page 2',
  outlinesDictRef,
  outlineItem3Ref,
  pageRefs[1],
);

const outlineItem3 = createOutlineItem(
  pdfDoc,
  'Page 3',
  outlinesDictRef,
  outlineItem2Ref,
  pageRefs[2],
  true,
);

const outlinesDict = PDFDictionary.from(
  {
    Type: PDFName.from('Outlines'),
    First: outlineItem1Ref,
    Last: outlineItem3Ref,
    Count: PDFNumber.fromNumber(3),
  },
  pdfDoc.index,
);

pdfDoc.index.assign(outlinesDictRef, outlinesDict);
pdfDoc.index.assign(outlineItem1Ref, outlineItem1);
pdfDoc.index.assign(outlineItem2Ref, outlineItem2);
pdfDoc.index.assign(outlineItem3Ref, outlineItem3);

pdfDoc.catalog.set('Outlines', outlinesDictRef);

const pdfBytes = PDFDocumentWriter.saveToBytes(pdfDoc);

fs.writeFileSync('./with_outline.pdf', pdfBytes);

This is, of course, a very simple document outline without any nesting. If you'd like to create something more complex, with multiple nested levels, you can certainly do so. However, I'll refer you to section 12.3.3 Document Outline and annex H.6 Outline Hierarchy Example of the PDF specification for the details.

I hope this helps. Please let me know if you have any additional questions!

All 10 comments

Hello @Johann-S!

I'm not sure what precisely you mean by "table of contents". You can certainly write out some text on a page to outline the contents of your document. Are you wanting to do something more than that?

Yep some PDF have a table of contents inside them, but not on a separate page, sometimes it's called signets too

You mean like this?
Screen Shot 2019-06-16 at 7 22 26 AM

Yep exactly !

Yes, this is definitely possible to do. As with page links, pdf-lib doesn't have a high level API for it yet. So it requires a bit of lower-level code. But it works all the same!

I created an example script to demonstrate how to do it. Here's the resulting PDF, along with a screenshot previewing the outline panel: with_outline.pdf
Screen Shot 2019-06-16 at 7 52 26 AM

And here's the script itself:

// ...imports omitted...

const PAGE_WIDTH = 500;
const PAGE_HEIGHT = 750;

const getPageRefs = (pdfDoc) => {
  const refs = [];
  pdfDoc.catalog.Pages.traverse((kid, ref) => {
    if (kid instanceof PDFPage) refs.push(ref);
  });
  return refs;
};

const createOutlineItem = (pdfDoc, title, parent, nextOrPrev, page, isLast = false) =>
  PDFDictionary.from(
    {
      Title: PDFString.fromString(title),
      Parent: parent,
      [isLast ? 'Prev' : 'Next']: nextOrPrev,
      Dest: PDFArray.fromArray(
        [
          page,
          PDFName.from('XYZ'),
          PDFNull.instance,
          PDFNull.instance,
          PDFNull.instance,
        ],
        pdfDoc.index,
      ),
    },
    pdfDoc.index,
  );

const pdfDoc = PDFDocumentFactory.create();

const [fontRef, font] = pdfDoc.embedStandardFont(StandardFonts.Helvetica);

const contentStream1 = pdfDoc.register(
  pdfDoc.createContentStream(
    drawText(font.encodeText('PAGE 1'), {
      font: 'Helvetica',
      size: 50,
      x: 175,
      y: PAGE_HEIGHT - 100,
    }),
  ),
);
const contentStream2 = pdfDoc.register(
  pdfDoc.createContentStream(
    drawText(font.encodeText('PAGE 2'), {
      font: 'Helvetica',
      size: 50,
      x: 175,
      y: PAGE_HEIGHT - 100,
    }),
  ),
);
const contentStream3 = pdfDoc.register(
  pdfDoc.createContentStream(
    drawText(font.encodeText('PAGE 3'), {
      font: 'Helvetica',
      size: 50,
      x: 175,
      y: PAGE_HEIGHT - 100,
    }),
  ),
);

const page1 = pdfDoc
  .createPage([PAGE_WIDTH, PAGE_HEIGHT])
  .addFontDictionary('Helvetica', fontRef)
  .addContentStreams(contentStream1);
const page2 = pdfDoc
  .createPage([PAGE_WIDTH, PAGE_HEIGHT])
  .addFontDictionary('Helvetica', fontRef)
  .addContentStreams(contentStream2);
const page3 = pdfDoc
  .createPage([PAGE_WIDTH, PAGE_HEIGHT])
  .addFontDictionary('Helvetica', fontRef)
  .addContentStreams(contentStream3);

pdfDoc.addPage(page1);
pdfDoc.addPage(page2);
pdfDoc.addPage(page3);

const pageRefs = getPageRefs(pdfDoc);

const outlinesDictRef = pdfDoc.index.nextObjectNumber();
const outlineItem1Ref = pdfDoc.index.nextObjectNumber();
const outlineItem2Ref = pdfDoc.index.nextObjectNumber();
const outlineItem3Ref = pdfDoc.index.nextObjectNumber();

const outlineItem1 = createOutlineItem(
  pdfDoc,
  'Page 1',
  outlinesDictRef,
  outlineItem2Ref,
  pageRefs[0],
);

const outlineItem2 = createOutlineItem(
  pdfDoc,
  'Page 2',
  outlinesDictRef,
  outlineItem3Ref,
  pageRefs[1],
);

const outlineItem3 = createOutlineItem(
  pdfDoc,
  'Page 3',
  outlinesDictRef,
  outlineItem2Ref,
  pageRefs[2],
  true,
);

const outlinesDict = PDFDictionary.from(
  {
    Type: PDFName.from('Outlines'),
    First: outlineItem1Ref,
    Last: outlineItem3Ref,
    Count: PDFNumber.fromNumber(3),
  },
  pdfDoc.index,
);

pdfDoc.index.assign(outlinesDictRef, outlinesDict);
pdfDoc.index.assign(outlineItem1Ref, outlineItem1);
pdfDoc.index.assign(outlineItem2Ref, outlineItem2);
pdfDoc.index.assign(outlineItem3Ref, outlineItem3);

pdfDoc.catalog.set('Outlines', outlinesDictRef);

const pdfBytes = PDFDocumentWriter.saveToBytes(pdfDoc);

fs.writeFileSync('./with_outline.pdf', pdfBytes);

This is, of course, a very simple document outline without any nesting. If you'd like to create something more complex, with multiple nested levels, you can certainly do so. However, I'll refer you to section 12.3.3 Document Outline and annex H.6 Outline Hierarchy Example of the PDF specification for the details.

I hope this helps. Please let me know if you have any additional questions!

Thanks @Hopding you're a PDF expert 馃憤

Hi @Hopding - seems to me this example might be based on an older version of pdf-lib? Could you let me know what changes I should think about for using with the latest version?

Hi @Hopding - seems to me this example might be based on an older version of pdf-lib? Could you let me know what changes I should think about for using with the latest version?

@jackwshepherd, I was facing the same problem, but this JS lib still seems to be the best for my needs. Did some deep inspecting of the current code and compared it to the older versions and managed to update @Hopding's solution.

Do pardon me if my code is inefficient as I'm still quite new to JS and I'm writing code to be run in Electron.

const { PDFDocument, PDFPageLeaf, PDFDict, PDFString, PDFArray, PDFName, PDFNull, PDFNumber, } = require("pdf-lib");
const fs = require("fs");

async function creatOutlines() {
const doc = await PDFDocument.load(
    fs.readFileSync("##YOUR CURRENT FILE NAME##")
);

const getPageRefs = (pdfDoc) => {
    const refs = [];
    pdfDoc.catalog.Pages().traverse((kid, ref) => {
    if (kid instanceof PDFPageLeaf) refs.push(ref);
    });
    return refs;
};
//(PDFDocument, string, PDFRef, PDFRef, PDFRef, boolean)
const createOutlineItem = (pdfDoc, title, parent, nextOrPrev, page, isLast = false) => {
    let array = PDFArray.withContext(pdfDoc.context);
    array.push(page);
    array.push(PDFName.of("XYZ"));
    array.push(PDFNull);
    array.push(PDFNull);
    array.push(PDFNull);
    const map = new Map();
    map.set(PDFName.Title, PDFString.of(title));
    map.set(PDFName.Parent, parent);
    map.set(PDFName.of(isLast ? "Prev" : "Next"), nextOrPrev);
    map.set(PDFName.of("Dest"), array);

    return PDFDict.fromMapWithContext(map, pdfDoc.context);
}

const pageRefs = getPageRefs(doc);

const outlinesDictRef = doc.context.nextRef(); 
const outlineItem1Ref = doc.context.nextRef();
const outlineItem2Ref = doc.context.nextRef();
const outlineItem3Ref = doc.context.nextRef();

const outlineItem1 = createOutlineItem(
    doc,
    "Page 1",
    outlinesDictRef,
    outlineItem2Ref,
    pageRefs[0]
);

const outlineItem2 = createOutlineItem(
    doc,
    "Page 2",
    outlinesDictRef,
    outlineItem3Ref,
    pageRefs[1]
);

const outlineItem3 = createOutlineItem(
    doc,
    "Page 3",
    outlinesDictRef,
    outlineItem2Ref,
    pageRefs[2],
    true
);

const outlinesDictMap = new Map();
outlinesDictMap.set(PDFName.Type, PDFName.of("Outlines"));
outlinesDictMap.set(PDFName.of("First"), outlineItem1Ref);
outlinesDictMap.set(PDFName.of("Last"), outlineItem3Ref);
outlinesDictMap.set(PDFName.of("Count"), PDFNumber.of(3)); //This is a count of the number of outline items. Should be changed for X no. of outlines

//Pointing the "Outlines" property of the PDF's "Catalog" to the first object of your outlines
doc.catalog.set(PDFName.of("Outlines"),outlinesDictRef)

const outlinesDict = PDFDict.fromMapWithContext(outlinesDictMap, doc.context);

//First 'Outline' object. Refer to table H.3 in Annex H.6 of PDF Specification doc.
doc.context.assign(outlinesDictRef, outlinesDict);

//Actual outline items that will be displayed
doc.context.assign(outlineItem1Ref, outlineItem1);
doc.context.assign(outlineItem2Ref, outlineItem2);
doc.context.assign(outlineItem3Ref, outlineItem3);

const file = await doc.save();

fs.writeFileSync("##YOUR DESTINATION FILE NAME##", file);
}

creatOutlines();

It is a lot of work for 3 outlines. I will be working on nested outlines and I'd need that. Happy to share with anyone that might need it when I'm done with that.

Hi @Hopding - seems to me this example might be based on an older version of pdf-lib? Could you let me know what changes I should think about for using with the latest version?

@jackwshepherd, I was facing the same problem, but this JS lib still seems to be the best for my needs. Did some deep inspecting of the current code and compared it to the older versions and managed to update @Hopding's solution.

Do pardon me if my code is inefficient as I'm still quite new to JS and I'm writing code to be run in Electron.

const { PDFDocument, PDFPageLeaf, PDFDict, PDFString, PDFArray, PDFName, PDFNull, PDFNumber, } = require("pdf-lib");
const fs = require("fs");

async function creatOutlines() {
const doc = await PDFDocument.load(
    fs.readFileSync("##YOUR CURRENT FILE NAME##")
);

const getPageRefs = (pdfDoc) => {
    const refs = [];
    pdfDoc.catalog.Pages().traverse((kid, ref) => {
    if (kid instanceof PDFPageLeaf) refs.push(ref);
    });
    return refs;
};
//(PDFDocument, string, PDFRef, PDFRef, PDFRef, boolean)
const createOutlineItem = (pdfDoc, title, parent, nextOrPrev, page, isLast = false) => {
    let array = PDFArray.withContext(pdfDoc.context);
    array.push(page);
    array.push(PDFName.of("XYZ"));
    array.push(PDFNull);
    array.push(PDFNull);
    array.push(PDFNull);
    const map = new Map();
    map.set(PDFName.Title, PDFString.of(title));
    map.set(PDFName.Parent, parent);
    map.set(PDFName.of(isLast ? "Prev" : "Next"), nextOrPrev);
    map.set(PDFName.of("Dest"), array);

    return PDFDict.fromMapWithContext(map, pdfDoc.context);
}

const pageRefs = getPageRefs(doc);

const outlinesDictRef = doc.context.nextRef(); 
const outlineItem1Ref = doc.context.nextRef();
const outlineItem2Ref = doc.context.nextRef();
const outlineItem3Ref = doc.context.nextRef();

const outlineItem1 = createOutlineItem(
    doc,
    "Page 1",
    outlinesDictRef,
    outlineItem2Ref,
    pageRefs[0]
);

const outlineItem2 = createOutlineItem(
    doc,
    "Page 2",
    outlinesDictRef,
    outlineItem3Ref,
    pageRefs[1]
);

const outlineItem3 = createOutlineItem(
    doc,
    "Page 3",
    outlinesDictRef,
    outlineItem2Ref,
    pageRefs[2],
    true
);

const outlinesDictMap = new Map();
outlinesDictMap.set(PDFName.Type, PDFName.of("Outlines"));
outlinesDictMap.set(PDFName.of("First"), outlineItem1Ref);
outlinesDictMap.set(PDFName.of("Last"), outlineItem3Ref);
outlinesDictMap.set(PDFName.of("Count"), PDFNumber.of(3)); //This is a count of the number of outline items. Should be changed for X no. of outlines

//Pointing the "Outlines" property of the PDF's "Catalog" to the first object of your outlines
doc.catalog.set(PDFName.of("Outlines"),outlinesDictRef)

const outlinesDict = PDFDict.fromMapWithContext(outlinesDictMap, doc.context);

//First 'Outline' object. Refer to table H.3 in Annex H.6 of PDF Specification doc.
doc.context.assign(outlinesDictRef, outlinesDict);

//Actual outline items that will be displayed
doc.context.assign(outlineItem1Ref, outlineItem1);
doc.context.assign(outlineItem2Ref, outlineItem2);
doc.context.assign(outlineItem3Ref, outlineItem3);

const file = await doc.save();

fs.writeFileSync("##YOUR DESTINATION FILE NAME##", file);
}

creatOutlines();

It is a lot of work for 3 outlines. I will be working on nested outlines and I'd need that. Happy to share with anyone that might need it when I'm done with that.

I tried adopting your code and allowing a merge of n numbers of PDFs, with the option of adding a bookmark for each PDF with a specified name passed through an argument to command line. Code is as follows. Two bookmarks only are added (out of the expected 4 in the demo I was running), and they both had the same title. Any ideas where i've gone wrong?

const { PDFDocument, PDFPageLeaf, PDFDict, PDFString, PDFArray, PDFName, PDFNull, PDFNumber, StandardFonts, rgb } = require('pdf-lib');
const fs = require('fs');
const parameters = require('minimist')(process.argv.slice(2));

var mergeFiles = parameters["a"];
var mergeFLength = mergeFiles.length;
var bookMarkDescs = parameters["ab"];
var buffers = [];
var bookmarkPages = [];
var outlineItemArr = [];
var outlineItemRefsArr = [];
var pageCount = 0;

const getPageRefs = (pdfDoc) => {
    const refs = [];
    pdfDoc.catalog.Pages().traverse((kid, ref) => {
    if (kid instanceof PDFPageLeaf) refs.push(ref);
    });
    return refs;
};

const createOutlineItem = (pdfDoc, title, parent, nextOrPrev, page, isLast = false) => {
    let array = PDFArray.withContext(pdfDoc.context);
    array.push(page);
    array.push(PDFName.of("XYZ"));
    array.push(PDFNull);
    array.push(PDFNull);
    array.push(PDFNull);
    const map = new Map();
    map.set(PDFName.Title, PDFString.of(title));
    map.set(PDFName.Parent, parent);
    map.set(PDFName.of(isLast ? "Prev" : "Next"), nextOrPrev);
    map.set(PDFName.of("Dest"), array);

    return PDFDict.fromMapWithContext(map, pdfDoc.context);
}

for (var i = 0; i < mergeFLength; i++){ 
    var fileBuffer = fs.readFileSync(mergeFiles[i]);
    buffers.push(fileBuffer);
}

if(buffers.length != 0){
    var output_file = mergePDFDocuments(buffers);
}

async function mergePDFDocuments(documents) {
    const mergedPdf = await PDFDocument.create();

    for (let document of documents) {
        document = await PDFDocument.load(document);
        if(bookmarkPages.length == 0){
            bookmarkPages.push(0);
            pageCount = pageCount + document.getPageCount();
            bookmarkPages.push(pageCount);          
        } else {
            pageCount = pageCount + document.getPageCount();
            bookmarkPages.push(pageCount);
        }
        const copiedPages = await mergedPdf.copyPages(document, document.getPageIndices());
        copiedPages.forEach((page) => mergedPdf.addPage(page));    
    }
    bookmarkPages.pop();

    var references = getPageRefs(mergedPdf);    

    const outlinesDictRef = mergedPdf.context.nextRef(); 

    for (var y = 0; y < bookmarkPages.length; y++){ 
        var pdf_reference = references[bookmarkPages[y]];
        var bookmark_title = bookMarkDescs[y];
        var outlineItemRef = mergedPdf.context.nextRef();
        outlineItemRefsArr.push(outlineItemRef);
        if(bookmarkPages[bookmarkPages.length - 1] === bookmarkPages[y]){
            console.log("last value");
            var outlineItem_last = createOutlineItem(mergedPdf, bookmark_title, outlinesDictRef, outlineItemRef, pdf_reference, true);  
            outlineItemArr.push(outlineItem_last);          
        } else {
            var outlineItem = createOutlineItem(mergedPdf, bookmark_title, outlinesDictRef, outlineItemRef, pdf_reference);     
            outlineItemArr.push(outlineItem);
        }
    }

    var outlinesDictMap = new Map();
    var countOfBookmarks = outlineItemArr.length;
    var firstBookmark = outlineItemArr[0];
    var lastBookmark = outlineItemArr[countOfBookmarks-1];

    outlinesDictMap.set(PDFName.Type, PDFName.of("Outlines"));
    outlinesDictMap.set(PDFName.of("First"), firstBookmark);
    outlinesDictMap.set(PDFName.of("Last"), lastBookmark);
    outlinesDictMap.set(PDFName.of("Count"), PDFNumber.of(countOfBookmarks));   

    for (var z = 0; z < bookmarkPages.length; z++){ 
        mergedPdf.context.assign(outlineItemRefsArr[z], outlineItemArr[z]);
    }       

    mergedPdf.catalog.set(PDFName.of("Outlines"),outlinesDictRef);

    const outlinesDict = PDFDict.fromMapWithContext(outlinesDictMap, mergedPdf.context);

    mergedPdf.context.assign(outlinesDictRef, outlinesDict);    

    fs.writeFileSync('output_merge.pdf', await mergedPdf.save());
}

@Resurg3nt @feodormak @jackwshepherd @Hopding @Johann-S
I created a module that has a high level api and uses pdf-lib to add outline to outline-less pdfs. You can find it here.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

sdfereday picture sdfereday  路  4Comments

dhollenbeck picture dhollenbeck  路  4Comments

zimt28 picture zimt28  路  5Comments

AlanHadzic picture AlanHadzic  路  3Comments

jboxxx picture jboxxx  路  4Comments