Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
22 changes: 22 additions & 0 deletions docs/assets/examples/textgrid/v2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ 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"

m.AddRow(40,
text.NewCol(2, "Red text", props.Text{Color: &props.RedColor}),
Expand Down Expand Up @@ -125,5 +126,26 @@ 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,
},
),
)
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: 11.66ms, executions: [11.66ms]
add_row -> avg: 3902.83ns, executions: [21.12μs, 0.42μs, 0.29μs, 0.21μs, 0.25μs, 1.12μs]
add_rows -> avg: 178.43ns, executions: [292.00ns, 209.00ns, 83.00ns, 250.00ns, 83.00ns, 166.00ns, 166.00ns]
file_size -> 34.90Kb
3 changes: 2 additions & 1 deletion docs/v2/features/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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) |
| `Hyperlink` | `*string` | `nil` | URL — makes the text a clickable link (rendered in blue) |

Expand All @@ -26,6 +26,7 @@ Text can be created as a standalone `Component`, wrapped directly into a `Col`,
- 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.
- 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
57 changes: 45 additions & 12 deletions internal/providers/gofpdf/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,7 @@ func (s *Text) Add(text string, cell *entity.Cell, textProp *props.Text) {
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.getLines(unicodeText, textProp.BreakLineStrategy, width)

accumulateOffsetY := 0.0

Expand All @@ -105,11 +98,20 @@ func (s *Text) GetLinesQuantity(text string, textProp *props.Text, colWidth floa

textTranslated := s.textToUnicode(text, textProp)

if textProp.BreakLineStrategy == breakline.DashStrategy {
return len(s.getLinesBreakingLineWithDash(text, colWidth))
}
return len(s.getLines(textTranslated, textProp.BreakLineStrategy, colWidth))
}

return len(s.getLinesBreakingLineFromSpace(strings.Split(textTranslated, " "), 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)
}
}

func (s *Text) getLinesBreakingLineFromSpace(words []string, colWidth float64) []string {
Expand Down Expand Up @@ -174,6 +176,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
47 changes: 47 additions & 0 deletions internal/providers/gofpdf/text_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,25 @@ func TestGetLinesHeight(t *testing.T) {

assert.Equal(t, 2, height)
})

t.Run("when translated text occupies two lines with CharacterStrategy, should return two", func(t *testing.T) {
t.Parallel()
textProp := &props.Text{BreakLineStrategy: breakline.CharacterStrategy}
textProp.MakeValid(&props.Font{Family: fontfamily.Arial, Size: 10, Style: fontstyle.Normal})

font := mocks.NewFont(t)
font.EXPECT().SetFont(textProp.Family, textProp.Style, textProp.Size)

pdf := mocks.NewFpdf(t)
pdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(string) string { return "aaaa" })
pdf.EXPECT().GetStringWidth("a").Return(3.0).Times(4)

text := gofpdf.NewText(pdf, mocks.NewMath(t), font)

height := text.GetLinesQuantity("ääää", textProp, 6)

assert.Equal(t, 2, height)
})
}

func TestText_Add(t *testing.T) {
Expand Down Expand Up @@ -354,6 +373,34 @@ func TestText_Add(t *testing.T) {
// Act
sut.Add("ab", cell, textProp)
})
t.Run("when character strategy text is wider than an empty column, should not render an empty line first", func(t *testing.T) {
t.Parallel()
cell := &entity.Cell{X: 0, Y: 0, Width: 0, Height: 100}
originalColor := &props.Color{Red: 0, Green: 0, Blue: 0}
textProp := &props.Text{
Family: fontfamily.Arial,
Style: fontstyle.Normal,
Size: 10,
Align: align.Left,
BreakLineStrategy: breakline.CharacterStrategy,
}

font := mocks.NewFont(t)
font.EXPECT().SetFont(fontfamily.Arial, fontstyle.Normal, 10.0)
font.EXPECT().GetHeight(fontfamily.Arial, fontstyle.Normal, 10.0).Return(5.0)
font.EXPECT().GetColor().Return(originalColor)
font.EXPECT().SetColor(originalColor)

pdf := mocks.NewFpdf(t)
pdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string { return s })
pdf.EXPECT().GetStringWidth("a").Return(5.0).Times(3)
pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0)
pdf.EXPECT().Text(0.0, 5.0, "a")

