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 }