Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ A Maroto way to create PDFs. Maroto is inspired in Bootstrap and uses [Gofpdf](h
You can write your PDFs like you are creating a site using Bootstrap. A Row may have many Cols, and a Col may have many components.
Besides that, pages will be added when content may extrapolate the useful area. You can define a header which will be added
always when a new page appear, in this case, a header may have many rows, lines or tablelist.
Text components support multiple line break strategies, including character-based wrapping when a text should break without spaces and without hyphenation.

#### Maroto `v2.4.0` is here! Try out:

Expand Down
56 changes: 56 additions & 0 deletions docs/assets/examples/textgrid/v2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package main
import (
"log"

"github.com/johnfercher/maroto/v2/pkg/components/col"
"github.com/johnfercher/maroto/v2/pkg/consts/fontstyle"

"github.com/johnfercher/maroto/v2/pkg/core"

"github.com/johnfercher/maroto/v2"

"github.com/johnfercher/maroto/v2/pkg/consts/border"
"github.com/johnfercher/maroto/v2/pkg/consts/breakline"

"github.com/johnfercher/maroto/v2/pkg/components/text"
Expand Down Expand Up @@ -47,6 +49,8 @@ func GetMaroto() core.Maroto {

longText := "This is a longer sentence that will be broken into multiple lines " +
"as it does not fit into the column otherwise."
longWord := "CharacterStrategyBreaksLongTextWithoutAddingHyphens"
paragraphText := "First paragraph line 1.\nFirst paragraph line 2.\n\nSecond paragraph starts here."

m.AddRow(40,
text.NewCol(2, "Red text", props.Text{Color: &props.RedColor}),
Expand Down Expand Up @@ -125,5 +129,57 @@ func GetMaroto() core.Maroto {
},
),
)

m.AddRows(text.NewRow(10, "Character break line strategy"))

m.AddAutoRow(
text.NewCol(6, longWord,
props.Text{
Left: 3,
Right: 3,
Align: align.Left,
BreakLineStrategy: breakline.DashStrategy,
},
),
text.NewCol(6, longWord,
props.Text{
Left: 3,
Right: 3,
Align: align.Left,
BreakLineStrategy: breakline.CharacterStrategy,
},
),
)

m.AddRows(text.NewRow(10, "Explicit line breaks", props.Text{Style: fontstyle.Bold}))

m.AddRow(8,
text.NewCol(6, "Default behavior", props.Text{Align: align.Center, Style: fontstyle.Bold}),
text.NewCol(6, "PreserveLineBreaks", props.Text{Align: align.Center, Style: fontstyle.Bold}),
)

textCellStyle := &props.Cell{
BorderType: border.Full,
BorderColor: &props.BlackColor,
BorderThickness: 0.1,
}

m.AddAutoRow(
col.New(6).Add(
text.New(paragraphText, props.Text{
Top: 2,
Left: 2,
Right: 2,
}),
).WithStyle(textCellStyle),
col.New(6).Add(
text.New(paragraphText, props.Text{
Top: 2,
Left: 2,
Right: 2,
PreserveLineBreaks: true,
}),
).WithStyle(textCellStyle),
)
return m
}
Binary file modified docs/assets/pdf/textgridv2.pdf
Binary file not shown.
8 changes: 4 additions & 4 deletions docs/assets/text/textgridv2.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
generate -> avg: 7.76ms, executions: [7.76ms]
add_row -> avg: 3375.17ns, executions: [18.38μs, 0.42μs, 0.21μs, 0.17μs, 0.21μs, 0.88μs]
add_rows -> avg: 138.83ns, executions: [208.00ns, 125.00ns, 83.00ns, 209.00ns, 42.00ns, 166.00ns]
file_size -> 33.35Kb
generate -> avg: 10.22ms, executions: [10.22ms]
add_row -> avg: 2702.57ns, executions: [16.88μs, 0.38μs, 0.17μs, 0.17μs, 0.21μs, 0.88μs, 0.25μs]
add_rows -> avg: 114.38ns, executions: [166.00ns, 83.00ns, 41.00ns, 167.00ns, 83.00ns, 125.00ns, 125.00ns, 125.00ns]
file_size -> 37.60Kb
5 changes: 4 additions & 1 deletion docs/v2/features/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@ Text can be created as a standalone `Component`, wrapped directly into a `Col`,
| `Bottom` | `float64` | `0` | Bottom offset — used by auto rows only (mm) |
| `Left` | `float64` | `0` | Left margin inside the cell (mm) |
| `Right` | `float64` | `0` | Right margin inside the cell (mm) |
| `BreakLineStrategy` | `breakline.Strategy` | `EmptySpaceStrategy` | `EmptySpaceStrategy` breaks on spaces; `DashStrategy` breaks mid-word with a hyphen |
| `BreakLineStrategy` | `breakline.Strategy` | `EmptySpaceStrategy` | `EmptySpaceStrategy` breaks on spaces; `DashStrategy` breaks mid-word with a hyphen; `CharacterStrategy` breaks at character boundaries without adding symbols |
| `VerticalPadding` | `float64` | `0` | Extra spacing between lines (mm) |
| `PreserveLineBreaks` | `bool` | `false` | Treat explicit `\n` as hard line breaks instead of collapsing them into spaces |
| `Hyperlink` | `*string` | `nil` | URL — makes the text a clickable link (rendered in blue) |

## Usage notes

- When `Hyperlink` is set, the text color is overridden with blue regardless of `Color`.
- `Top` and `Left`/`Right` are clamped to the cell dimensions if they exceed it.
- `BreakLineStrategy` only applies when the text does not fit on a single line.
- Use `CharacterStrategy` when a text should wrap without spaces and without inserting trailing hyphens.
- Set `PreserveLineBreaks: true` when you want explicit `\n` to become hard line breaks; use `\n\n` to create a blank paragraph gap inside the same text component.
- For justified text on the last line, spacing may revert to default space width to avoid stretching a few characters across the full width.

## GoDoc
Expand Down
117 changes: 88 additions & 29 deletions internal/providers/gofpdf/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ type Text struct {
font core.Font
}

type textLine struct {
content string
width float64
}

// NewText create a Text.
func NewText(pdf gofpdfwrapper.Fpdf, math core.Math, font core.Font) *Text {
return &Text{
Expand Down Expand Up @@ -66,33 +71,12 @@ func (s *Text) Add(text string, cell *entity.Cell, textProp *props.Text) {
}

y += fontHeight

// Apply Unicode before calc spaces
unicodeText := s.textToUnicode(text, textProp)
stringWidth := s.pdf.GetStringWidth(unicodeText)

// If should add one line
if stringWidth <= width {
s.addLine(textProp, x, width, y, stringWidth, unicodeText)
s.font.SetColor(originalColor)
return
}

var lines []string

if textProp.BreakLineStrategy == breakline.EmptySpaceStrategy {
words := strings.Split(unicodeText, " ")
lines = s.getLinesBreakingLineFromSpace(words, width)
} else {
lines = s.getLinesBreakingLineWithDash(unicodeText, width)
}
lines := s.getTextLines(text, textProp, width)

accumulateOffsetY := 0.0

for index, line := range lines {
lineWidth := s.pdf.GetStringWidth(line)

s.addLine(textProp, x, width, y+float64(index)*fontHeight+accumulateOffsetY, lineWidth, line)
s.addLine(textProp, x, width, y+float64(index)*fontHeight+accumulateOffsetY, line.width, line.content)
accumulateOffsetY += textProp.VerticalPadding
}

Expand All @@ -102,14 +86,20 @@ func (s *Text) Add(text string, cell *entity.Cell, textProp *props.Text) {
// GetLinesQuantity retrieve the quantity of lines which a text will occupy to avoid that text to extrapolate a cell.
func (s *Text) GetLinesQuantity(text string, textProp *props.Text, colWidth float64) int {
s.font.SetFont(textProp.Family, textProp.Style, textProp.Size)
return len(s.getTextLines(text, textProp, colWidth))
}

textTranslated := s.textToUnicode(text, textProp)

if textProp.BreakLineStrategy == breakline.DashStrategy {
return len(s.getLinesBreakingLineWithDash(text, colWidth))
func (s *Text) getLines(text string, strategy breakline.Strategy, colWidth float64) []string {
switch strategy {
case breakline.EmptySpaceStrategy:
return s.getLinesBreakingLineFromSpace(strings.Split(text, " "), colWidth)
case breakline.DashStrategy:
return s.getLinesBreakingLineWithDash(text, colWidth)
case breakline.CharacterStrategy:
return s.getLinesBreakingLineByCharacter(text, colWidth)
default:
return s.getLinesBreakingLineFromSpace(strings.Split(text, " "), colWidth)
}

return len(s.getLinesBreakingLineFromSpace(strings.Split(textTranslated, " "), colWidth))
}

func (s *Text) getLinesBreakingLineFromSpace(words []string, colWidth float64) []string {
Expand Down Expand Up @@ -145,6 +135,35 @@ func (s *Text) getLinesBreakingLineFromSpace(words []string, colWidth float64) [
return lines
}

func (s *Text) getTextLines(text string, textProp *props.Text, colWidth float64) []textLine {
normalizedText := normalizeLineBreaks(text, textProp.PreserveLineBreaks)
unicodeText := s.textToUnicode(normalizedText, textProp)
paragraphs := strings.Split(unicodeText, "\n")
lines := make([]textLine, 0, len(paragraphs))

for _, paragraph := range paragraphs {
paragraphWidth := s.pdf.GetStringWidth(paragraph)
if paragraphWidth <= colWidth {
lines = append(lines, textLine{
content: paragraph,
width: paragraphWidth,
})
continue
}

wrappedParagraph := s.getLines(paragraph, textProp.BreakLineStrategy, colWidth)

for _, line := range wrappedParagraph {
lines = append(lines, textLine{
content: line,
width: s.pdf.GetStringWidth(line),
})
}
}

return lines
}

func (s *Text) getLinesBreakingLineWithDash(words string, colWidth float64) []string {
currentlySize := 0.0

Expand Down Expand Up @@ -174,6 +193,37 @@ func (s *Text) getLinesBreakingLineWithDash(words string, colWidth float64) []st
return lines
}

func (s *Text) getLinesBreakingLineByCharacter(words string, colWidth float64) []string {
currentlySize := 0.0
lines := []string{}
var content string

for _, letter := range words {
letterString := fmt.Sprintf("%c", letter)
width := s.pdf.GetStringWidth(letterString)

if currentlySize+width > colWidth && content != "" {
lines = append(lines, content)
content = ""
currentlySize = 0
}

// Skip spaces if they would be at the start of a new line.
if letterString == " " && content == "" {
continue
}

content += letterString
currentlySize += width
}

if content != "" {
lines = append(lines, content)
}

return lines
}
Comment on lines +196 to +225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider caching per-character widths for repeated characters.

The current implementation calls GetStringWidth for every character, which may be expensive for long texts with repeated characters (e.g., CJK content). The underlying gofpdf method performs font metric lookups.

For this PR, the implementation is functionally correct. If performance becomes a concern with large texts, consider memoizing character widths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/providers/gofpdf/text.go` around lines 196 - 225, The loop in
getLinesBreakingLineByCharacter repeatedly calls s.pdf.GetStringWidth for each
character; to improve performance for long/repetitive input (e.g., CJK) memoize
per-character widths: create a local map[rune]float64 (or map[string]float64)
named e.g. charWidthCache inside getLinesBreakingLineByCharacter, look up the
rune/character in the cache and call s.pdf.GetStringWidth only on cache miss,
store the result, then use the cached width for sizing decisions (keep the rest
of the logic using currentlySize, content, and colWidth unchanged).


func (s *Text) addLine(textProp *props.Text, xColOffset, colWidth, yColOffset, textWidth float64, text string) {
left, top, _, _ := s.pdf.GetMargins()

Expand Down Expand Up @@ -258,3 +308,12 @@ func isIncorrectSpaceWidth(textWidth, spaceWidth, defaultSpaceWidth float64, tex
lastChar := r
return !unicode.IsLetter(lastChar) && !unicode.IsNumber(lastChar)
}

func normalizeLineBreaks(text string, preserve bool) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
if preserve {
return text
}
return strings.ReplaceAll(text, "\n", " ")
}
Loading