sut := gofpdf.NewText(pdf, mocks.NewMath(t), font)

sut.Add("a", cell, textProp)
})
t.Run("when top exceeds cell height, should clamp top to cell height", func(t *testing.T) {
t.Parallel()
// Arrange
Expand Down
13 changes: 13 additions & 0 deletions pkg/components/text/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"github.com/johnfercher/maroto/v2"
"github.com/johnfercher/maroto/v2/pkg/components/col"
"github.com/johnfercher/maroto/v2/pkg/components/text"
"github.com/johnfercher/maroto/v2/pkg/consts/breakline"
"github.com/johnfercher/maroto/v2/pkg/props"
)

// ExampleNew demonstrates how to create a text component.
Expand All @@ -27,6 +29,17 @@ func ExampleNewCol() {
// generate document
}

// ExampleNewCol_characterStrategy demonstrates how to create a text column that wraps at character boundaries.
func ExampleNewCol_characterStrategy() {
m := maroto.New()

content := "CharacterStrategyBreaksLongTextWithoutAddingHyphens"
textCol := text.NewCol(12, content, props.Text{BreakLineStrategy: breakline.CharacterStrategy})
m.AddRow(10, textCol)

// generate document
}

// ExampleNewRow demonstrates how to create a text component wrapped into a row.
func ExampleNewRow() {
m := maroto.New()
Expand Down
5 changes: 5 additions & 0 deletions pkg/consts/breakline/breakline.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@ const (
// This strategy is useful for languages that don't use space between words.
// To divide the lines, is applied a dash in the end of the line.
DashStrategy Strategy = "dash_strategy"
// CharacterStrategy is a break line strategy that counts the length for
// a set of characters with no relation with the meaning of words.
// This strategy is useful for languages that don't use space between words.
// Lines are broken at character boundaries without adding any symbols.
CharacterStrategy Strategy = "character_strategy"
)
72 changes: 71 additions & 1 deletion test/maroto/examples/textgrid.json
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,77 @@
]
},
{
"value": 139.080833333,
"value": 10,
"type": "row",
"nodes": [
{
"value": 0,
"type": "col",
"details": {
"is_max": true
},
"nodes": [
{
"value": "Character break line strategy",
"type": "text",
"details": {
"prop_align": "L",
"prop_breakline_strategy": "empty_space_strategy",
"prop_color": "RGB(0, 0, 0)",
"prop_font_family": "arial",
"prop_font_size": 10
}
}
]
}
]
},
{
"value": 7.055555555555556,
"type": "row",
"nodes": [
{
"value": 6,
"type": "col",
"nodes": [
{
"value": "CharacterStrategyBreaksLongTextWithoutAddingHyphens",
"type": "text",
"details": {
"prop_align": "L",
"prop_breakline_strategy": "dash_strategy",
"prop_color": "RGB(0, 0, 0)",
"prop_font_family": "arial",
"prop_font_size": 10,
"prop_left": 3,
"prop_right": 3
}
}
]
},
{
"value": 6,
"type": "col",
"nodes": [
{
"value": "CharacterStrategyBreaksLongTextWithoutAddingHyphens",
"type": "text",
"details": {
"prop_align": "L",
"prop_breakline_strategy": "character_strategy",
"prop_color": "RGB(0, 0, 0)",
"prop_font_family": "arial",
"prop_font_size": 10,
"prop_left": 3,
"prop_right": 3
}
}
]
}
]
},
{
"value": 122.025277777,
"type": "row",
"nodes": [
{
Expand Down