Skip to content
Closed
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
Binary file added demo/out_outlines.pdf
Binary file not shown.
82 changes: 82 additions & 0 deletions demo/test_outlines.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
PDFDocument = require 'pdfkit'
fs = require 'fs'

docOptions = {
hasOutlines: true, # required for outlines
margins: { top : 40, left : 40, bottom : 40, right : 30 } }


# Create a new PDFDocument
doc = new PDFDocument(docOptions)
doc.pipe fs.createWriteStream('out_outlines.pdf')

doc.fontSize(16)
title = 'Test Document With Outlines'

# add outline on current level (0), with outline options:
# 'FitH' - top coordinate is positioned at the top edge of the window,
# document fits the entire width.
# Can be any type according to PDF 1.3 specification, 7.2.1 Destinations (Table 7.2)
doc.addOutline( title, doc.page, { type: 'FitH', top : 0 } )

# text to point by outline should be added next
doc.text(title, { paragraphGap : 8, align : 'center' } )

doc.fontSize(12) # usual text

doc.info['Title'] = title
doc.info['Author'] = 'Alexey Voytenko'

loremIpsum_0_1 = 'LOREM IPSUM DOLOR.
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.'

loremIpsum_1_1 = 'CRAS LEO NEQUE.
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.'

loremIpsum_1_2 = 'AENEAN VELIT ERAT.
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;
Mauris at ante tellus. Vestibulum a metus lectus. Praesent tempor purus a lacus blandit eget
gravida ante hendrerit.'

loremIpsum_0_2 = 'CRAS ET EROS METUS.
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.'

# add outline on current level (0) with default options - 'XYZ', positioning on text
doc.addOutline('Lorem ipsum dolor', doc.page);

doc.text( loremIpsum_0_1,
{ paragraphGap : 2, indent : 30, align : 'justify' } )

# create next level (1) of table of contents and add outline
doc.addSublevelOutline('Cras leo neque', doc.page);
doc.text( loremIpsum_1_1, { paragraphGap : 2, indent : 30, align : 'justify' } );

# add outline on the same level 1 with positioning on text
doc.addOutline('Aenean velit erat', doc.page );

doc.text( loremIpsum_1_2, { paragraphGap : 2, indent : 30, align : 'justify' } );

# go up in outlines levels
doc.endOutlineSublevel();

outl_options = { type: 'XYZ', unicode: true }

# add outline on the level 0 with positioning on text ('XYZ' is by default),
# using non-ASCII characters in outline title: set unicode in this case
doc.addOutline('Тестовый заголовок', doc.page, outl_options);

doc.text( loremIpsum_0_2, { paragraphGap : 2, indent : 30, align : 'justify' } );

doc.end()
console.log "out_outlines.pdf created"
110 changes: 109 additions & 1 deletion lib/document.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ fs = require 'fs'
PDFObject = require './object'
PDFReference = require './reference'
PDFPage = require './page'
PDFOutline = require './outline'

class PDFDocument extends stream.Readable
constructor: (@options = {}) ->
Expand All @@ -31,9 +32,24 @@ class PDFDocument extends stream.Readable
Type: 'Pages'
Count: 0
Kids: []

if @options.hasOutlines
Copy link
Member

Choose a reason for hiding this comment

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

Instead of an option, can these extra pieces of data be added automatically later once the first outline has been added?

@_root.data['PageMode'] = 'UseOutlines'
@_root.data['Outlines'] = @ref
Type: 'Outlines'
Count: 0

@root_outlines = @_root.data['Outlines']

# The current page
@page = null

# Outlines data structures
@outlines = [] # a multitier tree, represented by lists ( [] )
@cur = @outlines # current level list
@levelHead = @root_outlines # head of current level list
@levs = [] # stack of sublevel lists heads
@prevs = [] # stack of sublevel lists

# Initialize mixins
@initColor()
Expand Down Expand Up @@ -97,10 +113,100 @@ class PDFDocument extends stream.Readable
@transform 1, 0, 0, -1, 0, @page.height

return this

isRoot: () ->
return @prevs.length == 0

addOutline: (title, dest, options) ->
Copy link
Member

Choose a reason for hiding this comment

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

The destination should always be the current page. Is there anything else it could be? If not, then why make the user pass the page everywhere themselves?

Copy link
Author

Choose a reason for hiding this comment

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

Destination is always set relatively to the page. Theoretically it can be any page, but in practice I agree - only current page is useful. Let's omit this parameter.


if not @options.hasOutlines
return this

prevNode = null
if @isRoot()
parent = @root_outlines
else
parent = @levelHead.dictionary

if @cur.length > 0 # can be on Root level
prevNode = @cur[@cur.length-1]
if prevNode instanceof Array # prev is a sublevel list
prevNode = @cur[@cur.length-2] # there *must* be an element before

# create an outline object
outline = new PDFOutline(this, parent, title, dest, options)

# update list header info
if @outlines.length == 0 # init root level list
@root_outlines.data['First'] = outline.dictionary

parent.data['Last'] = outline.dictionary # can be root level
parent.data['Count']++

