You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
typesetting/pitfall/pdfkit/lib/line_wrapper.coffee

231 lines
7.2 KiB
CoffeeScript

{EventEmitter} = require 'events'
LineBreaker = require 'linebreak'
class LineWrapper extends EventEmitter
constructor: (@document, options) ->
@indent = options.indent or 0
@characterSpacing = options.characterSpacing or 0
@wordSpacing = options.wordSpacing is 0
@columns = options.columns or 1
@columnGap = options.columnGap ? 18 # 1/4 inch
@lineWidth = (options.width - (@columnGap * (@columns - 1))) / @columns
@spaceLeft = @lineWidth
@startX = @document.x
@startY = @document.y
@column = 1
@ellipsis = options.ellipsis
@continuedX = 0
@features = options.features
# calculate the maximum Y position the text can appear at
if options.height?
@height = options.height
@maxY = @startY + options.height
else
@maxY = @document.page.maxY()
# handle paragraph indents
@on 'firstLine', (options) =>
# if this is the first line of the text segment, and
# we're continuing where we left off, indent that much
# otherwise use the user specified indent option
indent = @continuedX or @indent
@document.x += indent
@lineWidth -= indent
@once 'line', =>
@document.x -= indent
@lineWidth += indent
if options.continued and not @continuedX
@continuedX = @indent
@continuedX = 0 unless options.continued
# handle left aligning last lines of paragraphs
@on 'lastLine', (options) =>
align = options.align
options.align = 'left' if align is 'justify'
@lastLine = true
@once 'line', =>
@document.y += options.paragraphGap or 0
options.align = align
@lastLine = false
wordWidth: (word) ->
return @document.widthOfString(word, this) + @characterSpacing + @wordSpacing
eachWord: (text, fn) ->
# setup a unicode line breaker
breaker = new LineBreaker(text)
last = null
wordWidths = Object.create(null)
while bk = breaker.nextBreak()
word = text.slice(last?.position or 0, bk.position)
w = wordWidths[word] ?= @wordWidth word
# if the word is longer than the whole line, chop it up
# TODO: break by grapheme clusters, not JS string characters
if w > @lineWidth + @continuedX
# make some fake break objects
lbk = last
fbk = {}
while word.length
# fit as much of the word as possible into the space we have
l = word.length
while w > @spaceLeft
w = @wordWidth word.slice(0, --l)
# send a required break unless this is the last piece
fbk.required = l < word.length
shouldContinue = fn word.slice(0, l), w, fbk, lbk
lbk = required: false
# get the remaining piece of the word
word = word.slice(l)
w = @wordWidth word
break if shouldContinue is no
else
# otherwise just emit the break as it was given to us
shouldContinue = fn word, w, bk, last
break if shouldContinue is no
last = bk
return
wrap: (text, options) ->
# override options from previous continued fragments
@indent = options.indent if options.indent?
@characterSpacing = options.characterSpacing if options.characterSpacing?
@wordSpacing = options.wordSpacing if options.wordSpacing?
@ellipsis = options.ellipsis if options.ellipsis?
# make sure we're actually on the page
# and that the first line of is never by
# itself at the bottom of a page (orphans)
nextY = @document.y + @document.currentLineHeight(true)
if @document.y > @maxY or nextY > @maxY
@nextSection()
buffer = ''
textWidth = 0
wc = 0
lc = 0
y = @document.y # used to reset Y pos if options.continued (below)
emitLine = =>
options.textWidth = textWidth + @wordSpacing * (wc - 1)
options.wordCount = wc
options.lineWidth = @lineWidth
y = @document.y
@emit 'line', buffer, options, this
lc++
@emit 'sectionStart', options, this
@eachWord text, (word, w, bk, last) =>
if not last? or last.required
@emit 'firstLine', options, this
@spaceLeft = @lineWidth
if w <= @spaceLeft
buffer += word
textWidth += w
wc++
if bk.required or w > @spaceLeft
if bk.required
@emit 'lastLine', options, this
# if the user specified a max height and an ellipsis, and is about to pass the
# max height and max columns after the next line, append the ellipsis
lh = @document.currentLineHeight(true)
if @height? and @ellipsis and @document.y + lh * 2 > @maxY and @column >= @columns
@ellipsis = '' if @ellipsis is true # map default ellipsis character
buffer = buffer.replace(/\s+$/, '')
textWidth = @wordWidth buffer + @ellipsis
# remove characters from the buffer until the ellipsis fits
while textWidth > @lineWidth
buffer = buffer.slice(0, -1).replace(/\s+$/, '')
textWidth = @wordWidth buffer + @ellipsis
buffer = buffer + @ellipsis
if bk.required and w > @spaceLeft
buffer = word
textWidth = w
wc = 1
emitLine()
# if we've reached the edge of the page,
# continue on a new page or column
if @document.y + lh > @maxY
shouldContinue = @nextSection()
# stop if we reached the maximum height
unless shouldContinue
wc = 0
buffer = ''
return no
# reset the space left and buffer
if bk.required
@spaceLeft = @lineWidth
buffer = ''
textWidth = 0
wc = 0
else
# reset the space left and buffer
@spaceLeft = @lineWidth - w
buffer = word
textWidth = w
wc = 1
else
@spaceLeft -= w
if wc > 0
@emit 'lastLine', options, this
emitLine()
@emit 'sectionEnd', options, this
# if the wrap is set to be continued, save the X position
# to start the first line of the next segment at, and reset
# the y position
if options.continued is yes
@continuedX = 0 if lc > 1
@continuedX += options.textWidth
@document.y = y
else
@document.x = @startX
nextSection: (options) ->
@emit 'sectionEnd', options, this
if ++@column > @columns
# if a max height was specified by the user, we're done.
# otherwise, the default is to make a new page at the bottom.
return false if @height?
@document.addPage()
@column = 1
@startY = @document.page.margins.top
@maxY = @document.page.maxY()
@document.x = @startX
@document.fillColor @document._fillColor... if @document._fillColor
@emit 'pageBreak', options, this
else
@document.x += @lineWidth + @columnGap
@document.y = @startY
@emit 'columnBreak', options, this
@emit 'sectionStart', options, this
return true
module.exports = LineWrapper