unipdf/creator/styled_paragraph.go
Adrian-George Bostan eef3b8f13e Improve text chunk component (#181)
* Add more functionality to text chunks
* Add creator text chunk test cases
* Improve documentation of the text chunk Fit method
2019-10-08 20:41:20 +00:00

870 lines
22 KiB
Go

/*
* This file is subject to the terms and conditions defined in
* file 'LICENSE.md', which is part of this source code package.
*/
package creator
import (
"errors"
"fmt"
"strings"
"unicode"
"github.com/unidoc/unipdf/v3/common"
"github.com/unidoc/unipdf/v3/contentstream"
"github.com/unidoc/unipdf/v3/contentstream/draw"
"github.com/unidoc/unipdf/v3/core"
"github.com/unidoc/unipdf/v3/model"
)
// StyledParagraph represents text drawn with a specified font and can wrap across lines and pages.
// By default occupies the available width in the drawing context.
type StyledParagraph struct {
// Text chunks with styles that compose the paragraph.
chunks []*TextChunk
// Style used for the paragraph for spacing and offsets.
defaultStyle TextStyle
// Style used for the paragraph link annotations.
defaultLinkStyle TextStyle
// Text alignment: Align left/right/center/justify.
alignment TextAlignment
// The line relative height (default 1).
lineHeight float64
// Wrapping properties.
enableWrap bool
wrapWidth float64
// defaultWrap defines whether wrapping has been defined explictly or whether default behavior should
// be observed. Default behavior depends on context: normally wrap is expected, except for example in
// table cells wrapping is off by default.
defaultWrap bool
// Rotation angle (degrees).
angle float64
// Margins to be applied around the block when drawing on Page.
margins margins
// Positioning: relative / absolute.
positioning positioning
// Absolute coordinates (when in absolute mode).
xPos float64
yPos float64
// Scaling factors (1 default).
scaleX float64
scaleY float64
// Text chunk lines after wrapping to available width.
lines [][]*TextChunk
// Before render callback.
beforeRender func(p *StyledParagraph, ctx DrawContext)
}
// newStyledParagraph creates a new styled paragraph.
func newStyledParagraph(style TextStyle) *StyledParagraph {
// TODO: Can we wrap intellectually, only if given width is known?
return &StyledParagraph{
chunks: []*TextChunk{},
defaultStyle: style,
defaultLinkStyle: newLinkStyle(style.Font),
lineHeight: 1.0,
alignment: TextAlignmentLeft,
enableWrap: true,
defaultWrap: true,
angle: 0,
scaleX: 1,
scaleY: 1,
positioning: positionRelative,
}
}
// appendChunk adds the provided text chunk to the paragraph.
func (p *StyledParagraph) appendChunk(chunk *TextChunk) *TextChunk {
p.chunks = append(p.chunks, chunk)
p.wrapText()
return chunk
}
// Append adds a new text chunk to the paragraph.
func (p *StyledParagraph) Append(text string) *TextChunk {
chunk := NewTextChunk(text, p.defaultStyle)
return p.appendChunk(chunk)
}
// Insert adds a new text chunk at the specified position in the paragraph.
func (p *StyledParagraph) Insert(index uint, text string) *TextChunk {
l := uint(len(p.chunks))
if index > l {
index = l
}
chunk := NewTextChunk(text, p.defaultStyle)
p.chunks = append(p.chunks[:index], append([]*TextChunk{chunk}, p.chunks[index:]...)...)
p.wrapText()
return chunk
}
// AddExternalLink adds a new external link to the paragraph.
// The text parameter represents the text that is displayed and the url
// parameter sets the destionation of the link.
func (p *StyledParagraph) AddExternalLink(text, url string) *TextChunk {
chunk := NewTextChunk(text, p.defaultLinkStyle)
chunk.annotation = newExternalLinkAnnotation(url)
return p.appendChunk(chunk)
}
// AddInternalLink adds a new internal link to the paragraph.
// The text parameter represents the text that is displayed.
// The user is taken to the specified page, at the specified x and y
// coordinates. Position 0, 0 is at the top left of the page.
// The zoom of the destination page is controlled with the zoom
// parameter. Pass in 0 to keep the current zoom value.
func (p *StyledParagraph) AddInternalLink(text string, page int64, x, y, zoom float64) *TextChunk {
chunk := NewTextChunk(text, p.defaultLinkStyle)
chunk.annotation = newInternalLinkAnnotation(page-1, x, y, zoom)
return p.appendChunk(chunk)
}
// Reset removes all the text chunks the paragraph contains.
func (p *StyledParagraph) Reset() {
p.chunks = []*TextChunk{}
}
// SetText replaces all the text of the paragraph with the specified one.
func (p *StyledParagraph) SetText(text string) *TextChunk {
p.Reset()
return p.Append(text)
}
// SetTextAlignment sets the horizontal alignment of the text within the space provided.
func (p *StyledParagraph) SetTextAlignment(align TextAlignment) {
p.alignment = align
}
// SetLineHeight sets the line height (1.0 default).
func (p *StyledParagraph) SetLineHeight(lineheight float64) {
p.lineHeight = lineheight
}
// SetEnableWrap sets the line wrapping enabled flag.
func (p *StyledParagraph) SetEnableWrap(enableWrap bool) {
p.enableWrap = enableWrap
p.defaultWrap = false
}
// SetPos sets absolute positioning with specified coordinates.
func (p *StyledParagraph) SetPos(x, y float64) {
p.positioning = positionAbsolute
p.xPos = x
p.yPos = y
}
// SetAngle sets the rotation angle of the text.
func (p *StyledParagraph) SetAngle(angle float64) {
p.angle = angle
}
// SetMargins sets the Paragraph's margins.
func (p *StyledParagraph) SetMargins(left, right, top, bottom float64) {
p.margins.left = left
p.margins.right = right
p.margins.top = top
p.margins.bottom = bottom
}
// GetMargins returns the Paragraph's margins: left, right, top, bottom.
func (p *StyledParagraph) GetMargins() (float64, float64, float64, float64) {
return p.margins.left, p.margins.right, p.margins.top, p.margins.bottom
}
// SetWidth sets the the Paragraph width. This is essentially the wrapping width,
// i.e. the width the text can extend to prior to wrapping over to next line.
func (p *StyledParagraph) SetWidth(width float64) {
p.wrapWidth = width
p.wrapText()
}
// Width returns the width of the Paragraph.
func (p *StyledParagraph) Width() float64 {
if p.enableWrap && int(p.wrapWidth) > 0 {
return p.wrapWidth
}
return p.getTextWidth() / 1000.0
}
// Height returns the height of the Paragraph. The height is calculated based on the input text and how it is wrapped
// within the container. Does not include Margins.
func (p *StyledParagraph) Height() float64 {
if p.lines == nil || len(p.lines) == 0 {
p.wrapText()
}
var height float64
for _, line := range p.lines {
var lineHeight float64
for _, chunk := range line {
h := p.lineHeight * chunk.Style.FontSize
if h > lineHeight {
lineHeight = h
}
}
height += lineHeight
}
return height
}
// getLineHeight returns both the capheight and the font size based height of
// the line with the specified index.
func (p *StyledParagraph) getLineHeight(idx int) (capHeight, height float64) {
if p.lines == nil || len(p.lines) == 0 {
p.wrapText()
}
if idx < 0 || idx > len(p.lines)-1 {
common.Log.Debug("ERROR: invalid paragraph line index %d. Returning 0, 0", idx)
return 0, 0
}
line := p.lines[idx]
for _, chunk := range line {
descriptor, err := chunk.Style.Font.GetFontDescriptor()
if err != nil {
common.Log.Debug("ERROR: Unable to get font descriptor")
}
var fontCapHeight float64
if descriptor != nil {
if fontCapHeight, err = descriptor.GetCapHeight(); err != nil {
common.Log.Debug("ERROR: Unable to get font CapHeight: %v", err)
}
}
if int(fontCapHeight) <= 0 {
common.Log.Debug("WARN: CapHeight not available - setting to 1000")
fontCapHeight = 1000
}
h := fontCapHeight / 1000.0 * chunk.Style.FontSize * p.lineHeight
if h > capHeight {
capHeight = h
}
h = p.lineHeight * chunk.Style.FontSize
if h > height {
height = h
}
}
return capHeight, height
}
// getTextWidth calculates the text width as if all in one line (not taking
// wrapping into account).
func (p *StyledParagraph) getTextWidth() float64 {
var width float64
lenChunks := len(p.chunks)
for i, chunk := range p.chunks {
style := &chunk.Style
lenRunes := len(chunk.Text)
for j, r := range chunk.Text {
// Ignore newline for this. Handles as if all in one line.
if r == '\u000A' { // LF
continue
}
metrics, found := style.Font.GetRuneMetrics(r)
if !found {
common.Log.Debug("Rune char metrics not found! %v\n", r)
// FIXME: return error.
return -1
}
width += style.FontSize * metrics.Wx
// Do not add character spacing for the last character of the line.
if i != lenChunks-1 || j != lenRunes-1 {
width += style.CharSpacing * 1000.0
}
}
}
return width
}
// getTextLineWidth calculates the text width of a provided collection of text chunks.
func (p *StyledParagraph) getTextLineWidth(line []*TextChunk) float64 {
var width float64
lenChunks := len(line)
for i, chunk := range line {
style := &chunk.Style
lenRunes := len(chunk.Text)
for j, r := range chunk.Text {
// Ignore newline for this. Handles as if all in one line.
if r == '\u000A' { // LF
continue
}
metrics, found := style.Font.GetRuneMetrics(r)
if !found {
common.Log.Debug("Rune char metrics not found! %v\n", r)
// FIXME: return error.
return -1
}
width += style.FontSize * metrics.Wx
// Do not add character spacing for the last character of the line.
if i != lenChunks-1 || j != lenRunes-1 {
width += style.CharSpacing * 1000.0
}
}
}
return width
}
// getMaxLineWidth returns the width of the longest line of text in the paragraph.
func (p *StyledParagraph) getMaxLineWidth() float64 {
if p.lines == nil || len(p.lines) == 0 {
p.wrapText()
}
var width float64
for _, line := range p.lines {
w := p.getTextLineWidth(line)
if w > width {
width = w
}
}
return width
}
// getTextHeight calculates the text height as if all in one line (not taking wrapping into account).
func (p *StyledParagraph) getTextHeight() float64 {
var height float64
for _, chunk := range p.chunks {
h := chunk.Style.FontSize * p.lineHeight
if h > height {
height = h
}
}
return height
}
// wrapText splits text into lines. It uses a simple greedy algorithm to wrap
// fill the lines.
// TODO: Consider the Knuth/Plass algorithm or an alternative.
func (p *StyledParagraph) wrapText() error {
if !p.enableWrap || int(p.wrapWidth) <= 0 {
p.lines = [][]*TextChunk{p.chunks}
return nil
}
p.lines = [][]*TextChunk{}
var line []*TextChunk
var lineWidth float64
copyAnnotation := func(src *model.PdfAnnotation) *model.PdfAnnotation {
if src == nil {
return nil
}
var annotation *model.PdfAnnotation
switch t := src.GetContext().(type) {
case *model.PdfAnnotationLink:
if annot := copyLinkAnnotation(t); annot != nil {
annotation = annot.PdfAnnotation
}
}
return annotation
}
for _, chunk := range p.chunks {
style := chunk.Style
annotation := chunk.annotation
var (
part []rune
widths []float64
)
for _, r := range chunk.Text {
// newline wrapping.
if r == '\u000A' { // LF
// moves to next line.
line = append(line, &TextChunk{
Text: strings.TrimRightFunc(string(part), unicode.IsSpace),
Style: style,
annotation: copyAnnotation(annotation),
})
p.lines = append(p.lines, line)
line = nil
lineWidth = 0
part = nil
widths = nil
continue
}
metrics, found := style.Font.GetRuneMetrics(r)
if !found {
common.Log.Debug("Rune char metrics not found! %v\n", r)
return errors.New("glyph char metrics missing")
}
w := style.FontSize * metrics.Wx
charWidth := w + style.CharSpacing*1000.0
if lineWidth+w > p.wrapWidth*1000.0 {
// Goes out of bounds: Wrap.
// Breaks on the character.
// TODO: when goes outside: back up to next space,
// otherwise break on the character.
idx := -1
for j := len(part) - 1; j >= 0; j-- {
if part[j] == ' ' {
idx = j
break
}
}
text := string(part)
if idx >= 0 {
text = string(part[0 : idx+1])
part = part[idx+1:]
part = append(part, r)
widths = widths[idx+1:]
widths = append(widths, charWidth)
lineWidth = 0
for _, width := range widths {
lineWidth += width
}
} else {
lineWidth = charWidth
part = []rune{r}
widths = []float64{charWidth}
}
line = append(line, &TextChunk{
Text: strings.TrimRightFunc(string(text), unicode.IsSpace),
Style: style,
annotation: copyAnnotation(annotation),
})
p.lines = append(p.lines, line)
line = []*TextChunk{}
} else {
lineWidth += charWidth
part = append(part, r)
widths = append(widths, charWidth)
}
}
if len(part) > 0 {
line = append(line, &TextChunk{
Text: string(part),
Style: style,
annotation: copyAnnotation(annotation),
})
}
}
if len(line) > 0 {
p.lines = append(p.lines, line)
}
return nil
}
// GeneratePageBlocks generates the page blocks. Multiple blocks are generated
// if the contents wrap over multiple pages. Implements the Drawable interface.
func (p *StyledParagraph) GeneratePageBlocks(ctx DrawContext) ([]*Block, DrawContext, error) {
origContext := ctx
var blocks []*Block
blk := NewBlock(ctx.PageWidth, ctx.PageHeight)
if p.positioning.isRelative() {
// Account for Paragraph Margins.
ctx.X += p.margins.left
ctx.Y += p.margins.top
ctx.Width -= p.margins.left + p.margins.right
ctx.Height -= p.margins.top + p.margins.bottom
// Use available space.
p.SetWidth(ctx.Width)
if p.Height() > ctx.Height {
// Goes out of the bounds. Write on a new template instead and create a new context at upper
// left corner.
// TODO: Handle case when Paragraph is larger than the Page...
// Should be fine if we just break on the paragraph, i.e. splitting it up over 2+ pages
blocks = append(blocks, blk)
blk = NewBlock(ctx.PageWidth, ctx.PageHeight)
// New Page.
ctx.Page++
newContext := ctx
newContext.Y = ctx.Margins.top // + p.Margins.top
newContext.X = ctx.Margins.left + p.margins.left
newContext.Height = ctx.PageHeight - ctx.Margins.top - ctx.Margins.bottom - p.margins.bottom
newContext.Width = ctx.PageWidth - ctx.Margins.left - ctx.Margins.right - p.margins.left - p.margins.right
ctx = newContext
}
} else {
// Absolute.
if int(p.wrapWidth) <= 0 {
// Use necessary space.
p.SetWidth(p.getTextWidth())
}
ctx.X = p.xPos
ctx.Y = p.yPos
}
if p.beforeRender != nil {
p.beforeRender(p, ctx)
}
// Place the Paragraph on the template at position (x,y) based on the ctx.
ctx, err := drawStyledParagraphOnBlock(blk, p, ctx)
if err != nil {
common.Log.Debug("ERROR: %v", err)
return nil, ctx, err
}
blocks = append(blocks, blk)
if p.positioning.isRelative() {
ctx.X -= p.margins.left // Move back.
ctx.Width = origContext.Width
return blocks, ctx, nil
}
// Absolute: not changing the context.
return blocks, origContext, nil
}
// Draw block on specified location on Page, adding to the content stream.
func drawStyledParagraphOnBlock(blk *Block, p *StyledParagraph, ctx DrawContext) (DrawContext, error) {
// Find first free index for the font resources of the paragraph.
num := 1
fontName := core.PdfObjectName(fmt.Sprintf("Font%d", num))
for blk.resources.HasFontByName(fontName) {
num++
fontName = core.PdfObjectName(fmt.Sprintf("Font%d", num))
}
// Add default font to the page resources.
err := blk.resources.SetFontByName(fontName, p.defaultStyle.Font.ToPdfObject())
if err != nil {
return ctx, err
}
num++
defaultFontName := fontName
defaultFontSize := p.defaultStyle.FontSize
// Wrap the text into lines.
p.wrapText()
// Add the fonts of all chunks to the page resources.
var fonts [][]core.PdfObjectName
for _, line := range p.lines {
var fontLine []core.PdfObjectName
for _, chunk := range line {
fontName = core.PdfObjectName(fmt.Sprintf("Font%d", num))
err := blk.resources.SetFontByName(fontName, chunk.Style.Font.ToPdfObject())
if err != nil {
return ctx, err
}
fontLine = append(fontLine, fontName)
num++
}
fonts = append(fonts, fontLine)
}
// Create the content stream.
cc := contentstream.NewContentCreator()
cc.Add_q()
yPos := ctx.PageHeight - ctx.Y - defaultFontSize*p.lineHeight
cc.Translate(ctx.X, yPos)
if p.angle != 0 {
cc.RotateDeg(p.angle)
}
cc.Add_BT()
currY := yPos
for idx, line := range p.lines {
currX := ctx.X
if idx != 0 {
// Move to next line if not first.
cc.Add_Tstar()
}
isLastLine := idx == len(p.lines)-1
// Get width of the line (excluding spaces).
var (
width float64
height float64
spaceWidth float64
spaces uint
)
var chunkWidths []float64
for _, chunk := range line {
style := &chunk.Style
if style.FontSize > height {
height = style.FontSize
}
spaceMetrics, found := style.Font.GetRuneMetrics(' ')
if !found {
return ctx, errors.New("the font does not have a space glyph")
}
var chunkSpaces uint
var chunkWidth float64
lenChunk := len(chunk.Text)
for i, r := range chunk.Text {
if r == ' ' {
chunkSpaces++
continue
}
if r == '\u000A' { // LF
continue
}
metrics, found := style.Font.GetRuneMetrics(r)
if !found {
common.Log.Debug("Unsupported rune %v in font\n", r)
return ctx, errors.New("unsupported text glyph")
}
chunkWidth += style.FontSize * metrics.Wx
// Do not add character spacing for the last character of the line.
if i != lenChunk-1 {
chunkWidth += style.CharSpacing * 1000.0
}
}
chunkWidths = append(chunkWidths, chunkWidth)
width += chunkWidth
spaceWidth += float64(chunkSpaces) * spaceMetrics.Wx * style.FontSize
spaces += chunkSpaces
}
height *= p.lineHeight
// Add line shifts.
var objs []core.PdfObject
wrapWidth := p.wrapWidth * 1000.0
if p.alignment == TextAlignmentJustify {
// Do not justify last line.
if spaces > 0 && !isLastLine {
spaceWidth = (wrapWidth - width) / float64(spaces) / defaultFontSize
}
} else if p.alignment == TextAlignmentCenter {
// Start with an offset of half of the remaining line space.
offset := (wrapWidth - width - spaceWidth) / 2
shift := offset / defaultFontSize
objs = append(objs, core.MakeFloat(-shift))
currX += offset / 1000.0
} else if p.alignment == TextAlignmentRight {
// Push the text at the end of the line.
offset := (wrapWidth - width - spaceWidth)
shift := offset / defaultFontSize
objs = append(objs, core.MakeFloat(-shift))
currX += offset / 1000.0
}
if len(objs) > 0 {
cc.Add_Tf(defaultFontName, defaultFontSize).
Add_TL(defaultFontSize * p.lineHeight).
Add_TJ(objs...)
}
// Render line text chunks.
for k, chunk := range line {
style := &chunk.Style
r, g, b := style.Color.ToRGB()
fontName := defaultFontName
fontSize := defaultFontSize
// Set chunk rendering mode.
cc.Add_Tr(int64(style.RenderingMode))
// Set chunk character spacing.
cc.Add_Tc(style.CharSpacing)
if p.alignment != TextAlignmentJustify || isLastLine {
spaceMetrics, found := style.Font.GetRuneMetrics(' ')
if !found {
return ctx, errors.New("the font does not have a space glyph")
}
fontName = fonts[idx][k]
fontSize = style.FontSize
spaceWidth = spaceMetrics.Wx
}
enc := style.Font.Encoder()
var encStr []byte
for _, rn := range chunk.Text {
if r == '\u000A' { // LF
continue
}
if rn == ' ' {
if len(encStr) > 0 {
cc.Add_rg(r, g, b).
Add_Tf(fonts[idx][k], style.FontSize).
Add_TL(style.FontSize * p.lineHeight).
Add_TJ([]core.PdfObject{core.MakeStringFromBytes(encStr)}...)
encStr = nil
}
cc.Add_Tf(fontName, fontSize).
Add_TL(fontSize * p.lineHeight).
Add_TJ([]core.PdfObject{core.MakeFloat(-spaceWidth)}...)
chunkWidths[k] += spaceWidth * fontSize
} else {
if _, ok := enc.RuneToCharcode(rn); !ok {
common.Log.Debug("unsupported rune in text encoding: %#x (%c)", rn, rn)
continue
}
encStr = append(encStr, enc.Encode(string(rn))...)
}
}
if len(encStr) > 0 {
cc.Add_rg(r, g, b).
Add_Tf(fonts[idx][k], style.FontSize).
Add_TL(style.FontSize * p.lineHeight).
Add_TJ([]core.PdfObject{core.MakeStringFromBytes(encStr)}...)
}
chunkWidth := chunkWidths[k] / 1000.0
// Add annotations.
if chunk.annotation != nil {
var annotRect *core.PdfObjectArray
// Process annotation.
if !chunk.annotationProcessed {
switch t := chunk.annotation.GetContext().(type) {
case *model.PdfAnnotationLink:
// Initialize annotation rectangle.
annotRect = core.MakeArray()
t.Rect = annotRect
// Reverse the Y axis of the destination coordinates.
// The user passes in the annotation coordinates as if
// position 0, 0 is at the top left of the page.
// However, position 0, 0 in the PDF is at the bottom
// left of the page.
annotDest, ok := t.Dest.(*core.PdfObjectArray)
if ok && annotDest.Len() == 5 {
t, ok := annotDest.Get(1).(*core.PdfObjectName)
if ok && t.String() == "XYZ" {
y, err := core.GetNumberAsFloat(annotDest.Get(3))
if err == nil {
annotDest.Set(3, core.MakeFloat(ctx.PageHeight-y))
}
}
}
}
chunk.annotationProcessed = true
}
// Set the coordinates of the annotation.
if annotRect != nil {
// Calculate rotated annotation position.
annotPos := draw.NewPoint(currX-ctx.X, currY-yPos).Rotate(p.angle)
annotPos.X += ctx.X
annotPos.Y += yPos
// Calculate rotated annotation bounding box.
offX, offY, annotW, annotH := rotateRect(chunkWidth, height, p.angle)
annotPos.X += offX
annotPos.Y += offY
annotRect.Clear()
annotRect.Append(core.MakeFloat(annotPos.X))
annotRect.Append(core.MakeFloat(annotPos.Y))
annotRect.Append(core.MakeFloat(annotPos.X + annotW))
annotRect.Append(core.MakeFloat(annotPos.Y + annotH))
}
blk.AddAnnotation(chunk.annotation)
}
currX += chunkWidth
// Reset rendering mode.
cc.Add_Tr(int64(TextRenderingModeFill))
// Reset character spacing.
cc.Add_Tc(0)
}
currY -= height
}
cc.Add_ET()
cc.Add_Q()
ops := cc.Operations()
ops.WrapIfNeeded()
blk.addContents(ops)
if p.positioning.isRelative() {
pHeight := p.Height() + p.margins.bottom
ctx.Y += pHeight
ctx.Height -= pHeight
// If the division is inline, calculate context new X coordinate.
if ctx.Inline {
ctx.X += p.Width() + p.margins.right
}
}
return ctx, nil
}