if prevNode != null # not first in the list
outline.dictionary.data['Prev'] = prevNode.dictionary
prevNode.dictionary.data['Next'] = outline.dictionary

# add to the current level list
@cur.push outline

return this

addSublevelOutline: (title, dest, options) ->

if not @options.hasOutlines
return this

if @cur.length == 0 or ( @cur[@cur.length-1] instanceof Array )
throw new Error '
Cannot start sublevel with empty current level:
add current level outline first.'

@levs.push @levelHead
@levelHead = @cur[@cur.length-1] # head of sublevel list
@prevs.push @cur # remember up level container
@cur.push [] # add sublevel container
@cur = @cur[@cur.length-1]

outline = new PDFOutline(this, @levelHead.dictionary, title, dest, options)
@cur.push outline
@levelHead.dictionary.data['First'] = outline.dictionary
@levelHead.dictionary.data['Last'] = outline.dictionary
@levelHead.dictionary.data['Count'] = 1 # important: not ++ (field is absent now)

return this

endOutlineSublevel: () ->

if not @options.hasOutlines
return this

if @isRoot()
throw new Error 'cannot end root outlines level'

# restore previous list as current
@cur = @prevs.pop()
count = @levelHead.dictionary.data['Count']
@levelHead = @levs.pop()

# add child sublist counter (will contain all child counters)
if @isRoot()
@levelHead.data['Count'] += count
else
@levelHead.dictionary.data['Count'] += count

return this

endOutlines: (outlines) ->
for outline in outlines
if outline instanceof Array
@endOutlines(outline)
else
outline.dictionary.end()

ref: (data) ->
ref = new PDFReference(this, @_offsets.length + 1, data)
@_offsets.push null # placeholder for this object's offset once it is finalized
@_offsets.push null # placeholder for this object''s offset once it is finalized
@_waiting++
return ref

Expand Down Expand Up @@ -161,6 +267,8 @@ class PDFDocument extends stream.Readable

@_root.end()
@_root.data.Pages.end()
@_root.data.Outlines.end()
@endOutlines(@outlines)

if @_waiting is 0
@_finalize()
Copy link
Member

Choose a reason for hiding this comment

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

All of the stuff you added to PDFDocument directly should be a mixin in a separate file, the same way vector graphics, fonts, etc. are. To do this, you make a separate file in the mixins/ directory that exports an object of functions to be added to the PDFDocument class. Then, in PDFDocument call the mixin helper to load it. And if you need to do any initialization, do so in the constructor by calling your initialization method.

Copy link
Member

Choose a reason for hiding this comment

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

If you decide to implement the alternative API I described in the PR, then this isn't necessary since that would be a separate object anyway.

Expand Down
54 changes: 54 additions & 0 deletions lib/outline.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
###
PDFOutline - represents a table of contents in the PDF 1.3 document
By Alexey Voytenko
see also methods addOutline, addSublevelOutline, endOutlineSublevel
of PDFDocument class
###

PDFObject = require './object'
PDFPage = require './page'

class PDFOutline
constructor: ( @document, parent, @title, @dest, @options = {} ) ->

@type = @options.type or 'XYZ'
@unicode = @options.unicode or false

# to convert library y coordinate to requred user space coordinate
height = @document.page.height

# see PDFReference 1.3, 7.2.1 Destinations
switch @type
when 'Fit', 'FitB'
destination = [@dest.dictionary, @type]

when 'FitH', 'FitBH'
top = height - ( @options.top or 0 )
destination = [@dest.dictionary, @type, top]

when 'FitV', 'FitBV'
top = height - ( @options.left or 0 )
Copy link
Member

Choose a reason for hiding this comment

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

Are you sure you meant height here and not width? Seems like you're subtracting components of different axes.

destination = [@dest.dictionary, @type, left]

when 'FitR'
left = @options.left or 0
bottom = height - ( @options.bottom or 0 )
right = @options.right or 0
top = height - ( @options.top or 0 )
destination = [@dest.dictionary, @type, left, bottom, right, top]

else # assuming 'XYZ' - most common
# XY - coordinates of page top left in user space system,
# Z - zoom factor (null for X,Y,Z - previous value unchanged)
X = @options.X or 0
Y = height - ( @options.Y or @document.y )
Z = @options.Z or null
destination = [@dest.dictionary, @type, X, Y, Z]

# The outline dictionary
@dictionary = @document.ref
Title: PDFObject.s @title, @unicode # need to specify if unicode
Copy link
Member

Choose a reason for hiding this comment

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

It should always be unicode, no need for an option. That's more of an implementation detail.

Copy link
Author

Choose a reason for hiding this comment

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

By default it is not unicode in 's'. We can make unicode default value in PDFOutline class, agree.

Parent: parent # dictionary
Dest: destination

module.exports = PDFOutline
2 changes: 1 addition & 1 deletion lib/page.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,4 @@ class PDFPage
LETTER: [612.00, 792.00]
TABLOID: [792.00, 1224.00]

module.exports = PDFPage
module.exports = PDFPage