mirror of
https://github.com/foliojs/pdfkit.git
synced 2025-12-08 20:15:54 +00:00
These offsets are generated by fontkit when laying out glyphs, for example accents which should appear above other glyphs. To render them correctly in PDF, we have to break out of the TJ command and move the text position manually.
312 lines
8.8 KiB
CoffeeScript
312 lines
8.8 KiB
CoffeeScript
LineWrapper = require '../line_wrapper'
|
|
|
|
module.exports =
|
|
initText: ->
|
|
# Current coordinates
|
|
@x = 0
|
|
@y = 0
|
|
@_lineGap = 0
|
|
|
|
lineGap: (@_lineGap) ->
|
|
return this
|
|
|
|
moveDown: (lines = 1) ->
|
|
@y += @currentLineHeight(true) * lines + @_lineGap
|
|
return this
|
|
|
|
moveUp: (lines = 1) ->
|
|
@y -= @currentLineHeight(true) * lines + @_lineGap
|
|
return this
|
|
|
|
_text: (text, x, y, options, lineCallback) ->
|
|
options = @_initOptions(x, y, options)
|
|
|
|
# Convert text to a string
|
|
text = '' + text
|
|
|
|
# if the wordSpacing option is specified, remove multiple consecutive spaces
|
|
if options.wordSpacing
|
|
text = text.replace(/\s{2,}/g, ' ')
|
|
|
|
# word wrapping
|
|
if options.width
|
|
wrapper = @_wrapper
|
|
unless wrapper
|
|
wrapper = new LineWrapper(this, options)
|
|
wrapper.on 'line', lineCallback
|
|
|
|
@_wrapper = if options.continued then wrapper else null
|
|
@_textOptions = if options.continued then options else null
|
|
wrapper.wrap text, options
|
|
|
|
# render paragraphs as single lines
|
|
else
|
|
lineCallback line, options for line in text.split '\n'
|
|
|
|
return this
|
|
|
|
text: (text, x, y, options) ->
|
|
@_text text, x, y, options, @_line.bind(this)
|
|
|
|
widthOfString: (string, options = {}) ->
|
|
@_font.widthOfString(string, @_fontSize, options.features) + (options.characterSpacing or 0) * (string.length - 1)
|
|
|
|
heightOfString: (text, options = {}) ->
|
|
{x,y} = this
|
|
|
|
options = @_initOptions(options)
|
|
options.height = Infinity # don't break pages
|
|
|
|
lineGap = options.lineGap or @_lineGap or 0
|
|
@_text text, @x, @y, options, (line, options) =>
|
|
@y += @currentLineHeight(true) + lineGap
|
|
|
|
height = @y - y
|
|
@x = x
|
|
@y = y
|
|
|
|
return height
|
|
|
|
list: (list, x, y, options, wrapper) ->
|
|
options = @_initOptions(x, y, options)
|
|
|
|
r = Math.round (@_font.ascender / 1000 * @_fontSize) / 3
|
|
indent = options.textIndent or r * 5
|
|
itemIndent = options.bulletIndent or r * 8
|
|
|
|
level = 1
|
|
items = []
|
|
levels = []
|
|
|
|
flatten = (list) ->
|
|
for item, i in list
|
|
if Array.isArray(item)
|
|
level++
|
|
flatten(item)
|
|
level--
|
|
else
|
|
items.push(item)
|
|
levels.push(level)
|
|
|
|
flatten(list)
|
|
|
|
wrapper = new LineWrapper(this, options)
|
|
wrapper.on 'line', @_line.bind(this)
|
|
|
|
level = 1
|
|
i = 0
|
|
wrapper.on 'firstLine', =>
|
|
if (l = levels[i++]) isnt level
|
|
diff = itemIndent * (l - level)
|
|
@x += diff
|
|
wrapper.lineWidth -= diff
|
|
level = l
|
|
|
|
@circle @x - indent + r, @y + r + (r / 2), r
|
|
@fill()
|
|
|
|
wrapper.on 'sectionStart', =>
|
|
pos = indent + itemIndent * (level - 1)
|
|
@x += pos
|
|
wrapper.lineWidth -= pos
|
|
|
|
wrapper.on 'sectionEnd', =>
|
|
pos = indent + itemIndent * (level - 1)
|
|
@x -= pos
|
|
wrapper.lineWidth += pos
|
|
|
|
wrapper.wrap items.join('\n'), options
|
|
|
|
return this
|
|
|
|
_initOptions: (x = {}, y, options = {}) ->
|
|
if typeof x is 'object'
|
|
options = x
|
|
x = null
|
|
|
|
# clone options object
|
|
options = do ->
|
|
opts = {}
|
|
opts[k] = v for k, v of options
|
|
return opts
|
|
|
|
# extend options with previous values for continued text
|
|
if @_textOptions
|
|
for key, val of @_textOptions when key isnt 'continued'
|
|
options[key] ?= val
|
|
|
|
# Update the current position
|
|
if x?
|
|
@x = x
|
|
if y?
|
|
@y = y
|
|
|
|
# wrap to margins if no x or y position passed
|
|
unless options.lineBreak is false
|
|
margins = @page.margins
|
|
options.width ?= @page.width - @x - margins.right
|
|
|
|
options.columns ||= 0
|
|
options.columnGap ?= 18 # 1/4 inch
|
|
|
|
return options
|
|
|
|
_line: (text, options = {}, wrapper) ->
|
|
@_fragment text, @x, @y, options
|
|
lineGap = options.lineGap or @_lineGap or 0
|
|
|
|
if not wrapper
|
|
@x += @widthOfString text
|
|
else
|
|
@y += @currentLineHeight(true) + lineGap
|
|
|
|
_fragment: (text, x, y, options) ->
|
|
text = ('' + text).replace(/\n/g, '')
|
|
return if text.length is 0
|
|
|
|
# handle options
|
|
align = options.align or 'left'
|
|
wordSpacing = options.wordSpacing or 0
|
|
characterSpacing = options.characterSpacing or 0
|
|
|
|
# text alignments
|
|
if options.width
|
|
switch align
|
|
when 'right'
|
|
textWidth = @widthOfString text.replace(/\s+$/, ''), options
|
|
x += options.lineWidth - textWidth
|
|
|
|
when 'center'
|
|
x += options.lineWidth / 2 - options.textWidth / 2
|
|
|
|
when 'justify'
|
|
# calculate the word spacing value
|
|
words = text.trim().split(/\s+/)
|
|
textWidth = @widthOfString(text.replace(/\s+/g, ''), options)
|
|
spaceWidth = @widthOfString(' ') + characterSpacing
|
|
wordSpacing = Math.max 0, (options.lineWidth - textWidth) / Math.max(1, words.length - 1) - spaceWidth
|
|
|
|
# calculate the actual rendered width of the string after word and character spacing
|
|
renderedWidth = options.textWidth + (wordSpacing * (options.wordCount - 1)) + (characterSpacing * (text.length - 1))
|
|
|
|
# create link annotations if the link option is given
|
|
if options.link
|
|
@link x, y, renderedWidth, @currentLineHeight(), options.link
|
|
|
|
# create underline or strikethrough line
|
|
if options.underline or options.strike
|
|
@save()
|
|
@strokeColor @_fillColor... unless options.stroke
|
|
|
|
lineWidth = if @_fontSize < 10 then 0.5 else Math.floor(@_fontSize / 10)
|
|
@lineWidth lineWidth
|
|
|
|
d = if options.underline then 1 else 2
|
|
lineY = y + @currentLineHeight() / d
|
|
lineY -= lineWidth if options.underline
|
|
|
|
@moveTo x, lineY
|
|
@lineTo x + renderedWidth, lineY
|
|
@stroke()
|
|
@restore()
|
|
|
|
# flip coordinate system
|
|
@save()
|
|
@transform 1, 0, 0, -1, 0, @page.height
|
|
y = @page.height - y - (@_font.ascender / 1000 * @_fontSize)
|
|
|
|
# add current font to page if necessary
|
|
@page.fonts[@_font.id] ?= @_font.ref()
|
|
|
|
# begin the text object
|
|
@addContent "BT"
|
|
|
|
# text position
|
|
@addContent "1 0 0 1 #{x} #{y} Tm"
|
|
|
|
# font and font size
|
|
@addContent "/#{@_font.id} #{@_fontSize} Tf"
|
|
|
|
# rendering mode
|
|
mode = if options.fill and options.stroke then 2 else if options.stroke then 1 else 0
|
|
@addContent "#{mode} Tr" if mode
|
|
|
|
# Character spacing
|
|
@addContent "#{characterSpacing} Tc" if characterSpacing
|
|
|
|
# Add the actual text
|
|
# If we have a word spacing value, we need to encode each word separately
|
|
# since the normal Tw operator only works on character code 32, which isn't
|
|
# used for embedded fonts.
|
|
if wordSpacing
|
|
words = text.trim().split(/\s+/)
|
|
wordSpacing += @widthOfString(' ') + characterSpacing
|
|
wordSpacing *= 1000 / @_fontSize
|
|
|
|
encoded = []
|
|
positions = []
|
|
for word in words
|
|
[encodedWord, positionsWord] = @_font.encode(word, options.features)
|
|
encoded.push encodedWord...
|
|
positions.push positionsWord...
|
|
|
|
# add the word spacing to the end of the word
|
|
positions[positions.length - 1].xAdvance += wordSpacing
|
|
else
|
|
[encoded, positions] = @_font.encode(text, options.features)
|
|
|
|
scale = @_fontSize / 1000
|
|
commands = []
|
|
last = 0
|
|
hadOffset = no
|
|
|
|
# Adds a segment of text to the TJ command buffer
|
|
addSegment = (cur) =>
|
|
if last < cur
|
|
hex = encoded.slice(last, cur).join ''
|
|
advance = positions[cur - 1].xAdvance - positions[cur - 1].advanceWidth
|
|
commands.push "<#{hex}> #{-advance}"
|
|
|
|
last = cur
|
|
|
|
# Flushes the current TJ commands to the output stream
|
|
flush = (i) =>
|
|
addSegment i
|
|
|
|
if commands.length > 0
|
|
@addContent "[#{commands.join ' '}] TJ"
|
|
commands.length = 0
|
|
|
|
for pos, i in positions
|
|
# If we have an x or y offset, we have to break out of the current TJ command
|
|
# so we can move the text position.
|
|
if pos.xOffset or pos.yOffset
|
|
# Flush the current buffer
|
|
flush i
|
|
|
|
# Move the text position and flush just the current character
|
|
@addContent "1 0 0 1 #{x + pos.xOffset * scale} #{y + pos.yOffset * scale} Tm"
|
|
flush i + 1
|
|
|
|
hadOffset = yes
|
|
else
|
|
# If the last character had an offset, reset the text position
|
|
if hadOffset
|
|
@addContent "1 0 0 1 #{x} #{y} Tm"
|
|
hadOffset = no
|
|
|
|
# Group segments that don't have any advance adjustments
|
|
unless pos.xAdvance - pos.advanceWidth is 0
|
|
addSegment i + 1
|
|
|
|
x += pos.xAdvance * scale
|
|
|
|
# Flush any remaining commands
|
|
flush i
|
|
|
|
# end the text object
|
|
@addContent "ET"
|
|
|
|
# restore flipped coordinate system
|
|
@restore()
|