github.com/unidoc/unidoc@v2.2.0+incompatible/pdf/creator/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  
    12  	"github.com/unidoc/unidoc/common"
    13  	"github.com/unidoc/unidoc/pdf/contentstream"
    14  	"github.com/unidoc/unidoc/pdf/core"
    15  	"github.com/unidoc/unidoc/pdf/model"
    16  	"github.com/unidoc/unidoc/pdf/model/fonts"
    17  	"github.com/unidoc/unidoc/pdf/model/textencoding"
    18  )
    19  
    20  // Paragraph 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 Paragraph struct {
    23  	// The input utf-8 text as a string (series of runes).
    24  	text string
    25  
    26  	// The text encoder which can convert the text (as runes) into a series of glyphs and get character metrics.
    27  	encoder textencoding.TextEncoder
    28  
    29  	// The font to be used to draw the text.
    30  	textFont fonts.Font
    31  
    32  	// The font size (points).
    33  	fontSize float64
    34  
    35  	// The line relative height (default 1).
    36  	lineHeight float64
    37  
    38  	// The text color.
    39  	color model.PdfColorDeviceRGB
    40  
    41  	// Text alignment: Align left/right/center/justify.
    42  	alignment TextAlignment
    43  
    44  	// Wrapping properties.
    45  	enableWrap bool
    46  	wrapWidth  float64
    47  
    48  	// defaultWrap defines whether wrapping has been defined explictly or whether default behavior should
    49  	// be observed. Default behavior depends on context: normally wrap is expected, except for example in
    50  	// table cells wrapping is off by default.
    51  	defaultWrap bool
    52  
    53  	// Rotation angle (degrees).
    54  	angle float64
    55  
    56  	// Margins to be applied around the block when drawing on Page.
    57  	margins margins
    58  
    59  	// Positioning: relative / absolute.
    60  	positioning positioning
    61  
    62  	// Absolute coordinates (when in absolute mode).
    63  	xPos float64
    64  	yPos float64
    65  
    66  	// Scaling factors (1 default).
    67  	scaleX, scaleY float64
    68  
    69  	// Text lines after wrapping to available width.
    70  	textLines []string
    71  }
    72  
    73  // NewParagraph create a new text paragraph. Uses default parameters: Helvetica, WinAnsiEncoding and wrap enabled
    74  // with a wrap width of 100 points.
    75  func NewParagraph(text string) *Paragraph {
    76  	p := &Paragraph{}
    77  	p.text = text
    78  	p.textFont = fonts.NewFontHelvetica()
    79  	p.SetEncoder(textencoding.NewWinAnsiTextEncoder())
    80  	p.fontSize = 10
    81  	p.lineHeight = 1.0
    82  
    83  	// TODO: Can we wrap intellectually, only if given width is known?
    84  	p.enableWrap = true
    85  	p.defaultWrap = true
    86  	p.SetColor(ColorRGBFrom8bit(0, 0, 0))
    87  	p.alignment = TextAlignmentLeft
    88  	p.angle = 0
    89  
    90  	p.scaleX = 1
    91  	p.scaleY = 1
    92  
    93  	p.positioning = positionRelative
    94  
    95  	return p
    96  }
    97  
    98  // SetFont sets the Paragraph's font.
    99  func (p *Paragraph) SetFont(font fonts.Font) {
   100  	p.textFont = font
   101  }
   102  
   103  // SetFontSize sets the font size in document units (points).
   104  func (p *Paragraph) SetFontSize(fontSize float64) {
   105  	p.fontSize = fontSize
   106  }
   107  
   108  // SetTextAlignment sets the horizontal alignment of the text within the space provided.
   109  func (p *Paragraph) SetTextAlignment(align TextAlignment) {
   110  	p.alignment = align
   111  }
   112  
   113  // SetEncoder sets the text encoding.
   114  func (p *Paragraph) SetEncoder(encoder textencoding.TextEncoder) {
   115  	p.encoder = encoder
   116  	// Sync with the text font too.
   117  	// XXX/FIXME: Keep in 1 place only.
   118  	p.textFont.SetEncoder(encoder)
   119  }
   120  
   121  // SetLineHeight sets the line height (1.0 default).
   122  func (p *Paragraph) SetLineHeight(lineheight float64) {
   123  	p.lineHeight = lineheight
   124  }
   125  
   126  // SetText sets the text content of the Paragraph.
   127  func (p *Paragraph) SetText(text string) {
   128  	p.text = text
   129  }
   130  
   131  // Text sets the text content of the Paragraph.
   132  func (p *Paragraph) Text() string {
   133  	return p.text
   134  }
   135  
   136  // SetEnableWrap sets the line wrapping enabled flag.
   137  func (p *Paragraph) SetEnableWrap(enableWrap bool) {
   138  	p.enableWrap = enableWrap
   139  	p.defaultWrap = false
   140  }
   141  
   142  // SetColor set the color of the Paragraph text.
   143  //
   144  // Example:
   145  // 1.   p := NewParagraph("Red paragraph")
   146  //      // Set to red color with a hex code:
   147  //      p.SetColor(creator.ColorRGBFromHex("#ff0000"))
   148  //
   149  // 2. Make Paragraph green with 8-bit rgb values (0-255 each component)
   150  //      p.SetColor(creator.ColorRGBFrom8bit(0, 255, 0)
   151  //
   152  // 3. Make Paragraph blue with arithmetic (0-1) rgb components.
   153  //      p.SetColor(creator.ColorRGBFromArithmetic(0, 0, 1.0)
   154  //
   155  func (p *Paragraph) SetColor(col Color) {
   156  	pdfColor := model.NewPdfColorDeviceRGB(col.ToRGB())
   157  	p.color = *pdfColor
   158  }
   159  
   160  // SetPos sets absolute positioning with specified coordinates.
   161  func (p *Paragraph) SetPos(x, y float64) {
   162  	p.positioning = positionAbsolute
   163  	p.xPos = x
   164  	p.yPos = y
   165  }
   166  
   167  // SetAngle sets the rotation angle of the text.
   168  func (p *Paragraph) SetAngle(angle float64) {
   169  	p.angle = angle
   170  }
   171  
   172  // SetMargins sets the Paragraph's margins.
   173  func (p *Paragraph) SetMargins(left, right, top, bottom float64) {
   174  	p.margins.left = left
   175  	p.margins.right = right
   176  	p.margins.top = top
   177  	p.margins.bottom = bottom
   178  }
   179  
   180  // GetMargins returns the Paragraph's margins: left, right, top, bottom.
   181  func (p *Paragraph) GetMargins() (float64, float64, float64, float64) {
   182  	return p.margins.left, p.margins.right, p.margins.top, p.margins.bottom
   183  }
   184  
   185  // SetWidth sets the the Paragraph width. This is essentially the wrapping width, i.e. the width the text can extend to
   186  // prior to wrapping over to next line.
   187  func (p *Paragraph) SetWidth(width float64) {
   188  	p.wrapWidth = width
   189  	p.wrapText()
   190  }
   191  
   192  // Width returns the width of the Paragraph.
   193  func (p *Paragraph) Width() float64 {
   194  	if p.enableWrap {
   195  		return p.wrapWidth
   196  	}
   197  	return p.getTextWidth() / 1000.0
   198  }
   199  
   200  // Height returns the height of the Paragraph. The height is calculated based on the input text and how it is wrapped
   201  // within the container. Does not include Margins.
   202  func (p *Paragraph) Height() float64 {
   203  	if p.textLines == nil || len(p.textLines) == 0 {
   204  		p.wrapText()
   205  	}
   206  
   207  	h := float64(len(p.textLines)) * p.lineHeight * p.fontSize
   208  	return h
   209  }
   210  
   211  // getTextWidth calculates the text width as if all in one line (not taking wrapping into account).
   212  func (p *Paragraph) getTextWidth() float64 {
   213  	w := float64(0.0)
   214  
   215  	for _, rune := range p.text {
   216  		glyph, found := p.encoder.RuneToGlyph(rune)
   217  		if !found {
   218  			common.Log.Debug("Error! Glyph not found for rune: %s\n", rune)
   219  			return -1 // XXX/FIXME: return error.
   220  		}
   221  
   222  		// Ignore newline for this.. Handles as if all in one line.
   223  		if glyph == "controlLF" {
   224  			continue
   225  		}
   226  
   227  		metrics, found := p.textFont.GetGlyphCharMetrics(glyph)
   228  		if !found {
   229  			common.Log.Debug("Glyph char metrics not found! %s\n", glyph)
   230  			return -1 // XXX/FIXME: return error.
   231  		}
   232  		w += p.fontSize * metrics.Wx
   233  	}
   234  
   235  	return w
   236  }
   237  
   238  // Simple algorithm to wrap the text into lines (greedy algorithm - fill the lines).
   239  // XXX/TODO: Consider the Knuth/Plass algorithm or an alternative.
   240  func (p *Paragraph) wrapText() error {
   241  	if !p.enableWrap {
   242  		p.textLines = []string{p.text}
   243  		return nil
   244  	}
   245  
   246  	line := []rune{}
   247  	lineWidth := float64(0.0)
   248  	p.textLines = []string{}
   249  
   250  	runes := []rune(p.text)
   251  	glyphs := []string{}
   252  	widths := []float64{}
   253  
   254  	for _, val := range runes {
   255  		glyph, found := p.encoder.RuneToGlyph(val)
   256  		if !found {
   257  			common.Log.Debug("Error! Glyph not found for rune: %v\n", val)
   258  			return errors.New("Glyph not found for rune") // XXX/FIXME: return error.
   259  		}
   260  
   261  		// Newline wrapping.
   262  		if glyph == "controlLF" {
   263  			// Moves to next line.
   264  			p.textLines = append(p.textLines, string(line))
   265  			line = []rune{}
   266  			lineWidth = 0
   267  			widths = []float64{}
   268  			glyphs = []string{}
   269  			continue
   270  		}
   271  
   272  		metrics, found := p.textFont.GetGlyphCharMetrics(glyph)
   273  		if !found {
   274  			common.Log.Debug("Glyph char metrics not found! %s\n", glyph)
   275  			return errors.New("Glyph char metrics missing") // XXX/FIXME: return error.
   276  		}
   277  
   278  		w := p.fontSize * metrics.Wx
   279  		if lineWidth+w > p.wrapWidth*1000.0 {
   280  			// Goes out of bounds: Wrap.
   281  			// Breaks on the character.
   282  			// XXX/TODO: when goes outside: back up to next space, otherwise break on the character.
   283  			idx := -1
   284  			for i := len(glyphs) - 1; i >= 0; i-- {
   285  				if glyphs[i] == "space" {
   286  					idx = i
   287  					break
   288  				}
   289  			}
   290  			if idx > 0 {
   291  				p.textLines = append(p.textLines, string(line[0:idx+1]))
   292  
   293  				line = line[idx+1:]
   294  				line = append(line, val)
   295  
   296  				glyphs = glyphs[idx+1:]
   297  				glyphs = append(glyphs, glyph)
   298  				widths = widths[idx+1:]
   299  				widths = append(widths, w)
   300  
   301  				lineWidth = 0
   302  				for _, width := range widths {
   303  					lineWidth += width
   304  				}
   305  
   306  			} else {
   307  				p.textLines = append(p.textLines, string(line))
   308  				line = []rune{val}
   309  				lineWidth = w
   310  				widths = []float64{w}
   311  				glyphs = []string{glyph}
   312  			}
   313  		} else {
   314  			line = append(line, val)
   315  			lineWidth += w
   316  			glyphs = append(glyphs, glyph)
   317  			widths = append(widths, w)
   318  		}
   319  	}
   320  	if len(line) > 0 {
   321  		p.textLines = append(p.textLines, string(line))
   322  	}
   323  
   324  	return nil
   325  }
   326  
   327  // GeneratePageBlocks generates the page blocks.  Multiple blocks are generated if the contents wrap over
   328  // multiple pages. Implements the Drawable interface.
   329  func (p *Paragraph) GeneratePageBlocks(ctx DrawContext) ([]*Block, DrawContext, error) {
   330  	origContext := ctx
   331  	blocks := []*Block{}
   332  
   333  	blk := NewBlock(ctx.PageWidth, ctx.PageHeight)
   334  	if p.positioning.isRelative() {
   335  		// Account for Paragraph Margins.
   336  		ctx.X += p.margins.left
   337  		ctx.Y += p.margins.top
   338  		ctx.Width -= p.margins.left + p.margins.right
   339  		ctx.Height -= p.margins.top + p.margins.bottom
   340  
   341  		// Use available space.
   342  		p.SetWidth(ctx.Width)
   343  
   344  		if p.Height() > ctx.Height {
   345  			// Goes out of the bounds.  Write on a new template instead and create a new context at upper
   346  			// left corner.
   347  			// XXX/TODO: Handle case when Paragraph is larger than the Page...
   348  			// Should be fine if we just break on the paragraph, i.e. splitting it up over 2+ pages
   349  
   350  			blocks = append(blocks, blk)
   351  			blk = NewBlock(ctx.PageWidth, ctx.PageHeight)
   352  
   353  			// New Page.
   354  			ctx.Page++
   355  			newContext := ctx
   356  			newContext.Y = ctx.Margins.top // + p.Margins.top
   357  			newContext.X = ctx.Margins.left + p.margins.left
   358  			newContext.Height = ctx.PageHeight - ctx.Margins.top - ctx.Margins.bottom - p.margins.bottom
   359  			newContext.Width = ctx.PageWidth - ctx.Margins.left - ctx.Margins.right - p.margins.left - p.margins.right
   360  			ctx = newContext
   361  		}
   362  	} else {
   363  		// Absolute.
   364  		if p.wrapWidth == 0 {
   365  			// Use necessary space.
   366  			p.SetWidth(p.getTextWidth())
   367  		}
   368  		ctx.X = p.xPos
   369  		ctx.Y = p.yPos
   370  	}
   371  
   372  	// Place the Paragraph on the template at position (x,y) based on the ctx.
   373  	ctx, err := drawParagraphOnBlock(blk, p, ctx)
   374  	if err != nil {
   375  		common.Log.Debug("ERROR: %v", err)
   376  		return nil, ctx, err
   377  	}
   378  
   379  	blocks = append(blocks, blk)
   380  	if p.positioning.isRelative() {
   381  		ctx.X -= p.margins.left // Move back.
   382  		ctx.Width = origContext.Width
   383  		return blocks, ctx, nil
   384  	}
   385  	// Absolute: not changing the context.
   386  	return blocks, origContext, nil
   387  }
   388  
   389  // Draw block on specified location on Page, adding to the content stream.
   390  func drawParagraphOnBlock(blk *Block, p *Paragraph, ctx DrawContext) (DrawContext, error) {
   391  	// Find a free name for the font.
   392  	num := 1
   393  	fontName := core.PdfObjectName(fmt.Sprintf("Font%d", num))
   394  	for blk.resources.HasFontByName(fontName) {
   395  		num++
   396  		fontName = core.PdfObjectName(fmt.Sprintf("Font%d", num))
   397  	}
   398  
   399  	// Add to the Page resources.
   400  	err := blk.resources.SetFontByName(fontName, p.textFont.ToPdfObject())
   401  	if err != nil {
   402  		return ctx, err
   403  	}
   404  
   405  	// Wrap the text into lines.
   406  	p.wrapText()
   407  
   408  	// Create the content stream.
   409  	cc := contentstream.NewContentCreator()
   410  	cc.Add_q()
   411  
   412  	yPos := ctx.PageHeight - ctx.Y - p.fontSize*p.lineHeight
   413  
   414  	cc.Translate(ctx.X, yPos)
   415  	if p.angle != 0 {
   416  		cc.RotateDeg(p.angle)
   417  	}
   418  
   419  	cc.Add_BT().
   420  		Add_rg(p.color.R(), p.color.G(), p.color.B()).
   421  		Add_Tf(fontName, p.fontSize).
   422  		Add_TL(p.fontSize * p.lineHeight)
   423  
   424  	for idx, line := range p.textLines {
   425  		if idx != 0 {
   426  			// Move to next line if not first.
   427  			cc.Add_Tstar()
   428  		}
   429  
   430  		runes := []rune(line)
   431  
   432  		// Get width of the line (excluding spaces).
   433  		w := float64(0)
   434  		spaces := 0
   435  		for _, runeVal := range runes {
   436  			glyph, found := p.encoder.RuneToGlyph(runeVal)
   437  			if !found {
   438  				common.Log.Debug("Rune 0x%x not supported by text encoder", runeVal)
   439  				return ctx, errors.New("Unsupported rune in text encoding")
   440  			}
   441  			if glyph == "space" {
   442  				spaces++
   443  				continue
   444  			}
   445  			if glyph == "controlLF" {
   446  				continue
   447  			}
   448  			metrics, found := p.textFont.GetGlyphCharMetrics(glyph)
   449  			if !found {
   450  				common.Log.Debug("Unsupported glyph %s in font\n", glyph)
   451  				return ctx, errors.New("Unsupported text glyph")
   452  			}
   453  
   454  			w += p.fontSize * metrics.Wx
   455  		}
   456  
   457  		objs := []core.PdfObject{}
   458  
   459  		spaceMetrics, found := p.textFont.GetGlyphCharMetrics("space")
   460  		if !found {
   461  			return ctx, errors.New("The font does not have a space glyph")
   462  		}
   463  		spaceWidth := spaceMetrics.Wx
   464  		if p.alignment == TextAlignmentJustify {
   465  			if spaces > 0 && idx < len(p.textLines)-1 { // Not to justify last line.
   466  				spaceWidth = (p.wrapWidth*1000.0 - w) / float64(spaces) / p.fontSize
   467  			}
   468  		} else if p.alignment == TextAlignmentCenter {
   469  			// Start with a shift.
   470  			textWidth := w + float64(spaces)*spaceWidth*p.fontSize
   471  			shift := (p.wrapWidth*1000.0 - textWidth) / 2 / p.fontSize
   472  			objs = append(objs, core.MakeFloat(-shift))
   473  		} else if p.alignment == TextAlignmentRight {
   474  			textWidth := w + float64(spaces)*spaceWidth*p.fontSize
   475  			shift := (p.wrapWidth*1000.0 - textWidth) / p.fontSize
   476  			objs = append(objs, core.MakeFloat(-shift))
   477  		}
   478  
   479  		encStr := ""
   480  		for _, runeVal := range runes {
   481  			//creator.Add_Tj(core.PdfObjectString(tb.Encoder.Encode(line)))
   482  			glyph, found := p.encoder.RuneToGlyph(runeVal)
   483  			if !found {
   484  				common.Log.Debug("Rune 0x%x not supported by text encoder", runeVal)
   485  				return ctx, errors.New("Unsupported rune in text encoding")
   486  			}
   487  
   488  			if glyph == "space" {
   489  				if !found {
   490  					common.Log.Debug("Unsupported glyph %s in font\n", glyph)
   491  					return ctx, errors.New("Unsupported text glyph")
   492  				}
   493  
   494  				if len(encStr) > 0 {
   495  					objs = append(objs, core.MakeString(encStr))
   496  					encStr = ""
   497  				}
   498  				objs = append(objs, core.MakeFloat(-spaceWidth))
   499  			} else {
   500  				encStr += string(p.encoder.Encode(string(runeVal)))
   501  			}
   502  		}
   503  		if len(encStr) > 0 {
   504  			objs = append(objs, core.MakeString(encStr))
   505  		}
   506  
   507  		cc.Add_TJ(objs...)
   508  	}
   509  	cc.Add_ET()
   510  	cc.Add_Q()
   511  
   512  	ops := cc.Operations()
   513  	ops.WrapIfNeeded()
   514  
   515  	blk.addContents(ops)
   516  
   517  	if p.positioning.isRelative() {
   518  		pHeight := p.Height() + p.margins.bottom
   519  		ctx.Y += pHeight
   520  		ctx.Height -= pHeight
   521  
   522  		// If the division is inline, calculate context new X coordinate.
   523  		if ctx.Inline {
   524  			ctx.X += p.Width() + p.margins.right
   525  		}
   526  	}
   527  
   528  	return ctx, nil
   529  }