diff --git a/docs/v2/features/text.md b/docs/v2/features/text.md index 828221e0..c765cbb0 100644 --- a/docs/v2/features/text.md +++ b/docs/v2/features/text.md @@ -20,6 +20,8 @@ Text can be created as a standalone `Component`, wrapped directly into a `Col`, | `BreakLineStrategy` | `breakline.Strategy` | `EmptySpaceStrategy` | `EmptySpaceStrategy` breaks on spaces; `DashStrategy` breaks mid-word with a hyphen | | `VerticalPadding` | `float64` | `0` | Extra spacing between lines (mm) | | `Hyperlink` | `*string` | `nil` | URL — makes the text a clickable link (rendered in blue) | +| `Rotation` | `float64` | `0` | Rotation angle in degrees. Positive values rotate counter-clockwise, negative values clockwise. The cell auto-expands vertically to contain the rotated bounding box. | +| `RotationPivot` | `rotationpivot.Pivot` | `{Center, Middle}` | Anchor point of the rotation. `Horizontal`: `Start`, `Center`, `End`. `Vertical`: `Top`, `Middle`, `Bottom`. For multi-line text the vertical axis applies to the whole block. | ## Usage notes @@ -27,6 +29,7 @@ Text can be created as a standalone `Component`, wrapped directly into a `Col`, - `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. - For justified text on the last line, spacing may revert to default space width to avoid stretching a few characters across the full width. +- `Rotation` is purely additive: `Rotation == 0` produces byte-identical PDFs to before the feature existed. When set, `GetHeight` returns the rotated bounding box height (`w·|sinθ| + h·|cosθ|`) so the layout reserves enough vertical space; the rendered text baseline is shifted so the rotated bounding box sits inside the cell for any combination of pivot and angle sign. ## GoDoc * [constructor : New](https://pkg.go.dev/github.com/johnfercher/maroto/v2/pkg/components/text#New) diff --git a/internal/providers/gofpdf/provider.go b/internal/providers/gofpdf/provider.go index 720ee890..c0e32cac 100644 --- a/internal/providers/gofpdf/provider.go +++ b/internal/providers/gofpdf/provider.go @@ -57,6 +57,10 @@ func (g *provider) GetLinesQuantity(text string, textProp *props.Text, colWidth return g.text.GetLinesQuantity(text, textProp, colWidth) } +func (g *provider) GetStringWidth(text string, textProp *props.Text) float64 { + return g.text.GetStringWidth(text, textProp) +} + func (g *provider) GetFontHeight(prop *props.Font) float64 { return g.font.GetHeight(prop.Family, prop.Style, prop.Size) } diff --git a/internal/providers/gofpdf/text.go b/internal/providers/gofpdf/text.go index bc1d6e7d..6fc6bb7f 100644 --- a/internal/providers/gofpdf/text.go +++ b/internal/providers/gofpdf/text.go @@ -2,6 +2,7 @@ package gofpdf import ( "fmt" + "math" "strings" "unicode" "unicode/utf8" @@ -10,6 +11,7 @@ import ( "github.com/johnfercher/maroto/v2/pkg/consts/align" "github.com/johnfercher/maroto/v2/pkg/consts/breakline" "github.com/johnfercher/maroto/v2/pkg/consts/fontfamily" + "github.com/johnfercher/maroto/v2/pkg/consts/rotationpivot" "github.com/johnfercher/maroto/v2/pkg/core" "github.com/johnfercher/maroto/v2/pkg/core/entity" "github.com/johnfercher/maroto/v2/pkg/props" @@ -71,20 +73,87 @@ func (s *Text) Add(text string, cell *entity.Cell, textProp *props.Text) { unicodeText := s.textToUnicode(text, textProp) stringWidth := s.pdf.GetStringWidth(unicodeText) - // If should add one line + // Determine the lines up-front so multi-line rotation can pivot around the + // whole block, not just the first line. + var lines []string if stringWidth <= width { - s.addLine(textProp, x, width, y, stringWidth, unicodeText) - s.font.SetColor(originalColor) - return + lines = []string{unicodeText} + } else if textProp.BreakLineStrategy == breakline.EmptySpaceStrategy { + lines = s.getLinesBreakingLineFromSpace(strings.Split(unicodeText, " "), width) + } else { + lines = s.getLinesBreakingLineWithDash(unicodeText, width) } - var lines []string + // Rotation honours both axes of textProp.RotationPivot. The baseline of + // the first line is shifted so the rotated bounding box of the whole + // (multi-line) block sits inside the Text.GetHeight-expanded cell. + if textProp.Rotation != 0 { + marginLeft, marginTop, _, _ := s.pdf.GetMargins() + n := float64(len(lines)) + textHeight := n*fontHeight + (n-1)*textProp.VerticalPadding + blockWidth := stringWidth + if blockWidth > width { + blockWidth = width + } - if textProp.BreakLineStrategy == breakline.EmptySpaceStrategy { - words := strings.Split(unicodeText, " ") - lines = s.getLinesBreakingLineFromSpace(words, width) - } else { - lines = s.getLinesBreakingLineWithDash(unicodeText, width) + var alignOffsetX float64 + switch textProp.Align { + case align.Center: + alignOffsetX = (width - blockWidth) / 2 + case align.Right: + alignOffsetX = width - blockWidth + } + if alignOffsetX < 0 { + alignOffsetX = 0 + } + + var pivotOffsetX float64 + switch textProp.RotationPivot.Horizontal { + case rotationpivot.Start: + pivotOffsetX = 0 + case rotationpivot.End: + pivotOffsetX = blockWidth + default: // Center + pivotOffsetX = blockWidth / 2 + } + var pivotOffsetY float64 + switch textProp.RotationPivot.Vertical { + case rotationpivot.Top: + pivotOffsetY = 0 + case rotationpivot.Bottom: + pivotOffsetY = textHeight + default: // Middle + pivotOffsetY = textHeight / 2 + } + + rad := textProp.Rotation * math.Pi / 180 + sin, cos := math.Sin(rad), math.Cos(rad) + // Distance any rotated corner rises above the pivot. Corners relative + // to the pivot are TL=(-px,-py), TR=(W-px,-py), BR=(W-px,H-py), BL=(-px,H-py); + // rotated y is -dx*sin + dy*cos, so -y = dx*sin - dy*cos. Take the max. + px, py := pivotOffsetX, pivotOffsetY + W, H := blockWidth, textHeight + upExtent := math.Max(0, math.Max(py*cos-px*sin, + math.Max((W-px)*sin+py*cos, + math.Max((W-px)*sin-(H-py)*cos, + -px*sin-(H-py)*cos)))) + + contentHeight := cell.Height - textProp.Top - textProp.Bottom + if contentHeight > textHeight { + // place the rotated bbox top at the cell content top + y = cell.Y + textProp.Top + upExtent + fontHeight - pivotOffsetY + } + pivotX := x + alignOffsetX + pivotOffsetX + marginLeft + pivotY := y + (pivotOffsetY - fontHeight) + marginTop + s.pdf.TransformBegin() + s.pdf.TransformRotate(textProp.Rotation, pivotX, pivotY) + defer s.pdf.TransformEnd() + } + + if len(lines) == 1 { + s.addLine(textProp, x, width, y, stringWidth, lines[0]) + s.font.SetColor(originalColor) + return } accumulateOffsetY := 0.0 @@ -99,6 +168,13 @@ func (s *Text) Add(text string, cell *entity.Cell, textProp *props.Text) { s.font.SetColor(originalColor) } +// GetStringWidth returns the rendered width of the text after font selection +// and unicode translation. +func (s *Text) GetStringWidth(text string, textProp *props.Text) float64 { + s.font.SetFont(textProp.Family, textProp.Style, textProp.Size) + return s.pdf.GetStringWidth(s.textToUnicode(text, textProp)) +} + // 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) diff --git a/internal/providers/gofpdf/text_test.go b/internal/providers/gofpdf/text_test.go index fbd23df3..246393c9 100644 --- a/internal/providers/gofpdf/text_test.go +++ b/internal/providers/gofpdf/text_test.go @@ -2,6 +2,7 @@ package gofpdf_test import ( "fmt" + "math" "testing" "github.com/johnfercher/maroto/v2/internal/providers/gofpdf" @@ -9,6 +10,7 @@ import ( "github.com/johnfercher/maroto/v2/pkg/consts/breakline" "github.com/johnfercher/maroto/v2/pkg/consts/fontfamily" "github.com/johnfercher/maroto/v2/pkg/consts/fontstyle" + "github.com/johnfercher/maroto/v2/pkg/consts/rotationpivot" "github.com/johnfercher/maroto/v2/pkg/core/entity" "github.com/johnfercher/maroto/v2/pkg/props" @@ -452,4 +454,354 @@ func TestText_Add(t *testing.T) { // Act sut.Add("", cell, textProp) }) + t.Run("when rotation 90 and center pivot, should anchor bbox top at cell top", func(t *testing.T) { + t.Parallel() + // Arrange + cell := &entity.Cell{X: 10, Y: 20, Width: 100, Height: 50} + originalColor := &props.Color{Red: 0, Green: 0, Blue: 0} + textProp := &props.Text{ + Family: fontfamily.Arial, + Style: fontstyle.Normal, + Size: 10, + Align: align.Left, + Left: 5, + Right: 5, + Rotation: 90, + RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.Center, Vertical: rotationpivot.Middle}, + } + stringWidth, fontHeight := 20.0, 5.0 + marginLeft, marginTop := 2.0, 3.0 + rad := 90.0 * math.Pi / 180 + sin, cos := math.Sin(rad), math.Cos(rad) + upExtent := (stringWidth*math.Abs(sin) + fontHeight*math.Abs(cos)) / 2 + // y = cell.Y + Top + upExtent + fontHeight/2 + // x = cell.X + Left = 15 + // pivotX = x + alignOffsetX(0) + stringWidth/2 + marginLeft + // pivotY = y - fontHeight/2 + marginTop = upExtent + marginTop + cell.Y + expectedY := cell.Y + textProp.Top + upExtent + fontHeight/2 + expectedPivotX := (cell.X + textProp.Left) + stringWidth/2 + marginLeft + expectedPivotY := expectedY - fontHeight/2 + marginTop + + font := mocks.NewFont(t) + font.EXPECT().SetFont(fontfamily.Arial, fontstyle.Normal, 10.0) + font.EXPECT().GetHeight(fontfamily.Arial, fontstyle.Normal, 10.0).Return(fontHeight) + 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("hello").Return(stringWidth) + pdf.EXPECT().GetMargins().Return(marginLeft, marginTop, 0.0, 0.0) + pdf.EXPECT().TransformBegin().Once() + pdf.EXPECT().TransformRotate(90.0, expectedPivotX, expectedPivotY).Once() + pdf.EXPECT().TransformEnd().Once() + pdf.EXPECT().Text((cell.X+textProp.Left)+marginLeft, expectedY+marginTop, "hello") + + sut := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + // Act + sut.Add("hello", cell, textProp) + }) + t.Run("when rotation -90 and center pivot, should match +90 baseline (symmetric)", func(t *testing.T) { + t.Parallel() + // Arrange — same parameters as +90, sign flipped on rotation only + cell := &entity.Cell{X: 10, Y: 20, Width: 100, Height: 50} + originalColor := &props.Color{Red: 0, Green: 0, Blue: 0} + textProp := &props.Text{ + Family: fontfamily.Arial, + Style: fontstyle.Normal, + Size: 10, + Align: align.Left, + Left: 5, + Right: 5, + Rotation: -90, + RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.Center, Vertical: rotationpivot.Middle}, + } + stringWidth, fontHeight := 20.0, 5.0 + marginLeft, marginTop := 2.0, 3.0 + rad := -90.0 * math.Pi / 180 + sin, cos := math.Sin(rad), math.Cos(rad) + upExtent := (stringWidth*math.Abs(sin) + fontHeight*math.Abs(cos)) / 2 + expectedY := cell.Y + textProp.Top + upExtent + fontHeight/2 + expectedPivotX := (cell.X + textProp.Left) + stringWidth/2 + marginLeft + expectedPivotY := expectedY - fontHeight/2 + marginTop + + font := mocks.NewFont(t) + font.EXPECT().SetFont(fontfamily.Arial, fontstyle.Normal, 10.0) + font.EXPECT().GetHeight(fontfamily.Arial, fontstyle.Normal, 10.0).Return(fontHeight) + 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("hello").Return(stringWidth) + pdf.EXPECT().GetMargins().Return(marginLeft, marginTop, 0.0, 0.0) + pdf.EXPECT().TransformBegin().Once() + pdf.EXPECT().TransformRotate(-90.0, expectedPivotX, expectedPivotY).Once() + pdf.EXPECT().TransformEnd().Once() + pdf.EXPECT().Text((cell.X+textProp.Left)+marginLeft, expectedY+marginTop, "hello") + + sut := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + // Act + sut.Add("hello", cell, textProp) + }) + t.Run("when rotation 45 with center align, pivot should account for align offset", func(t *testing.T) { + t.Parallel() + // Arrange + cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 50} + originalColor := &props.Color{Red: 0, Green: 0, Blue: 0} + textProp := &props.Text{ + Family: fontfamily.Arial, + Style: fontstyle.Normal, + Size: 10, + Align: align.Center, + Rotation: 45, + RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.Center, Vertical: rotationpivot.Middle}, + } + stringWidth, fontHeight, contentWidth := 20.0, 5.0, 100.0 + rad := 45.0 * math.Pi / 180 + sin, cos := math.Sin(rad), math.Cos(rad) + upExtent := (stringWidth*math.Abs(sin) + fontHeight*math.Abs(cos)) / 2 + alignOffsetX := (contentWidth - stringWidth) / 2 // 40 + expectedY := upExtent + fontHeight/2 + expectedPivotX := alignOffsetX + stringWidth/2 // 50 + expectedPivotY := expectedY - fontHeight/2 + + font := mocks.NewFont(t) + font.EXPECT().SetFont(fontfamily.Arial, fontstyle.Normal, 10.0) + font.EXPECT().GetHeight(fontfamily.Arial, fontstyle.Normal, 10.0).Return(fontHeight) + 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("hello").Return(stringWidth) + pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0) + pdf.EXPECT().TransformBegin().Once() + pdf.EXPECT().TransformRotate(45.0, expectedPivotX, expectedPivotY).Once() + pdf.EXPECT().TransformEnd().Once() + pdf.EXPECT().Text(alignOffsetX, expectedY, "hello") + + sut := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + // Act + sut.Add("hello", cell, textProp) + }) + t.Run("when rotation 45 with start pivot, should anchor at left edge of text", func(t *testing.T) { + t.Parallel() + // Arrange + cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 50} + originalColor := &props.Color{Red: 0, Green: 0, Blue: 0} + textProp := &props.Text{ + Family: fontfamily.Arial, + Style: fontstyle.Normal, + Size: 10, + Align: align.Left, + Rotation: 45, + RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.Start, Vertical: rotationpivot.Middle}, + } + stringWidth, fontHeight := 20.0, 5.0 + rad := 45.0 * math.Pi / 180 + sin, cos := math.Sin(rad), math.Cos(rad) + // start pivot: pivotOffsetX = 0, upExtent = h/2*|cos| + max(0, w*sin) + upExtent := fontHeight/2*math.Abs(cos) + math.Max(0, stringWidth*sin) + expectedY := upExtent + fontHeight/2 + expectedPivotX := 0.0 // left edge of text + expectedPivotY := expectedY - fontHeight/2 + + font := mocks.NewFont(t) + font.EXPECT().SetFont(fontfamily.Arial, fontstyle.Normal, 10.0) + font.EXPECT().GetHeight(fontfamily.Arial, fontstyle.Normal, 10.0).Return(fontHeight) + 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("hello").Return(stringWidth) + pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0) + pdf.EXPECT().TransformBegin().Once() + pdf.EXPECT().TransformRotate(45.0, expectedPivotX, expectedPivotY).Once() + pdf.EXPECT().TransformEnd().Once() + pdf.EXPECT().Text(0.0, expectedY, "hello") + + sut := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + // Act + sut.Add("hello", cell, textProp) + }) + t.Run("when rotation 45 with end pivot, should anchor at right edge of text", func(t *testing.T) { + t.Parallel() + // Arrange + cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 50} + originalColor := &props.Color{Red: 0, Green: 0, Blue: 0} + textProp := &props.Text{ + Family: fontfamily.Arial, + Style: fontstyle.Normal, + Size: 10, + Align: align.Left, + Rotation: 45, + RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.End, Vertical: rotationpivot.Middle}, + } + stringWidth, fontHeight := 20.0, 5.0 + rad := 45.0 * math.Pi / 180 + sin, cos := math.Sin(rad), math.Cos(rad) + // end pivot: pivotOffsetX = stringWidth, upExtent = h/2*|cos| + max(0, -w*sin) + upExtent := fontHeight/2*math.Abs(cos) + math.Max(0, -stringWidth*sin) + expectedY := upExtent + fontHeight/2 + expectedPivotX := stringWidth // right edge of text + expectedPivotY := expectedY - fontHeight/2 + + font := mocks.NewFont(t) + font.EXPECT().SetFont(fontfamily.Arial, fontstyle.Normal, 10.0) + font.EXPECT().GetHeight(fontfamily.Arial, fontstyle.Normal, 10.0).Return(fontHeight) + 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("hello").Return(stringWidth) + pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0) + pdf.EXPECT().TransformBegin().Once() + pdf.EXPECT().TransformRotate(45.0, expectedPivotX, expectedPivotY).Once() + pdf.EXPECT().TransformEnd().Once() + pdf.EXPECT().Text(0.0, expectedY, "hello") + + sut := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + // Act + sut.Add("hello", cell, textProp) + }) + t.Run("when rotation 45 with vertical Top pivot, should anchor at top of block", func(t *testing.T) { + t.Parallel() + // Arrange + cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 50} + originalColor := &props.Color{Red: 0, Green: 0, Blue: 0} + textProp := &props.Text{ + Family: fontfamily.Arial, + Style: fontstyle.Normal, + Size: 10, + Align: align.Left, + Rotation: 45, + RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.Center, Vertical: rotationpivot.Top}, + } + stringWidth, fontHeight := 20.0, 5.0 + W, H := stringWidth, fontHeight + px, py := W/2, 0.0 + rad := 45.0 * math.Pi / 180 + sin, cos := math.Sin(rad), math.Cos(rad) + upExtent := math.Max(0, math.Max(py*cos-px*sin, + math.Max((W-px)*sin+py*cos, + math.Max((W-px)*sin-(H-py)*cos, + -px*sin-(H-py)*cos)))) + expectedY := upExtent + fontHeight - py + expectedPivotX := px + expectedPivotY := expectedY + (py - fontHeight) + + font := mocks.NewFont(t) + font.EXPECT().SetFont(fontfamily.Arial, fontstyle.Normal, 10.0) + font.EXPECT().GetHeight(fontfamily.Arial, fontstyle.Normal, 10.0).Return(fontHeight) + 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("hello").Return(stringWidth) + pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0) + pdf.EXPECT().TransformBegin().Once() + pdf.EXPECT().TransformRotate(45.0, expectedPivotX, expectedPivotY).Once() + pdf.EXPECT().TransformEnd().Once() + pdf.EXPECT().Text(0.0, expectedY, "hello") + + sut := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + // Act + sut.Add("hello", cell, textProp) + }) + t.Run("when rotation 45 multi-line, pivot uses textHeight, not fontHeight", func(t *testing.T) { + t.Parallel() + // Arrange — narrow column so two short words wrap into two lines. + cell := &entity.Cell{X: 0, Y: 0, Width: 8, 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.EmptySpaceStrategy, + Rotation: 45, + RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.Center, Vertical: rotationpivot.Middle}, + } + fontHeight := 5.0 + // 2-line block: textHeight = 2*fontHeight = 10, blockWidth = min(stringWidth, width) = 8 + W, H := 8.0, 2*fontHeight + px, py := W/2, H/2 + rad := 45.0 * math.Pi / 180 + sin, cos := math.Sin(rad), math.Cos(rad) + upExtent := math.Max(0, math.Max(py*cos-px*sin, + math.Max((W-px)*sin+py*cos, + math.Max((W-px)*sin-(H-py)*cos, + -px*sin-(H-py)*cos)))) + expectedY := upExtent + fontHeight - py + expectedPivotX := px + expectedPivotY := expectedY + (py - fontHeight) + + font := mocks.NewFont(t) + font.EXPECT().SetFont(fontfamily.Arial, fontstyle.Normal, 10.0) + font.EXPECT().GetHeight(fontfamily.Arial, fontstyle.Normal, 10.0).Return(fontHeight) + font.EXPECT().GetColor().Return(originalColor) + font.EXPECT().SetColor(originalColor) + + pdf := mocks.NewFpdf(t) + pdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string { return s }) + // total "ab cd" doesn't fit in width=8 → multi-line path + pdf.EXPECT().GetStringWidth("ab cd").Return(20.0) + pdf.EXPECT().GetStringWidth("ab").Return(7.0) + pdf.EXPECT().GetStringWidth(" cd").Return(8.0) + pdf.EXPECT().GetStringWidth("cd").Return(7.0) + pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0) + pdf.EXPECT().TransformBegin().Once() + pdf.EXPECT().TransformRotate(45.0, expectedPivotX, expectedPivotY).Once() + pdf.EXPECT().TransformEnd().Once() + // Two lines drawn at y, y+fontHeight + pdf.EXPECT().Text(0.0, expectedY, "ab") + pdf.EXPECT().Text(0.0, expectedY+fontHeight, "cd") + + sut := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + // Act + sut.Add("ab cd", cell, textProp) + }) + t.Run("when rotation is 0, should not call any Transform method", func(t *testing.T) { + t.Parallel() + // Arrange + cell := &entity.Cell{X: 0, Y: 0, Width: 100, Height: 50} + originalColor := &props.Color{Red: 0, Green: 0, Blue: 0} + textProp := &props.Text{ + Family: fontfamily.Arial, + Style: fontstyle.Normal, + Size: 10, + Align: align.Left, + Rotation: 0, + } + + 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("hello").Return(20.0) + pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0) + pdf.EXPECT().Text(0.0, 5.0, "hello") + // No TransformBegin/Rotate/End expected — mocks.NewFpdf(t) will fail on + // any unexpected call. + + sut := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + // Act + sut.Add("hello", cell, textProp) + }) } diff --git a/mocks/Provider.go b/mocks/Provider.go index d5702e35..495ba079 100644 --- a/mocks/Provider.go +++ b/mocks/Provider.go @@ -793,6 +793,53 @@ func (_c *Provider_GetLinesQuantity_Call) RunAndReturn(run func(string, *props.T return _c } +// GetStringWidth provides a mock function with given fields: text, textProp +func (_m *Provider) GetStringWidth(text string, textProp *props.Text) float64 { + ret := _m.Called(text, textProp) + + if len(ret) == 0 { + panic("no return value specified for GetStringWidth") + } + + var r0 float64 + if rf, ok := ret.Get(0).(func(string, *props.Text) float64); ok { + r0 = rf(text, textProp) + } else { + r0 = ret.Get(0).(float64) + } + + return r0 +} + +// Provider_GetStringWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStringWidth' +type Provider_GetStringWidth_Call struct { + *mock.Call +} + +// GetStringWidth is a helper method to define mock.On call +// - text string +// - textProp *props.Text +func (_e *Provider_Expecter) GetStringWidth(text interface{}, textProp interface{}) *Provider_GetStringWidth_Call { + return &Provider_GetStringWidth_Call{Call: _e.mock.On("GetStringWidth", text, textProp)} +} + +func (_c *Provider_GetStringWidth_Call) Run(run func(text string, textProp *props.Text)) *Provider_GetStringWidth_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(*props.Text)) + }) + return _c +} + +func (_c *Provider_GetStringWidth_Call) Return(_a0 float64) *Provider_GetStringWidth_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Provider_GetStringWidth_Call) RunAndReturn(run func(string, *props.Text) float64) *Provider_GetStringWidth_Call { + _c.Call.Return(run) + return _c +} + // SetCompression provides a mock function with given fields: compression func (_m *Provider) SetCompression(compression bool) { _m.Called(compression) diff --git a/mocks/Text.go b/mocks/Text.go index 7b4eab9a..8081ebd1 100644 --- a/mocks/Text.go +++ b/mocks/Text.go @@ -105,6 +105,53 @@ func (_c *Text_GetLinesQuantity_Call) RunAndReturn(run func(string, *props.Text, return _c } +// GetStringWidth provides a mock function with given fields: text, textProp +func (_m *Text) GetStringWidth(text string, textProp *props.Text) float64 { + ret := _m.Called(text, textProp) + + if len(ret) == 0 { + panic("no return value specified for GetStringWidth") + } + + var r0 float64 + if rf, ok := ret.Get(0).(func(string, *props.Text) float64); ok { + r0 = rf(text, textProp) + } else { + r0 = ret.Get(0).(float64) + } + + return r0 +} + +// Text_GetStringWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStringWidth' +type Text_GetStringWidth_Call struct { + *mock.Call +} + +// GetStringWidth is a helper method to define mock.On call +// - text string +// - textProp *props.Text +func (_e *Text_Expecter) GetStringWidth(text interface{}, textProp interface{}) *Text_GetStringWidth_Call { + return &Text_GetStringWidth_Call{Call: _e.mock.On("GetStringWidth", text, textProp)} +} + +func (_c *Text_GetStringWidth_Call) Run(run func(text string, textProp *props.Text)) *Text_GetStringWidth_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(*props.Text)) + }) + return _c +} + +func (_c *Text_GetStringWidth_Call) Return(_a0 float64) *Text_GetStringWidth_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Text_GetStringWidth_Call) RunAndReturn(run func(string, *props.Text) float64) *Text_GetStringWidth_Call { + _c.Call.Return(run) + return _c +} + // NewText creates a new instance of Text. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewText(t interface { diff --git a/pkg/components/text/example_test.go b/pkg/components/text/example_test.go index fc906a9e..7304bf7e 100644 --- a/pkg/components/text/example_test.go +++ b/pkg/components/text/example_test.go @@ -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/rotationpivot" + "github.com/johnfercher/maroto/v2/pkg/props" ) // ExampleNew demonstrates how to create a text component. @@ -36,3 +38,20 @@ func ExampleNewRow() { // generate document } + +// ExampleNew_rotated demonstrates how to rotate text and select the pivot point. +// The cell auto-expands vertically to contain the rotated bounding box. +func ExampleNew_rotated() { + m := maroto.New() + + rotated := text.New("Draft", props.Text{ + Rotation: 30, + RotationPivot: rotationpivot.Pivot{ + Horizontal: rotationpivot.Center, + Vertical: rotationpivot.Middle, + }, + }) + m.AddRow(10, col.New(12).Add(rotated)) + + // generate document +} diff --git a/pkg/components/text/text.go b/pkg/components/text/text.go index 3f278928..37423b67 100644 --- a/pkg/components/text/text.go +++ b/pkg/components/text/text.go @@ -2,6 +2,8 @@ package text import ( + "math" + "github.com/johnfercher/go-tree/node" "github.com/johnfercher/maroto/v2/pkg/components/col" @@ -61,11 +63,31 @@ func (t *Text) GetStructure() *node.Node[core.Structure] { return node.New(str) } -// GetHeight returns the height that the text will have in the PDF +// GetHeight returns the height that the text will have in the PDF. +// When Rotation is set, the cell is expanded to contain the rotated +// bounding box (|w·sinθ| + |h·cosθ|), with text width upper-bounded by +// the cell's content width — otherwise rotated text would bleed into +// adjacent rows. func (t *Text) GetHeight(provider core.Provider, cell *entity.Cell) float64 { - amountLines := provider.GetLinesQuantity(t.value, &t.prop, cell.Width-t.prop.Left-t.prop.Right) + contentWidth := cell.Width - t.prop.Left - t.prop.Right + if contentWidth < 0 { + contentWidth = 0 + } + amountLines := provider.GetLinesQuantity(t.value, &t.prop, contentWidth) fontHeight := provider.GetFontHeight(&props.Font{Family: t.prop.Family, Style: t.prop.Style, Size: t.prop.Size, Color: t.prop.Color}) textHeight := float64(amountLines)*fontHeight + float64(amountLines-1)*t.prop.VerticalPadding + + if t.prop.Rotation != 0 { + rad := t.prop.Rotation * math.Pi / 180 + absSin := math.Abs(math.Sin(rad)) + absCos := math.Abs(math.Cos(rad)) + stringWidth := provider.GetStringWidth(t.value, &t.prop) + if stringWidth > contentWidth { + stringWidth = contentWidth + } + textHeight = stringWidth*absSin + textHeight*absCos + } + return textHeight + t.prop.Top + t.prop.Bottom } diff --git a/pkg/components/text/text_test.go b/pkg/components/text/text_test.go index 1e846b04..0fb4beea 100644 --- a/pkg/components/text/text_test.go +++ b/pkg/components/text/text_test.go @@ -1,6 +1,7 @@ package text_test import ( + "math" "testing" "github.com/johnfercher/maroto/v2/internal/fixture" @@ -191,4 +192,62 @@ func TestText_GetHeight(t *testing.T) { height := sut.GetHeight(provider, &cell) assert.Equal(t, 10.0, height) }) + + t.Run("When rotation is 90, should return rotated bounding box height (= actual string width)", func(t *testing.T) { + t.Parallel() + cell := fixture.CellEntity() + font := fixture.FontProp() + textProp := props.Text{Rotation: 90} + textProp.MakeValid(&font) + + sut := text.New("text", textProp) + + provider := mocks.NewProvider(t) + provider.EXPECT().GetLinesQuantity("text", &textProp, 100.0).Return(1.0) + provider.EXPECT().GetFontHeight(&font).Return(2.0) + provider.EXPECT().GetStringWidth("text", &textProp).Return(40.0) + + // stringWidth=40, h=2: sin(90)=1, cos(90)=0 → 40*1 + 2*0 = 40 + height := sut.GetHeight(provider, &cell) + assert.InDelta(t, 40.0, height, 0.0001) + }) + + t.Run("When rotation is 45, should return (stringWidth + h) / sqrt(2)", func(t *testing.T) { + t.Parallel() + cell := fixture.CellEntity() + font := fixture.FontProp() + textProp := props.Text{Rotation: 45} + textProp.MakeValid(&font) + + sut := text.New("text", textProp) + + provider := mocks.NewProvider(t) + provider.EXPECT().GetLinesQuantity("text", &textProp, 100.0).Return(1.0) + provider.EXPECT().GetFontHeight(&font).Return(2.0) + provider.EXPECT().GetStringWidth("text", &textProp).Return(40.0) + + // stringWidth=40, h=2: sin(45)=cos(45)=1/sqrt(2) → (40 + 2) / sqrt(2) + height := sut.GetHeight(provider, &cell) + expected := (40.0 + 2.0) / math.Sqrt2 + assert.InDelta(t, expected, height, 0.0001) + }) + + t.Run("When string width exceeds content width, should clamp to content width", func(t *testing.T) { + t.Parallel() + cell := fixture.CellEntity() + font := fixture.FontProp() + textProp := props.Text{Rotation: 90} + textProp.MakeValid(&font) + + sut := text.New("text", textProp) + + provider := mocks.NewProvider(t) + provider.EXPECT().GetLinesQuantity("text", &textProp, 100.0).Return(1.0) + provider.EXPECT().GetFontHeight(&font).Return(2.0) + // stringWidth (200) > contentWidth (100) → clamps to 100 + provider.EXPECT().GetStringWidth("text", &textProp).Return(200.0) + + height := sut.GetHeight(provider, &cell) + assert.InDelta(t, 100.0, height, 0.0001) + }) } diff --git a/pkg/consts/rotationpivot/rotationpivot.go b/pkg/consts/rotationpivot/rotationpivot.go new file mode 100644 index 00000000..e05090a5 --- /dev/null +++ b/pkg/consts/rotationpivot/rotationpivot.go @@ -0,0 +1,36 @@ +// Package rotationpivot defines the anchor points used when rotating text. +package rotationpivot + +// Type selects the horizontal anchor of the text glyph block during rotation. +type Type string + +const ( + // Start anchors rotation at the leading edge of the text (left side for + // left-to-right scripts). + Start Type = "start" + // Center anchors rotation at the horizontal center of the text. Default. + Center Type = "center" + // End anchors rotation at the trailing edge of the text (right side for + // left-to-right scripts). + End Type = "end" +) + +// VerticalType selects the vertical anchor of the text glyph block during +// rotation. For multi-line text the anchor refers to the whole block, not a +// single line. +type VerticalType string + +const ( + // Top anchors rotation at the top of the text block. + Top VerticalType = "top" + // Middle anchors rotation at the vertical center of the text block. Default. + Middle VerticalType = "middle" + // Bottom anchors rotation at the bottom of the text block. + Bottom VerticalType = "bottom" +) + +// Pivot is the combined horizontal + vertical anchor used when rotating text. +type Pivot struct { + Horizontal Type + Vertical VerticalType +} diff --git a/pkg/core/components.go b/pkg/core/components.go index 8857ef3f..88db0c2a 100644 --- a/pkg/core/components.go +++ b/pkg/core/components.go @@ -41,6 +41,7 @@ type Checkbox interface { type Text interface { Add(text string, cell *entity.Cell, textProp *props.Text) GetLinesQuantity(text string, textProp *props.Text, colWidth float64) int + GetStringWidth(text string, textProp *props.Text) float64 } // Font is the abstraction which deals of how to set fontstyle configurations. diff --git a/pkg/core/provider.go b/pkg/core/provider.go index 579e491e..e0b492b7 100644 --- a/pkg/core/provider.go +++ b/pkg/core/provider.go @@ -20,6 +20,7 @@ type Provider interface { AddCheckbox(label string, cell *entity.Cell, prop *props.Checkbox) GetFontHeight(prop *props.Font) float64 GetLinesQuantity(text string, textProp *props.Text, colWidth float64) int + GetStringWidth(text string, textProp *props.Text) float64 AddMatrixCode(code string, cell *entity.Cell, prop *props.Rect) AddQrCode(code string, cell *entity.Cell, rect *props.Rect) AddBarCode(code string, cell *entity.Cell, prop *props.Barcode) diff --git a/pkg/props/text.go b/pkg/props/text.go index a9e8b8dd..0576401f 100644 --- a/pkg/props/text.go +++ b/pkg/props/text.go @@ -4,6 +4,7 @@ import ( "github.com/johnfercher/maroto/v2/pkg/consts/align" "github.com/johnfercher/maroto/v2/pkg/consts/breakline" "github.com/johnfercher/maroto/v2/pkg/consts/fontstyle" + "github.com/johnfercher/maroto/v2/pkg/consts/rotationpivot" ) // Text represents properties from a Text inside a cell. @@ -32,6 +33,14 @@ type Text struct { Color *Color // Hyperlink define a link to be opened when the text is clicked. Hyperlink *string + // Rotation rotates the text by the given angle in degrees. Positive values + // rotate counter-clockwise, negative values clockwise. The cell automatically + // expands vertically to contain the rotated bounding box. + Rotation float64 + // RotationPivot selects the anchor point used during rotation. Horizontal + // defaults to Center, Vertical defaults to Middle. For multi-line text the + // vertical axis applies to the whole block. + RotationPivot rotationpivot.Pivot } // ToMap converts a Text to a map. @@ -84,6 +93,18 @@ func (t *Text) ToMap() map[string]any { m["prop_hyperlink"] = *t.Hyperlink } + if t.Rotation != 0 { + m["prop_rotation"] = t.Rotation + } + + if t.RotationPivot.Horizontal != "" && t.RotationPivot.Horizontal != rotationpivot.Center { + m["prop_rotation_pivot_horizontal"] = t.RotationPivot.Horizontal + } + + if t.RotationPivot.Vertical != "" && t.RotationPivot.Vertical != rotationpivot.Middle { + m["prop_rotation_pivot_vertical"] = t.RotationPivot.Vertical + } + return m } @@ -135,4 +156,12 @@ func (t *Text) MakeValid(font *Font) { if t.BreakLineStrategy == "" { t.BreakLineStrategy = breakline.EmptySpaceStrategy } + + if t.RotationPivot.Horizontal == "" { + t.RotationPivot.Horizontal = rotationpivot.Center + } + + if t.RotationPivot.Vertical == "" { + t.RotationPivot.Vertical = rotationpivot.Middle + } } diff --git a/pkg/props/text_test.go b/pkg/props/text_test.go index ac2b8903..9005efc5 100644 --- a/pkg/props/text_test.go +++ b/pkg/props/text_test.go @@ -10,6 +10,7 @@ import ( "github.com/johnfercher/maroto/v2/pkg/consts/breakline" "github.com/johnfercher/maroto/v2/pkg/consts/fontfamily" "github.com/johnfercher/maroto/v2/pkg/consts/fontstyle" + "github.com/johnfercher/maroto/v2/pkg/consts/rotationpivot" "github.com/johnfercher/maroto/v2/pkg/props" ) @@ -203,4 +204,106 @@ func TestText_ToMap(t *testing.T) { // Assert assert.Equal(t, 5.0, m["prop_right"]) }) + t.Run("when rotation is set, should include rotation in map", func(t *testing.T) { + t.Parallel() + // Arrange + prop := props.Text{Rotation: 45} + + // Act + m := prop.ToMap() + + // Assert + assert.Equal(t, 45.0, m["prop_rotation"]) + }) + t.Run("when rotation is zero, should not include rotation in map", func(t *testing.T) { + t.Parallel() + // Arrange + prop := props.Text{Rotation: 0} + + // Act + m := prop.ToMap() + + // Assert + _, ok := m["prop_rotation"] + assert.False(t, ok) + }) + t.Run("when horizontal rotation pivot is set, should include it in map", func(t *testing.T) { + t.Parallel() + // Arrange + prop := props.Text{RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.Start}} + + // Act + m := prop.ToMap() + + // Assert + assert.Equal(t, rotationpivot.Start, m["prop_rotation_pivot_horizontal"]) + _, hasV := m["prop_rotation_pivot_vertical"] + assert.False(t, hasV) + }) + t.Run("when vertical rotation pivot is set, should include it in map", func(t *testing.T) { + t.Parallel() + // Arrange + prop := props.Text{RotationPivot: rotationpivot.Pivot{Vertical: rotationpivot.Top}} + + // Act + m := prop.ToMap() + + // Assert + assert.Equal(t, rotationpivot.Top, m["prop_rotation_pivot_vertical"]) + _, hasH := m["prop_rotation_pivot_horizontal"] + assert.False(t, hasH) + }) + t.Run("when rotation pivot is empty, should not include it in map", func(t *testing.T) { + t.Parallel() + // Arrange + prop := props.Text{} + + // Act + m := prop.ToMap() + + // Assert + _, hasH := m["prop_rotation_pivot_horizontal"] + _, hasV := m["prop_rotation_pivot_vertical"] + assert.False(t, hasH) + assert.False(t, hasV) + }) + t.Run("when rotation pivot is the default (Center, Middle), should omit both", func(t *testing.T) { + t.Parallel() + // Arrange + prop := props.Text{RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.Center, Vertical: rotationpivot.Middle}} + + // Act + m := prop.ToMap() + + // Assert + _, hasH := m["prop_rotation_pivot_horizontal"] + _, hasV := m["prop_rotation_pivot_vertical"] + assert.False(t, hasH) + assert.False(t, hasV) + }) +} + +func TestText_MakeValid_RotationPivot(t *testing.T) { + t.Parallel() + t.Run("when rotation pivot is empty, should default to center+middle", func(t *testing.T) { + t.Parallel() + prop := props.Text{} + prop.MakeValid(&props.Font{Family: fontfamily.Arial, Size: 10, Style: fontstyle.Normal}) + assert.Equal(t, rotationpivot.Center, prop.RotationPivot.Horizontal) + assert.Equal(t, rotationpivot.Middle, prop.RotationPivot.Vertical) + }) + t.Run("when rotation pivot is set, should preserve both axes", func(t *testing.T) { + t.Parallel() + prop := props.Text{RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.End, Vertical: rotationpivot.Bottom}} + prop.MakeValid(&props.Font{Family: fontfamily.Arial, Size: 10, Style: fontstyle.Normal}) + assert.Equal(t, rotationpivot.End, prop.RotationPivot.Horizontal) + assert.Equal(t, rotationpivot.Bottom, prop.RotationPivot.Vertical) + }) + t.Run("when only one axis is set, the other defaults", func(t *testing.T) { + t.Parallel() + prop := props.Text{RotationPivot: rotationpivot.Pivot{Horizontal: rotationpivot.Start}} + prop.MakeValid(&props.Font{Family: fontfamily.Arial, Size: 10, Style: fontstyle.Normal}) + assert.Equal(t, rotationpivot.Start, prop.RotationPivot.Horizontal) + assert.Equal(t, rotationpivot.Middle, prop.RotationPivot.Vertical) + }) }