github.com/unidoc/unidoc@v2.2.0+incompatible/pdf/creator/styled_paragraph.go (about)

     1  /*
     2   * This file is subject to the terms and conditions defined in
     3   * file 'LICENSE.md', which is part of this source code package.
     4   */
     5  
     6  package creator
     7  
     8  import (
     9  	"errors"
    10  	"fmt"
    11  	"strings"
    12  	"unicode"
    13  
    14  	"github.com/unidoc/unidoc/common"
    15  	"github.com/unidoc/unidoc/pdf/contentstream"
    16  	"github.com/unidoc/unidoc/pdf/core"
    17  	"github.com/unidoc/unidoc/pdf/model/textencoding"
    18  )
    19  
    20  // StyledParagraph represents text drawn with a specified font and can wrap across lines and pages.
    21  // By default occupies the available width in the drawing context.
    22  type StyledParagraph struct {
    23  	// Text chunks with styles that compose the paragraph
    24  	chunks []TextChunk
    25  
    26  	// Style used for the paragraph for spacing and offsets
    27  	defaultStyle TextStyle
    28  
    29  	// The text encoder which can convert the text (as runes) into a series of glyphs and get character metrics.
    30  	encoder textencoding.TextEncoder
    31  
    32  	// Text alignment: Align left/right/center/justify.
    33  	alignment TextAlignment
    34  
    35  	// The line relative height (default 1).
    36  	lineHeight float64
    37  
    38  	// Wrapping properties.
    39  	enableWrap bool
    40  	wrapWidth  float64
    41  
    42  	// defaultWrap defines whether wrapping has been defined explictly or whether default behavior should
    43  	// be observed. Default behavior depends on context: normally wrap is expected, except for example in
    44  	// table cells wrapping is off by default.
    45  	defaultWrap bool
    46  
    47  	// Rotation angle (degrees).
    48  	angle float64
    49  
    50  	// Margins to be applied around the block when drawing on Page.
    51  	margins margins
    52  
    53  	// Positioning: relative / absolute.
    54  	positioning positioning
    55  
    56  	// Absolute coordinates (when in absolute mode).
    57  	xPos float64
    58  	yPos float64
    59  
    60  	// Scaling factors (1 default).
    61  	scaleX float64
    62  	scaleY float64
    63  
    64  	// Text chunk lines after wrapping to available width.
    65  	lines [][]TextChunk
    66  }
    67  
    68  // NewStyledParagraph creates a new styled paragraph.
    69  // Uses default parameters: Helvetica, WinAnsiEncoding and wrap enabled
    70  // with a wrap width of 100 points.
    71  func NewStyledParagraph(text string, style TextStyle) *StyledParagraph {
    72  	// TODO: Can we wrap intellectually, only if given width is known?
    73  	p := &StyledParagraph{
    74  		chunks: []TextChunk{
    75  			TextChunk{
    76  				Text:  text,
    77  				Style: style,
    78  			},
    79  		},
    80  		defaultStyle: NewTextStyle(),
    81  		lineHeight:   1.0,
    82  		alignment:    TextAlignmentLeft,
    83  		enableWrap:   true,
    84  		defaultWrap:  true,
    85  		angle:        0,
    86  		scaleX:       1,
    87  		scaleY:       1,
    88  		positioning:  positionRelative,
    89  	}
    90  
    91  	p.SetEncoder(textencoding.NewWinAnsiTextEncoder())
    92  	return p
    93  }
    94  
    95  // Append adds a new text chunk with a specified style to the paragraph.
    96  func (p *StyledParagraph) Append(text string, style TextStyle) {
    97  	chunk := TextChunk{
    98  		Text:  text,
    99  		Style: style,
   100  	}
   101  	chunk.Style.Font.SetEncoder(p.encoder)
   102  
   103  	p.chunks = append(p.chunks, chunk)
   104  	p.wrapText()
   105  }
   106  
   107  // Reset sets the entire text and also the style of the paragraph
   108  // to those specified. It behaves as if the paragraph was a new one.
   109  func (p *StyledParagraph) Reset(text string, style TextStyle) {
   110  	p.chunks = []TextChunk{}
   111  	p.Append(text, style)
   112  }
   113  
   114  // SetTextAlignment sets the horizontal alignment of the text within the space provided.
   115  func (p *StyledParagraph) SetTextAlignment(align TextAlignment) {
   116  	p.alignment = align
   117  }
   118  
   119  // SetEncoder sets the text encoding.
   120  func (p *StyledParagraph) SetEncoder(encoder textencoding.TextEncoder) {
   121  	p.encoder = encoder
   122  	p.defaultStyle.Font.SetEncoder(encoder)
   123  
   124  	// Sync with the text font too.
   125  	// XXX/FIXME: Keep in 1 place only.
   126  	for _, chunk := range p.chunks {
   127  		chunk.Style.Font.SetEncoder(encoder)
   128  	}
   129  }
   130  
   131  // SetLineHeight sets the line height (1.0 default).
   132  func (p *StyledParagraph) SetLineHeight(lineheight float64) {
   133  	p.lineHeight = lineheight
   134  }
   135  
   136  // SetEnableWrap sets the line wrapping enabled flag.
   137  func (p *StyledParagraph) SetEnableWrap(enableWrap bool) {
   138  	p.enableWrap = enableWrap
   139  	p.defaultWrap = false
   140  }
   141  
   142  // SetPos sets absolute positioning with specified coordinates.
   143  func (p *StyledParagraph) SetPos(x, y float64) {
   144  	p.positioning = positionAbsolute
   145  	p.xPos = x
   146  	p.yPos = y
   147  }
   148  
   149  // SetAngle sets the rotation angle of the text.
   150  func (p *StyledParagraph) SetAngle(angle float64) {
   151  	p.angle = angle
   152  }
   153  
   154  // SetMargins sets the Paragraph's margins.
   155  func (p *StyledParagraph) SetMargins(left, right, top, bottom float64) {
   156  	p.margins.left = left
   157  	p.margins.right = right
   158  	p.margins.top = top
   159  	p.margins.bottom = bottom
   160  }
   161  
   162  // GetMargins returns the Paragraph's margins: left, right, top, bottom.
   163  func (p *StyledParagraph) GetMargins() (float64, float64, float64, float64) {
   164  	return p.margins.left, p.margins.right, p.margins.top, p.margins.bottom
   165  }
   166  
   167  // SetWidth sets the the Paragraph width. This is essentially the wrapping width,
   168  // i.e. the width the text can extend to prior to wrapping over to next line.
   169  func (p *StyledParagraph) SetWidth(width float64) {
   170  	p.wrapWidth = width
   171  	p.wrapText()
   172  }
   173  
   174  // Width returns the width of the Paragraph.
   175  func (p *StyledParagraph) Width() float64 {
   176  	if p.enableWrap {
   177  		return p.wrapWidth
   178  	}
   179  
   180  	return p.getTextWidth() / 1000.0
   181  }
   182  
   183  // Height returns the height of the Paragraph. The height is calculated based on the input text and how it is wrapped
   184  // within the container. Does not include Margins.
   185  func (p *StyledParagraph) Height() float64 {
   186  	if p.lines == nil || len(p.lines) == 0 {
   187  		p.wrapText()
   188  	}
   189  
   190  	var height float64
   191  	for _, line := range p.lines {
   192  		var lineHeight float64
   193  		for _, chunk := range line {
   194  			h := p.lineHeight * chunk.Style.FontSize
   195  			if h > lineHeight {
   196  				lineHeight = h
   197  			}
   198  		}
   199  
   200  		height += lineHeight
   201  	}
   202  
   203  	return height
   204  }
   205  
   206  // getTextWidth calculates the text width as if all in one line (not taking wrapping into account).
   207  func (p *StyledParagraph) getTextWidth() float64 {
   208  	var width float64
   209  	for _, chunk := range p.chunks {
   210  		style := &chunk.Style
   211  
   212  		for _, rune := range chunk.Text {
   213  			glyph, found := p.encoder.RuneToGlyph(rune)
   214  			if !found {
   215  				common.Log.Debug("Error! Glyph not found for rune: %s\n", rune)
   216  
   217  				// XXX/FIXME: return error.
   218  				return -1
   219  			}
   220  
   221  			// Ignore newline for this.. Handles as if all in one line.
   222  			if glyph == "controlLF" {
   223  				continue
   224  			}
   225  
   226  			metrics, found := style.Font.GetGlyphCharMetrics(glyph)
   227  			if !found {
   228  				common.Log.Debug("Glyph char metrics not found! %s\n", glyph)
   229  
   230  				// XXX/FIXME: return error.
   231  				return -1
   232  			}
   233  
   234  			width += style.FontSize * metrics.Wx
   235  		}
   236  	}
   237  
   238  	return width
   239  }
   240  
   241  // getTextHeight calculates the text height as if all in one line (not taking wrapping into account).
   242  func (p *StyledParagraph) getTextHeight() float64 {
   243  	var height float64
   244  	for _, chunk := range p.chunks {
   245  		h := chunk.Style.FontSize * p.lineHeight
   246  		if h > height {
   247  			height = h
   248  		}
   249  	}
   250  
   251  	return height
   252  }
   253  
   254  // wrapText splits text into lines. It uses a simple greedy algorithm to wrap
   255  // fill the lines.
   256  // XXX/TODO: Consider the Knuth/Plass algorithm or an alternative.
   257  func (p *StyledParagraph) wrapText() error {
   258  	if !p.enableWrap {
   259  		p.lines = [][]TextChunk{p.chunks}
   260  		return nil
   261  	}
   262  
   263  	p.lines = [][]TextChunk{}
   264  	var line []TextChunk
   265  	var lineWidth float64
   266  
   267  	for _, chunk := range p.chunks {
   268  		style := chunk.Style
   269  
   270  		var part []rune
   271  		var glyphs []string
   272  		var widths []float64
   273  
   274  		for _, r := range chunk.Text {
   275  			glyph, found := p.encoder.RuneToGlyph(r)
   276  			if !found {
   277  				common.Log.Debug("Error! Glyph not found for rune: %v\n", r)
   278  
   279  				// XXX/FIXME: return error.
   280  				return errors.New("Glyph not found for rune")
   281  			}
   282  
   283  			// newline wrapping.
   284  			if glyph == "controlLF" {
   285  				// moves to next line.
   286  				line = append(line, TextChunk{
   287  					Text:  strings.TrimRightFunc(string(part), unicode.IsSpace),
   288  					Style: style,
   289  				})
   290  				p.lines = append(p.lines, line)
   291  				line = []TextChunk{}
   292  
   293  				lineWidth = 0
   294  				part = []rune{}
   295  				widths = []float64{}
   296  				glyphs = []string{}
   297  				continue
   298  			}
   299  
   300  			metrics, found := style.Font.GetGlyphCharMetrics(glyph)
   301  			if !found {
   302  				common.Log.Debug("Glyph char metrics not found! %s\n", glyph)
   303  
   304  				// XXX/FIXME: return error.
   305  				return errors.New("Glyph char metrics missing")
   306  			}
   307  
   308  			w := style.FontSize * metrics.Wx
   309  			if lineWidth+w > p.wrapWidth*1000.0 {
   310  				// Goes out of bounds: Wrap.
   311  				// Breaks on the character.
   312  				// XXX/TODO: when goes outside: back up to next space,
   313  				// otherwise break on the character.
   314  				idx := -1
   315  				for j := len(glyphs) - 1; j >= 0; j-- {
   316  					if glyphs[j] == "space" {
   317  						idx = j
   318  						break
   319  					}
   320  				}
   321  
   322  				text := string(part)
   323  				if idx >= 0 {
   324  					text = string(part[0 : idx+1])
   325  
   326  					part = part[idx+1:]
   327  					part = append(part, r)
   328  					glyphs = glyphs[idx+1:]
   329  					glyphs = append(glyphs, glyph)
   330  					widths = widths[idx+1:]
   331  					widths = append(widths, w)
   332  
   333  					lineWidth = 0
   334  					for _, width := range widths {
   335  						lineWidth += width
   336  					}
   337  				} else {
   338  					lineWidth = w
   339  					part = []rune{r}
   340  					glyphs = []string{glyph}
   341  					widths = []float64{w}
   342  				}
   343  
   344  				line = append(line, TextChunk{
   345  					Text:  strings.TrimRightFunc(string(text), unicode.IsSpace),
   346  					Style: style,
   347  				})
   348  				p.lines = append(p.lines, line)
   349  				line = []TextChunk{}
   350  			} else {
   351  				lineWidth += w
   352  				part = append(part, r)
   353  				glyphs = append(glyphs, glyph)
   354  				widths = append(widths, w)
   355  			}
   356  		}
   357  
   358  		if len(part) > 0 {
   359  			line = append(line, TextChunk{
   360  				Text:  string(part),
   361  				Style: style,
   362  			})
   363  		}
   364  	}
   365  
   366  	if len(line) > 0 {
   367  		p.lines = append(p.lines, line)
   368  	}
   369  
   370  	return nil
   371  }
   372  
   373  // GeneratePageBlocks generates the page blocks.  Multiple blocks are generated
   374  // if the contents wrap over multiple pages. Implements the Drawable interface.
   375  func (p *StyledParagraph) GeneratePageBlocks(ctx DrawContext) ([]*Block, DrawContext, error) {
   376  	origContext := ctx
   377  	blocks := []*Block{}
   378  
   379  	blk := NewBlock(ctx.PageWidth, ctx.PageHeight)
   380  	if p.positioning.isRelative() {
   381  		// Account for Paragraph Margins.
   382  		ctx.X += p.margins.left
   383  		ctx.Y += p.margins.top
   384  		ctx.Width -= p.margins.left + p.margins.right
   385  		ctx.Height -= p.margins.top + p.margins.bottom
   386  
   387  		// Use available space.
   388  		p.SetWidth(ctx.Width)
   389  
   390  		if p.Height() > ctx.Height {
   391  			// Goes out of the bounds.  Write on a new template instead and create a new context at upper
   392  			// left corner.
   393  			// XXX/TODO: Handle case when Paragraph is larger than the Page...
   394  			// Should be fine if we just break on the paragraph, i.e. splitting it up over 2+ pages
   395  
   396  			blocks = append(blocks, blk)
   397  			blk = NewBlock(ctx.PageWidth, ctx.PageHeight)
   398  
   399  			// New Page.
   400  			ctx.Page++
   401  			newContext := ctx
   402  			newContext.Y = ctx.Margins.top // + p.Margins.top
   403  			newContext.X = ctx.Margins.left + p.margins.left
   404  			newContext.Height = ctx.PageHeight - ctx.Margins.top - ctx.Margins.bottom - p.margins.bottom
   405  			newContext.Width = ctx.PageWidth - ctx.Margins.left - ctx.Margins.right - p.margins.left - p.margins.right
   406  			ctx = newContext
   407  		}
   408  	} else {
   409  		// Absolute.
   410  		if p.wrapWidth == 0 {
   411  			// Use necessary space.
   412  			p.SetWidth(p.getTextWidth())
   413  		}
   414  		ctx.X = p.xPos
   415  		ctx.Y = p.yPos
   416  	}
   417  
   418  	// Place the Paragraph on the template at position (x,y) based on the ctx.
   419  	ctx, err := drawStyledParagraphOnBlock(blk, p, ctx)
   420  	if err != nil {
   421  		common.Log.Debug("ERROR: %v", err)
   422  		return nil, ctx, err
   423  	}
   424  
   425  	blocks = append(blocks, blk)
   426  	if p.positioning.isRelative() {
   427  		ctx.X -= p.margins.left // Move back.
   428  		ctx.Width = origContext.Width
   429  		return blocks, ctx, nil
   430  	}
   431  	// Absolute: not changing the context.
   432  	return blocks, origContext, nil
   433  }
   434  
   435  // Draw block on specified location on Page, adding to the content stream.
   436  func drawStyledParagraphOnBlock(blk *Block, p *StyledParagraph, ctx DrawContext) (DrawContext, error) {
   437  	// Find first free index for the font resources of the paragraph
   438  	num := 1
   439  	fontName := core.PdfObjectName(fmt.Sprintf("Font%d", num))
   440  	for blk.resources.HasFontByName(fontName) {
   441  		num++
   442  		fontName = core.PdfObjectName(fmt.Sprintf("Font%d", num))
   443  	}
   444  
   445  	// Add default font to the page resources
   446  	err := blk.resources.SetFontByName(fontName, p.defaultStyle.Font.ToPdfObject())
   447  	if err != nil {
   448  		return ctx, err
   449  	}
   450  	num++
   451  
   452  	defaultFontName := fontName
   453  	defaultFontSize := p.defaultStyle.FontSize
   454  
   455  	// Wrap the text into lines.
   456  	p.wrapText()
   457  
   458  	// Add the fonts of all chunks to the page resources
   459  	fonts := [][]core.PdfObjectName{}
   460  
   461  	for _, line := range p.lines {
   462  		fontLine := []core.PdfObjectName{}
   463  
   464  		for _, chunk := range line {
   465  			fontName = core.PdfObjectName(fmt.Sprintf("Font%d", num))
   466  
   467  			err := blk.resources.SetFontByName(fontName, chunk.Style.Font.ToPdfObject())
   468  			if err != nil {
   469  				return ctx, err
   470  			}
   471  
   472  			fontLine = append(fontLine, fontName)
   473  			num++
   474  		}
   475  
   476  		fonts = append(fonts, fontLine)
   477  	}
   478  
   479  	// Create the content stream.
   480  	cc := contentstream.NewContentCreator()
   481  	cc.Add_q()
   482  
   483  	yPos := ctx.PageHeight - ctx.Y - defaultFontSize*p.lineHeight
   484  	cc.Translate(ctx.X, yPos)
   485  
   486  	if p.angle != 0 {
   487  		cc.RotateDeg(p.angle)
   488  	}
   489  
   490  	cc.Add_BT()
   491  
   492  	for idx, line := range p.lines {
   493  		if idx != 0 {
   494  			// Move to next line if not first.
   495  			cc.Add_Tstar()
   496  		}
   497  
   498  		isLastLine := idx == len(p.lines)-1
   499  
   500  		// Get width of the line (excluding spaces).
   501  		var width float64
   502  		var spaceWidth float64
   503  		var spaces uint
   504  
   505  		for _, chunk := range line {
   506  			style := &chunk.Style
   507  
   508  			spaceMetrics, found := style.Font.GetGlyphCharMetrics("space")
   509  			if !found {
   510  				return ctx, errors.New("The font does not have a space glyph")
   511  			}
   512  
   513  			var chunkSpaces uint
   514  			for _, r := range chunk.Text {
   515  				glyph, found := p.encoder.RuneToGlyph(r)
   516  				if !found {
   517  					common.Log.Debug("Rune 0x%x not supported by text encoder", r)
   518  					return ctx, errors.New("Unsupported rune in text encoding")
   519  				}
   520  
   521  				if glyph == "space" {
   522  					chunkSpaces++
   523  					continue
   524  				}
   525  				if glyph == "controlLF" {
   526  					continue
   527  				}
   528  
   529  				metrics, found := style.Font.GetGlyphCharMetrics(glyph)
   530  				if !found {
   531  					common.Log.Debug("Unsupported glyph %s in font\n", glyph)
   532  					return ctx, errors.New("Unsupported text glyph")
   533  				}
   534  
   535  				width += style.FontSize * metrics.Wx
   536  			}
   537  
   538  			spaceWidth += float64(chunkSpaces) * spaceMetrics.Wx * style.FontSize
   539  			spaces += chunkSpaces
   540  		}
   541  
   542  		// Add line shifts
   543  		objs := []core.PdfObject{}
   544  		if p.alignment == TextAlignmentJustify {
   545  			// Not to justify last line.
   546  			if spaces > 0 && !isLastLine {
   547  				spaceWidth = (p.wrapWidth*1000.0 - width) / float64(spaces) / defaultFontSize
   548  			}
   549  		} else if p.alignment == TextAlignmentCenter {
   550  			// Start with a shift.
   551  			shift := (p.wrapWidth*1000.0 - width - spaceWidth) / 2 / defaultFontSize
   552  			objs = append(objs, core.MakeFloat(-shift))
   553  		} else if p.alignment == TextAlignmentRight {
   554  			shift := (p.wrapWidth*1000.0 - width - spaceWidth) / defaultFontSize
   555  			objs = append(objs, core.MakeFloat(-shift))
   556  		}
   557  
   558  		if len(objs) > 0 {
   559  			cc.Add_Tf(defaultFontName, defaultFontSize).
   560  				Add_TL(defaultFontSize * p.lineHeight).
   561  				Add_TJ(objs...)
   562  		}
   563  
   564  		// Render line text chunks
   565  		for k, chunk := range line {
   566  			style := &chunk.Style
   567  
   568  			r, g, b := style.Color.ToRGB()
   569  			fontName := defaultFontName
   570  			fontSize := defaultFontSize
   571  
   572  			if p.alignment != TextAlignmentJustify || isLastLine {
   573  				spaceMetrics, found := style.Font.GetGlyphCharMetrics("space")
   574  				if !found {
   575  					return ctx, errors.New("The font does not have a space glyph")
   576  				}
   577  
   578  				fontName = fonts[idx][k]
   579  				fontSize = style.FontSize
   580  				spaceWidth = spaceMetrics.Wx
   581  			}
   582  
   583  			encStr := ""
   584  			for _, rn := range chunk.Text {
   585  				glyph, found := p.encoder.RuneToGlyph(rn)
   586  				if !found {
   587  					common.Log.Debug("Rune 0x%x not supported by text encoder", r)
   588  					return ctx, errors.New("Unsupported rune in text encoding")
   589  				}
   590  
   591  				if glyph == "space" {
   592  					if !found {
   593  						common.Log.Debug("Unsupported glyph %s in font\n", glyph)
   594  						return ctx, errors.New("Unsupported text glyph")
   595  					}
   596  
   597  					if len(encStr) > 0 {
   598  						cc.Add_rg(r, g, b).
   599  							Add_Tf(fonts[idx][k], style.FontSize).
   600  							Add_TL(style.FontSize * p.lineHeight).
   601  							Add_TJ([]core.PdfObject{core.MakeString(encStr)}...)
   602  
   603  						encStr = ""
   604  					}
   605  
   606  					cc.Add_Tf(fontName, fontSize).
   607  						Add_TL(fontSize * p.lineHeight).
   608  						Add_TJ([]core.PdfObject{core.MakeFloat(-spaceWidth)}...)
   609  				} else {
   610  					encStr += p.encoder.Encode(string(rn))
   611  				}
   612  			}
   613  
   614  			if len(encStr) > 0 {
   615  				cc.Add_rg(r, g, b).
   616  					Add_Tf(fonts[idx][k], style.FontSize).
   617  					Add_TL(style.FontSize * p.lineHeight).
   618  					Add_TJ([]core.PdfObject{core.MakeString(encStr)}...)
   619  			}
   620  		}
   621  	}
   622  	cc.Add_ET()
   623  	cc.Add_Q()
   624  
   625  	ops := cc.Operations()
   626  	ops.WrapIfNeeded()
   627  
   628  	blk.addContents(ops)
   629  
   630  	if p.positioning.isRelative() {
   631  		pHeight := p.Height() + p.margins.bottom
   632  		ctx.Y += pHeight
   633  		ctx.Height -= pHeight
   634  
   635  		// If the division is inline, calculate context new X coordinate.
   636  		if ctx.Inline {
   637  			ctx.X += p.Width() + p.margins.right
   638  		}
   639  	}
   640  
   641  	return ctx, nil
   642  }