unipdf/creator/text_chunk.go
Adrian-George Bostan 5a21f16e83
Paragraph components fixes (#294)
* Take spaces into account when wrapping styled paragraph text
* Fix starting position of styled paragraphs
* Wrap text unconditionally when style paragraph height is requested
* Fix styled paragraph text wrapping edge case
* Fix text chunk wrapping edge case
* Adapt text chunk test
* Wrap text unconditionally when paragraph height is requested
* Add text wrapping test case for styled paragraph using character spacing
2020-04-06 23:58:22 +00:00

222 lines
5.4 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"
"strings"
"unicode"
"github.com/unidoc/unipdf/v3/common"
"github.com/unidoc/unipdf/v3/core"
"github.com/unidoc/unipdf/v3/model"
)
// TextChunk represents a chunk of text along with a particular style.
type TextChunk struct {
// The text that is being rendered in the PDF.
Text string
// The style of the text being rendered.
Style TextStyle
// Text chunk annotation.
annotation *model.PdfAnnotation
// Internally used in order to skip processing the annotation
// if it has already been processed by the parent component.
annotationProcessed bool
}
// NewTextChunk returns a new text chunk instance.
func NewTextChunk(text string, style TextStyle) *TextChunk {
return &TextChunk{
Text: text,
Style: style,
}
}
// SetAnnotation sets a annotation on a TextChunk.
func (tc *TextChunk) SetAnnotation(annotation *model.PdfAnnotation) {
tc.annotation = annotation
}
// Wrap wraps the text of the chunk into lines based on its style and the
// specified width.
func (tc *TextChunk) Wrap(width float64) ([]string, error) {
if int(width) <= 0 {
return []string{tc.Text}, nil
}
var lines []string
var line []rune
var lineWidth float64
var widths []float64
style := tc.Style
runes := []rune(tc.Text)
for _, r := range runes {
// Move to the next line due to newline wrapping (LF).
if r == '\u000A' {
lines = append(lines, strings.TrimRightFunc(string(line), unicode.IsSpace)+string(r))
line = nil
lineWidth = 0
widths = nil
continue
}
isSpace := r == ' '
metrics, found := style.Font.GetRuneMetrics(r)
if !found {
common.Log.Debug("ERROR: Rune char metrics not found! rune=0x%04x=%c font=%s %#q",
r, r, style.Font.BaseFont(), style.Font.Subtype())
common.Log.Trace("Font: %#v", style.Font)
common.Log.Trace("Encoder: %#v", style.Font.Encoder())
return nil, errors.New("glyph char metrics missing")
}
w := style.FontSize * metrics.Wx
charWidth := w
if !isSpace {
charWidth = w + style.CharSpacing*1000.0
}
if lineWidth+w > width*1000.0 {
// Goes out of bounds. Break on the character.
idx := -1
if !isSpace {
for i := len(line) - 1; i >= 0; i-- {
if line[i] == ' ' {
idx = i
break
}
}
}
text := string(line)
if idx > 0 {
// Back up to last space.
text = string(line[0 : idx+1])
// Remainder of line.
line = append(line[idx+1:], r)
widths = append(widths[idx+1:], charWidth)
lineWidth = 0
for _, width := range widths {
lineWidth += width
}
} else {
if isSpace {
line = []rune{}
widths = []float64{}
lineWidth = 0
} else {
line = []rune{r}
widths = []float64{charWidth}
lineWidth = charWidth
}
}
lines = append(lines, strings.TrimRightFunc(text, unicode.IsSpace))
} else {
line = append(line, r)
lineWidth += charWidth
widths = append(widths, charWidth)
}
}
if len(line) > 0 {
lines = append(lines, string(line))
}
return lines, nil
}
// Fit fits the chunk into the specified bounding box, cropping off the
// remainder in a new chunk, if it exceeds the specified dimensions.
// NOTE: The method assumes a line height of 1.0. In order to account for other
// line height values, the passed in height must be divided by the line height:
// height = height / lineHeight
func (tc *TextChunk) Fit(width, height float64) (*TextChunk, error) {
lines, err := tc.Wrap(width)
if err != nil {
return nil, err
}
fit := int(height / tc.Style.FontSize)
if fit >= len(lines) {
return nil, nil
}
lf := "\u000A"
tc.Text = strings.Replace(strings.Join(lines[:fit], " "), lf+" ", lf, -1)
remainder := strings.Replace(strings.Join(lines[fit:], " "), lf+" ", lf, -1)
return NewTextChunk(remainder, tc.Style), nil
}
// newExternalLinkAnnotation returns a new external link annotation.
func newExternalLinkAnnotation(url string) *model.PdfAnnotation {
annotation := model.NewPdfAnnotationLink()
// Set border style.
bs := model.NewBorderStyle()
bs.SetBorderWidth(0)
annotation.BS = bs.ToPdfObject()
// Set link destination.
action := model.NewPdfActionURI()
action.URI = core.MakeString(url)
annotation.SetAction(action.PdfAction)
return annotation.PdfAnnotation
}
// newExternalLinkAnnotation returns a new internal link annotation.
func newInternalLinkAnnotation(page int64, x, y, zoom float64) *model.PdfAnnotation {
annotation := model.NewPdfAnnotationLink()
// Set border style.
bs := model.NewBorderStyle()
bs.SetBorderWidth(0)
annotation.BS = bs.ToPdfObject()
// Set link destination.
if page < 0 {
page = 0
}
annotation.Dest = core.MakeArray(
core.MakeInteger(page),
core.MakeName("XYZ"),
core.MakeFloat(x),
core.MakeFloat(y),
core.MakeFloat(zoom),
)
return annotation.PdfAnnotation
}
// copyLinkAnnotation returns a new link annotation based on an existing one.
func copyLinkAnnotation(link *model.PdfAnnotationLink) *model.PdfAnnotationLink {
if link == nil {
return nil
}
annotation := model.NewPdfAnnotationLink()
annotation.BS = link.BS
annotation.A = link.A
if action, err := link.GetAction(); err == nil && action != nil {
annotation.SetAction(action)
}
if annotDest, ok := link.Dest.(*core.PdfObjectArray); ok {
annotation.Dest = core.MakeArray(annotDest.Elements()...)
}
return annotation
}