diff --git a/README.md b/README.md index 675d71e1..55b23354 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/assets/examples/richtextgrid/v2/main.go b/docs/assets/examples/richtextgrid/v2/main.go new file mode 100644 index 00000000..3f45dc40 --- /dev/null +++ b/docs/assets/examples/richtextgrid/v2/main.go @@ -0,0 +1,148 @@ +package main + +import ( + "log" + + "github.com/johnfercher/maroto/v2" + "github.com/johnfercher/maroto/v2/pkg/components/richtext" + "github.com/johnfercher/maroto/v2/pkg/components/text" + "github.com/johnfercher/maroto/v2/pkg/config" + "github.com/johnfercher/maroto/v2/pkg/consts/align" + "github.com/johnfercher/maroto/v2/pkg/consts/fontstyle" + "github.com/johnfercher/maroto/v2/pkg/core" + "github.com/johnfercher/maroto/v2/pkg/props" +) + +func main() { + m := GetMaroto() + document, err := m.Generate() + if err != nil { + log.Fatal(err.Error()) + } + + err = document.Save("docs/assets/pdf/richtextgridv2.pdf") + if err != nil { + log.Fatal(err.Error()) + } + + err = document.GetReport().Save("docs/assets/text/richtextgridv2.txt") + if err != nil { + log.Fatal(err.Error()) + } +} + +func GetMaroto() core.Maroto { + cfg := config.NewBuilder(). + WithDebug(true). + Build() + + mrt := maroto.New(cfg) + m := maroto.NewMetricsDecorator(mrt) + + m.AddRows(text.NewRow(10, "Bold word in a sentence", props.Text{ + Top: 3, + Left: 2, + Bottom: 2, + Style: fontstyle.Bold, + Size: 9, + })) + m.AddAutoRow( + richtext.NewCol(12, + richtext.NewChunk("This sentence has a ", props.Text{Top: 2, Left: 2, Bottom: 2}), + richtext.NewChunk("bold", props.Text{Style: fontstyle.Bold}), + richtext.NewChunk(" word in the middle."), + ), + ) + + m.AddRows(text.NewRow(10, "Mixed styles and colors", props.Text{ + Top: 3, + Left: 2, + Bottom: 2, + Style: fontstyle.Bold, + Size: 9, + })) + m.AddAutoRow( + richtext.NewCol(12, + richtext.NewChunk("Normal, ", props.Text{Top: 2, Left: 2, Bottom: 2}), + richtext.NewChunk("bold, ", props.Text{Style: fontstyle.Bold}), + richtext.NewChunk("italic, ", props.Text{Style: fontstyle.Italic}), + richtext.NewChunk("red", props.Text{Style: fontstyle.Bold, Color: &props.RedColor}), + richtext.NewChunk(", "), + richtext.NewChunk("green", props.Text{Style: fontstyle.Bold, Color: &props.GreenColor}), + richtext.NewChunk(", and "), + richtext.NewChunk("blue", props.Text{Style: fontstyle.Bold, Color: &props.BlueColor}), + richtext.NewChunk(" in one flowing paragraph."), + ), + ) + + m.AddRows(text.NewRow(10, "Mixed font sizes", props.Text{ + Top: 3, + Left: 2, + Bottom: 2, + Style: fontstyle.Bold, + Size: 9, + })) + m.AddAutoRow( + richtext.NewCol(12, + richtext.NewChunk("Small ", props.Text{Top: 2, Left: 2, Bottom: 2, Size: 8}), + richtext.NewChunk("Medium ", props.Text{Size: 12}), + richtext.NewChunk("Large ", props.Text{Size: 16, Style: fontstyle.Bold}), + richtext.NewChunk("back to small.", props.Text{Size: 8}), + ), + ) + + m.AddRows(text.NewRow(10, "Word wrapping across styles", props.Text{ + Top: 3, + Left: 2, + Bottom: 2, + Style: fontstyle.Bold, + Size: 9, + })) + m.AddAutoRow( + richtext.NewCol(12, + richtext.NewChunk("This is a longer paragraph that demonstrates how ", props.Text{ + Top: 2, + Left: 2, + Right: 2, + }), + richtext.NewChunk("rich text", props.Text{Style: fontstyle.Bold}), + richtext.NewChunk(" handles "), + richtext.NewChunk("word wrapping", props.Text{Style: fontstyle.Italic}), + richtext.NewChunk(" across multiple lines while "), + richtext.NewChunk("preserving each chunk style", props.Text{Style: fontstyle.Bold, Color: &props.RedColor}), + richtext.NewChunk(" inside the same paragraph."), + ), + ) + + m.AddRows(text.NewRow(10, "Alignment and line breaks", props.Text{ + Top: 3, + Left: 2, + Bottom: 2, + Style: fontstyle.Bold, + Size: 9, + })) + m.AddAutoRow( + richtext.NewCol(6, + richtext.NewChunk("Centered rich text\nwith explicit line breaks", props.Text{ + Top: 2, + Left: 2, + Right: 2, + Align: align.Center, + PreserveLineBreaks: true, + }), + richtext.NewChunk(" and "), + richtext.NewChunk("bold emphasis", props.Text{Style: fontstyle.Bold}), + richtext.NewChunk("."), + ), + richtext.NewCol(6, + richtext.NewChunk("Justified text keeps the paragraph flowing while distributing the available width between words.", props.Text{ + Top: 2, + Left: 2, + Right: 2, + Align: align.Justify, + }), + ), + ) + + return m +} diff --git a/docs/assets/examples/richtextgrid/v2/main_test.go b/docs/assets/examples/richtextgrid/v2/main_test.go new file mode 100644 index 00000000..1ab877c5 --- /dev/null +++ b/docs/assets/examples/richtextgrid/v2/main_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "testing" + + "github.com/johnfercher/maroto/v2/pkg/test" +) + +func TestGetMaroto(t *testing.T) { + t.Parallel() + // Act + sut := GetMaroto() + + // Assert + test.New(t).Assert(sut.GetStructure()).Equals("examples/richtextgrid.json") +} diff --git a/docs/assets/examples/textgrid/v2/main.go b/docs/assets/examples/textgrid/v2/main.go index b723e3be..a106c39d 100644 --- a/docs/assets/examples/textgrid/v2/main.go +++ b/docs/assets/examples/textgrid/v2/main.go @@ -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" @@ -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}), @@ -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 } diff --git a/docs/assets/pdf/richtextgridv2.pdf b/docs/assets/pdf/richtextgridv2.pdf new file mode 100755 index 00000000..35388bfa Binary files /dev/null and b/docs/assets/pdf/richtextgridv2.pdf differ diff --git a/docs/assets/pdf/textgridv2.pdf b/docs/assets/pdf/textgridv2.pdf index f4667b7e..fb1cd895 100644 Binary files a/docs/assets/pdf/textgridv2.pdf and b/docs/assets/pdf/textgridv2.pdf differ diff --git a/docs/assets/text/richtextgridv2.txt b/docs/assets/text/richtextgridv2.txt new file mode 100644 index 00000000..eabf7486 --- /dev/null +++ b/docs/assets/text/richtextgridv2.txt @@ -0,0 +1,3 @@ +generate -> avg: 14.22ms, executions: [14.22ms] +add_rows -> avg: 3716.60ns, executions: [17.92μs, 0.21μs, 0.21μs, 0.08μs, 0.17μs] +file_size -> 51.02Kb diff --git a/docs/assets/text/textgridv2.txt b/docs/assets/text/textgridv2.txt index 908ff4e5..b5f44f3d 100644 --- a/docs/assets/text/textgridv2.txt +++ b/docs/assets/text/textgridv2.txt @@ -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 diff --git a/docs/v2/features/_sidebar.md b/docs/v2/features/_sidebar.md index b8bd271b..40f86feb 100644 --- a/docs/v2/features/_sidebar.md +++ b/docs/v2/features/_sidebar.md @@ -28,6 +28,7 @@ * [Parallelism Mode](v2/features/parallelism.md?id=parallelism) * [Protection](v2/features/protection.md?id=protection) * [QR Code](v2/features/qrcode.md?id=qrcode) + * [Rich Text](v2/features/richtext.md?id=rich-text) * [Signature](v2/features/signature.md?id=signature) * [Text](v2/features/text.md?id=text) * [Unit Testing](v2/features/unittests.md?id=unit-testing) diff --git a/docs/v2/features/richtext.md b/docs/v2/features/richtext.md new file mode 100644 index 00000000..6d55fe21 --- /dev/null +++ b/docs/v2/features/richtext.md @@ -0,0 +1,27 @@ +# Rich Text + +The Rich Text component renders multiple styled chunks inside a single flowing paragraph. It is useful when one sentence needs partial emphasis such as bold words, mixed colors, italic fragments, or different font sizes without splitting the content across multiple columns. + +`richtext` keeps wrapping behavior across chunk boundaries, so the paragraph still behaves like one text block instead of several disconnected components. + +## GoDoc +* [constructor : New](https://pkg.go.dev/github.com/johnfercher/maroto/v2/pkg/components/richtext#New) +* [constructor : NewCol](https://pkg.go.dev/github.com/johnfercher/maroto/v2/pkg/components/richtext#NewCol) +* [constructor : NewRow](https://pkg.go.dev/github.com/johnfercher/maroto/v2/pkg/components/richtext#NewRow) +* [constructor : NewAutoRow](https://pkg.go.dev/github.com/johnfercher/maroto/v2/pkg/components/richtext#NewAutoRow) +* [constructor : NewChunk](https://pkg.go.dev/github.com/johnfercher/maroto/v2/pkg/components/richtext#NewChunk) +* [type : Chunk](https://pkg.go.dev/github.com/johnfercher/maroto/v2/pkg/components/richtext#Chunk) + +## Code Example +[filename](../../assets/examples/richtextgrid/v2/main.go ':include :type=code') + +## PDF Generated +```pdf + assets/pdf/richtextgridv2.pdf +``` + +## Time Execution +[filename](../../assets/text/richtextgridv2.txt ':include :type=code') + +## Test File +[filename](https://raw.githubusercontent.com/johnfercher/maroto/master/test/maroto/examples/richtextgrid.json ':include :type=code') diff --git a/docs/v2/features/text.md b/docs/v2/features/text.md index 828221e0..9e03c202 100644 --- a/docs/v2/features/text.md +++ b/docs/v2/features/text.md @@ -17,8 +17,9 @@ 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 @@ -26,6 +27,8 @@ 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. +- 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 diff --git a/go.mod b/go.mod index d80858b2..418f1772 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/f-amaral/go-async v0.3.0 github.com/google/uuid v1.6.0 github.com/johnfercher/go-tree v1.1.0 - github.com/pdfcpu/pdfcpu v0.11.1 + github.com/pdfcpu/pdfcpu v0.12.0 github.com/phpdave11/gofpdf v1.4.3 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 @@ -17,14 +17,14 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/hhrutter/lzw v1.0.0 // indirect - github.com/hhrutter/pkcs7 v0.2.0 // indirect - github.com/hhrutter/tiff v1.0.2 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/hhrutter/pkcs7 v0.2.2 // indirect + github.com/hhrutter/tiff v1.0.3 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.5.3 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/image v0.38.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/image v0.39.0 // indirect + golang.org/x/text v0.36.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index c4e23ffe..d1984c88 100644 --- a/go.sum +++ b/go.sum @@ -12,17 +12,17 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= -github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I= -github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= -github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8= -github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw= +github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8= +github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0= +github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA= github.com/johnfercher/go-tree v1.1.0 h1:L0Fs5jLR1uA2e/CwfHjNdO/Lt4IGQ46QgxarAC1yeXs= github.com/johnfercher/go-tree v1.1.0/go.mod h1:DUO6QkXIFh1K7jeGBIkLCZaeUgnkdQAsB64FDSoHswg= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= -github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas= -github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/pdfcpu/pdfcpu v0.12.0 h1:GonU1Ub45kKo/LdakJhaBA0NTTvBA7KGs3bfmEU1osU= +github.com/pdfcpu/pdfcpu v0.12.0/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps= github.com/phpdave11/gofpdf v1.4.3 h1:M/zHvS8FO3zh9tUd2RCOPEjyuVcs281FCyF22Qlz/IA= github.com/phpdave11/gofpdf v1.4.3/go.mod h1:MAwzoUIgD3J55u0rxIG2eu37c+XWhBtXSpPAhnQXf/o= github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= @@ -37,14 +37,14 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= -golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/internal/providers/gofpdf/provider.go b/internal/providers/gofpdf/provider.go index 720ee890..a7ec218f 100644 --- a/internal/providers/gofpdf/provider.go +++ b/internal/providers/gofpdf/provider.go @@ -61,6 +61,11 @@ func (g *provider) GetFontHeight(prop *props.Font) float64 { return g.font.GetHeight(prop.Family, prop.Style, prop.Size) } +func (g *provider) GetStringWidth(text string, prop *props.Font) float64 { + g.font.SetFont(prop.Family, prop.Style, prop.Size) + return g.fpdf.GetStringWidth(text) +} + func (g *provider) AddLine(cell *entity.Cell, prop *props.Line) { g.line.Add(cell, prop) } diff --git a/internal/providers/gofpdf/provider_test.go b/internal/providers/gofpdf/provider_test.go index 7bfd3e7f..99db07a2 100644 --- a/internal/providers/gofpdf/provider_test.go +++ b/internal/providers/gofpdf/provider_test.go @@ -81,6 +81,34 @@ func TestProvider_GetTextHeight(t *testing.T) { assert.Equal(t, fontHeightToReturn, fontHeight) } +func TestProvider_GetStringWidth(t *testing.T) { + t.Parallel() + // Arrange + textWidthToReturn := 10.0 + text := "hello" + prop := fixture.FontProp() + + font := mocks.NewFont(t) + font.EXPECT().SetFont(prop.Family, prop.Style, prop.Size) + + fpdf := mocks.NewFpdf(t) + fpdf.EXPECT().GetStringWidth(text).Return(textWidthToReturn) + + dep := &gofpdf.Dependencies{ + Fpdf: fpdf, + Font: font, + } + sut := gofpdf.New(dep) + + // Act + textWidth := sut.GetStringWidth(text, &prop) + + // Assert + font.AssertNumberOfCalls(t, "SetFont", 1) + fpdf.AssertNumberOfCalls(t, "GetStringWidth", 1) + assert.Equal(t, textWidthToReturn, textWidth) +} + func TestProvider_AddLine(t *testing.T) { t.Parallel() // Arrange diff --git a/internal/providers/gofpdf/text.go b/internal/providers/gofpdf/text.go index bc1d6e7d..d64a61d2 100644 --- a/internal/providers/gofpdf/text.go +++ b/internal/providers/gofpdf/text.go @@ -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{ @@ -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 } @@ -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 { @@ -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 @@ -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 +} + func (s *Text) addLine(textProp *props.Text, xColOffset, colWidth, yColOffset, textWidth float64, text string) { left, top, _, _ := s.pdf.GetMargins() @@ -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", " ") +} diff --git a/internal/providers/gofpdf/text_test.go b/internal/providers/gofpdf/text_test.go index fbd23df3..bb67df49 100644 --- a/internal/providers/gofpdf/text_test.go +++ b/internal/providers/gofpdf/text_test.go @@ -36,8 +36,10 @@ func TestGetLinesHeight(t *testing.T) { pdf := mocks.NewFpdf(t) pdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string { return s }) + pdf.EXPECT().GetStringWidth("text text text text").Return(22.0) pdf.EXPECT().GetStringWidth("text").Return(5.0) // First token just returns text pdf.EXPECT().GetStringWidth(" text").Return(6.0) // subsequent tokens return leading space + pdf.EXPECT().GetStringWidth("text text").Return(11.0) text := gofpdf.NewText(pdf, mocks.NewMath(t), font) @@ -55,9 +57,12 @@ func TestGetLinesHeight(t *testing.T) { font.EXPECT().SetFont(textProp.Family, textProp.Style, textProp.Size) pdf := mocks.NewFpdf(t) + pdf.EXPECT().GetStringWidth("tttt tttt tttt tttt").Return(12) pdf.EXPECT().GetStringWidth("t").Return(1) pdf.EXPECT().GetStringWidth(" ").Return(1) pdf.EXPECT().GetStringWidth(" - ").Return(1) + pdf.EXPECT().GetStringWidth("tttt tttt -").Return(10) + pdf.EXPECT().GetStringWidth("tttt tttt").Return(9) pdf.EXPECT().UnicodeTranslatorFromDescriptor("").Return(func(s string) string { return s }) text := gofpdf.NewText(pdf, mocks.NewMath(t), font) @@ -66,6 +71,48 @@ 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("aaaa").Return(12.0) + pdf.EXPECT().GetStringWidth("a").Return(3.0).Times(4) + pdf.EXPECT().GetStringWidth("aa").Return(6.0).Twice() + + text := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + height := text.GetLinesQuantity("ääää", textProp, 6) + + assert.Equal(t, 2, height) + }) + + t.Run("when a text contains explicit empty lines, should count each paragraph break", func(t *testing.T) { + t.Parallel() + textProp := &props.Text{PreserveLineBreaks: true} + 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(s string) string { return s }) + pdf.EXPECT().GetStringWidth("line one").Return(20.0) + pdf.EXPECT().GetStringWidth("").Return(0.0) + pdf.EXPECT().GetStringWidth("line two").Return(20.0) + + text := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + height := text.GetLinesQuantity("line one\n\nline two", textProp, 100) + + assert.Equal(t, 3, height) + }) } func TestText_Add(t *testing.T) { @@ -354,6 +401,70 @@ 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 text contains explicit empty lines, should preserve paragraph gaps", func(t *testing.T) { + t.Parallel() + // Arrange + cell := &entity.Cell{X: 0, Y: 0, Width: 100, 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, + PreserveLineBreaks: true, + } + + 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("first").Return(20.0) + pdf.EXPECT().GetStringWidth("").Return(0.0) + pdf.EXPECT().GetStringWidth("third").Return(20.0) + pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0) + pdf.EXPECT().Text(0.0, 5.0, "first") + pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0) + pdf.EXPECT().Text(0.0, 10.0, "") + pdf.EXPECT().GetMargins().Return(0.0, 0.0, 0.0, 0.0) + pdf.EXPECT().Text(0.0, 15.0, "third") + + sut := gofpdf.NewText(pdf, mocks.NewMath(t), font) + + // Act + sut.Add("first\n\nthird", cell, textProp) + }) t.Run("when top exceeds cell height, should clamp top to cell height", func(t *testing.T) { t.Parallel() // Arrange diff --git a/mocks/Provider.go b/mocks/Provider.go index d5702e35..21a4d591 100644 --- a/mocks/Provider.go +++ b/mocks/Provider.go @@ -745,6 +745,53 @@ func (_c *Provider_GetFontHeight_Call) RunAndReturn(run func(*props.Font) float6 return _c } +// GetStringWidth provides a mock function with given fields: text, prop +func (_m *Provider) GetStringWidth(text string, prop *props.Font) float64 { + ret := _m.Called(text, prop) + + if len(ret) == 0 { + panic("no return value specified for GetStringWidth") + } + + var r0 float64 + if rf, ok := ret.Get(0).(func(string, *props.Font) float64); ok { + r0 = rf(text, prop) + } 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 +// - prop *props.Font +func (_e *Provider_Expecter) GetStringWidth(text interface{}, prop interface{}) *Provider_GetStringWidth_Call { + return &Provider_GetStringWidth_Call{Call: _e.mock.On("GetStringWidth", text, prop)} +} + +func (_c *Provider_GetStringWidth_Call) Run(run func(text string, prop *props.Font)) *Provider_GetStringWidth_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(*props.Font)) + }) + 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.Font) float64) *Provider_GetStringWidth_Call { + _c.Call.Return(run) + return _c +} + // GetLinesQuantity provides a mock function with given fields: text, textProp, colWidth func (_m *Provider) GetLinesQuantity(text string, textProp *props.Text, colWidth float64) int { ret := _m.Called(text, textProp, colWidth) diff --git a/pkg/components/richtext/example_test.go b/pkg/components/richtext/example_test.go new file mode 100644 index 00000000..35bd3907 --- /dev/null +++ b/pkg/components/richtext/example_test.go @@ -0,0 +1,52 @@ +package richtext_test + +import ( + "github.com/johnfercher/maroto/v2" + "github.com/johnfercher/maroto/v2/pkg/components/col" + "github.com/johnfercher/maroto/v2/pkg/components/richtext" + "github.com/johnfercher/maroto/v2/pkg/consts/fontstyle" + "github.com/johnfercher/maroto/v2/pkg/props" +) + +// ExampleNew demonstrates how to create a richtext component. +func ExampleNew() { + m := maroto.New() + + rt := richtext.New( + richtext.NewChunk("This is "), + richtext.NewChunk("bold", props.Text{Style: fontstyle.Bold}), + richtext.NewChunk(" text."), + ) + c := col.New(12).Add(rt) + m.AddRow(10, c) + + // generate document +} + +// ExampleNewCol demonstrates how to create a richtext component wrapped into a column. +func ExampleNewCol() { + m := maroto.New() + + rtCol := richtext.NewCol( + 12, + richtext.NewChunk("Price: "), + richtext.NewChunk("$99.99", props.Text{Style: fontstyle.Bold, Color: &props.RedColor}), + ) + m.AddRow(10, rtCol) + + // generate document +} + +// ExampleNewAutoRow demonstrates how to create a richtext component with automatic row height. +func ExampleNewAutoRow() { + m := maroto.New() + + rtRow := richtext.NewAutoRow( + richtext.NewChunk("This paragraph has "), + richtext.NewChunk("mixed styles", props.Text{Style: fontstyle.Bold}), + richtext.NewChunk(" and wraps as one flow."), + ) + m.AddRows(rtRow) + + // generate document +} diff --git a/pkg/components/richtext/richtext.go b/pkg/components/richtext/richtext.go new file mode 100644 index 00000000..317e7e26 --- /dev/null +++ b/pkg/components/richtext/richtext.go @@ -0,0 +1,479 @@ +// Package richtext implements creation of text with mixed styles inside one flowing paragraph. +package richtext + +import ( + "fmt" + "strings" + "unicode" + + "github.com/johnfercher/go-tree/node" + + "github.com/johnfercher/maroto/v2/pkg/components/col" + "github.com/johnfercher/maroto/v2/pkg/components/row" + "github.com/johnfercher/maroto/v2/pkg/consts/align" + "github.com/johnfercher/maroto/v2/pkg/core" + "github.com/johnfercher/maroto/v2/pkg/core/entity" + "github.com/johnfercher/maroto/v2/pkg/props" +) + +// Chunk represents a styled fragment inside a rich text paragraph. +type Chunk struct { + Text string + Style props.Text +} + +// NewChunk creates a new Chunk with the given text and optional style. +func NewChunk(text string, ps ...props.Text) Chunk { + style := props.Text{} + if len(ps) > 0 { + style = ps[0] + } + + return Chunk{ + Text: text, + Style: style, + } +} + +// RichText renders multiple chunks with different styles as a single flowing paragraph. +type RichText struct { + chunks []Chunk + config *entity.Config +} + +// New creates a RichText component from the given chunks. +func New(chunks ...Chunk) core.Component { + return &RichText{ + chunks: chunks, + } +} + +// NewCol creates a RichText wrapped in a column. +func NewCol(size int, chunks ...Chunk) core.Col { + rt := New(chunks...) + return col.New(size).Add(rt) +} + +// NewAutoRow creates a RichText wrapped in an automatic-height row. +func NewAutoRow(chunks ...Chunk) core.Row { + rt := New(chunks...) + c := col.New().Add(rt) + return row.New().Add(c) +} + +// NewRow creates a RichText wrapped in a fixed-height row. +func NewRow(height float64, chunks ...Chunk) core.Row { + rt := New(chunks...) + c := col.New().Add(rt) + return row.New(height).Add(c) +} + +// SetConfig sets the component configuration. +func (r *RichText) SetConfig(config *entity.Config) { + r.config = config + for i := range r.chunks { + r.chunks[i].Style.MakeValid(r.config.DefaultFont) + } +} + +// GetStructure returns the tree structure of the component. +func (r *RichText) GetStructure() *node.Node[core.Structure] { + details := make(map[string]any) + for i, chunk := range r.chunks { + details[chunkKey(i, "text")] = chunk.Text + for k, v := range chunk.Style.ToMap() { + details[chunkKey(i, k)] = v + } + } + + return node.New(core.Structure{ + Type: "richtext", + Value: len(r.chunks), + Details: details, + }) +} + +// GetHeight returns the height that the rich text will occupy in the PDF. +func (r *RichText) GetHeight(provider core.Provider, cell *entity.Cell) float64 { + if len(r.chunks) == 0 { + return 0 + } + + layoutStyle := r.layoutStyle() + defaultLineHeight := provider.GetFontHeight(fontPropFromText(layoutStyle)) + lines := r.layoutLines( + provider, + max(cell.Width-layoutStyle.Left-layoutStyle.Right, 0), + defaultLineHeight, + ) + if len(lines) == 0 { + return layoutStyle.Top + layoutStyle.Bottom + } + + total := layoutStyle.Top + layoutStyle.Bottom + for i, line := range lines { + total += line.height + if i < len(lines)-1 { + total += layoutStyle.VerticalPadding + } + } + + return total +} + +// Render renders the rich text into a PDF context. +func (r *RichText) Render(provider core.Provider, cell *entity.Cell) { + if len(r.chunks) == 0 { + return + } + + layoutStyle := r.layoutStyle() + colWidth := max(cell.Width-layoutStyle.Left-layoutStyle.Right, 0) + defaultLineHeight := provider.GetFontHeight(fontPropFromText(layoutStyle)) + lines := r.layoutLines(provider, colWidth, defaultLineHeight) + + xBase := cell.X + layoutStyle.Left + yBase := cell.Y + layoutStyle.Top + currentTop := yBase + + for i, line := range lines { + x := xBase + lineOffsetX(line.width, colWidth, layoutStyle.Align) + extraSpace := 0.0 + if layoutStyle.Align == align.Justify && i < len(lines)-1 && line.spaceCount > 0 && line.width < colWidth { + extraSpace = (colWidth - line.width) / float64(line.spaceCount) + } + + for _, part := range line.parts { + if part.kind == richTextSpacePart { + x += part.width + extraSpace + continue + } + + prop := *part.style + prop.Align = align.Left + prop.Top = 0 + prop.Bottom = 0 + prop.Left = 0 + prop.Right = 0 + prop.VerticalPadding = 0 + + segmentHeight := provider.GetFontHeight(fontPropFromText(&prop)) + segmentCell := &entity.Cell{ + X: x, + Y: currentTop + line.height - segmentHeight, + Width: part.width, + Height: line.height, + } + + provider.AddText(part.text, segmentCell, &prop) + x += part.width + } + + currentTop += line.height + if i < len(lines)-1 { + currentTop += layoutStyle.VerticalPadding + } + } +} + +type richTextTokenKind int + +const ( + richTextTextToken richTextTokenKind = iota + richTextSpaceToken + richTextNewlineToken +) + +type richTextToken struct { + kind richTextTokenKind + text string + style *props.Text +} + +type richTextElementKind int + +const ( + richTextClusterElement richTextElementKind = iota + richTextSpaceElement + richTextNewlineElement +) + +type richTextSegment struct { + text string + style *props.Text +} + +type richTextElement struct { + kind richTextElementKind + segments []richTextSegment + text string + style *props.Text +} + +type richTextLinePartKind int + +const ( + richTextTextPart richTextLinePartKind = iota + richTextSpacePart +) + +type richTextLinePart struct { + kind richTextLinePartKind + text string + style *props.Text + width float64 +} + +type richTextLine struct { + parts []richTextLinePart + width float64 + height float64 + spaceCount int +} + +func (r *RichText) layoutStyle() *props.Text { + return &r.chunks[0].Style +} + +func chunkKey(index int, key string) string { + return fmt.Sprintf("chunk_%d_%s", index, key) +} + +func fontPropFromText(t *props.Text) *props.Font { + return &props.Font{ + Family: t.Family, + Style: t.Style, + Size: t.Size, + Color: t.Color, + } +} + +func measureText(provider core.Provider, text string, style *props.Text) float64 { + return provider.GetStringWidth(text, fontPropFromText(style)) +} + +func normalizeChunkText(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", " ") +} + +func tokenizeChunk(chunk *Chunk, preserve bool) []richTextToken { + text := normalizeChunkText(chunk.Text, preserve) + if text == "" { + return nil + } + + tokens := make([]richTextToken, 0) + var current strings.Builder + currentKind := richTextTextToken + hasCurrent := false + + flush := func() { + if !hasCurrent || current.Len() == 0 { + return + } + tokens = append(tokens, richTextToken{ + kind: currentKind, + text: current.String(), + style: &chunk.Style, + }) + current.Reset() + hasCurrent = false + } + + for _, r := range text { + switch { + case r == '\n': + flush() + tokens = append(tokens, richTextToken{ + kind: richTextNewlineToken, + text: "\n", + style: &chunk.Style, + }) + case unicode.IsSpace(r): + if !hasCurrent || currentKind != richTextSpaceToken { + flush() + currentKind = richTextSpaceToken + hasCurrent = true + } + current.WriteRune(' ') + default: + if !hasCurrent || currentKind != richTextTextToken { + flush() + currentKind = richTextTextToken + hasCurrent = true + } + current.WriteRune(r) + } + } + + flush() + return tokens +} + +func (r *RichText) elements() []richTextElement { + if len(r.chunks) == 0 { + return nil + } + + preserve := r.layoutStyle().PreserveLineBreaks + elements := make([]richTextElement, 0) + cluster := make([]richTextSegment, 0) + + flushCluster := func() { + if len(cluster) == 0 { + return + } + copied := make([]richTextSegment, len(cluster)) + copy(copied, cluster) + elements = append(elements, richTextElement{ + kind: richTextClusterElement, + segments: copied, + }) + cluster = cluster[:0] + } + + for i := range r.chunks { + chunk := &r.chunks[i] + for _, token := range tokenizeChunk(chunk, preserve) { + switch token.kind { + case richTextTextToken: + cluster = append(cluster, richTextSegment{ + text: token.text, + style: token.style, + }) + case richTextSpaceToken: + flushCluster() + elements = append(elements, richTextElement{ + kind: richTextSpaceElement, + text: token.text, + style: token.style, + }) + case richTextNewlineToken: + flushCluster() + elements = append(elements, richTextElement{ + kind: richTextNewlineElement, + }) + } + } + } + + flushCluster() + return elements +} + +func finalizeRichTextLine(line richTextLine, defaultHeight float64) richTextLine { + for len(line.parts) > 0 && line.parts[len(line.parts)-1].kind == richTextSpacePart { + line.width -= line.parts[len(line.parts)-1].width + line.spaceCount-- + line.parts = line.parts[:len(line.parts)-1] + } + + if line.height == 0 { + line.height = defaultHeight + } + + return line +} + +func (r *RichText) layoutLines(provider core.Provider, colWidth, defaultHeight float64) []richTextLine { + elements := r.elements() + if len(elements) == 0 { + return nil + } + + lines := make([]richTextLine, 0) + current := richTextLine{} + + flush := func(forceEmpty bool) { + if len(current.parts) == 0 { + if forceEmpty { + lines = append(lines, richTextLine{height: defaultHeight}) + } + return + } + + lines = append(lines, finalizeRichTextLine(current, defaultHeight)) + current = richTextLine{} + } + + for _, element := range elements { + switch element.kind { + case richTextNewlineElement: + flush(true) + current = richTextLine{} + case richTextSpaceElement: + if len(current.parts) == 0 { + continue + } + + width := measureText(provider, element.text, element.style) + if colWidth > 0 && current.width+width > colWidth { + flush(false) + current = richTextLine{} + continue + } + + current.parts = append(current.parts, richTextLinePart{ + kind: richTextSpacePart, + text: element.text, + style: element.style, + width: width, + }) + current.width += width + current.spaceCount++ + case richTextClusterElement: + clusterParts := make([]richTextLinePart, 0, len(element.segments)) + clusterWidth := 0.0 + clusterHeight := 0.0 + + for _, segment := range element.segments { + width := measureText(provider, segment.text, segment.style) + height := provider.GetFontHeight(fontPropFromText(segment.style)) + + clusterParts = append(clusterParts, richTextLinePart{ + kind: richTextTextPart, + text: segment.text, + style: segment.style, + width: width, + }) + clusterWidth += width + clusterHeight = max(clusterHeight, height) + } + + if len(current.parts) > 0 && colWidth > 0 && current.width+clusterWidth > colWidth { + flush(false) + current = richTextLine{} + } + + current.parts = append(current.parts, clusterParts...) + current.width += clusterWidth + current.height = max(current.height, clusterHeight) + } + } + + if len(current.parts) > 0 { + lines = append(lines, finalizeRichTextLine(current, defaultHeight)) + } + + return lines +} + +func lineOffsetX(lineWidth, colWidth float64, lineAlign align.Type) float64 { + if colWidth <= lineWidth { + return 0 + } + + switch lineAlign { + case align.Center: + return (colWidth - lineWidth) / 2 + case align.Right: + return colWidth - lineWidth + default: + return 0 + } +} diff --git a/pkg/components/richtext/richtext_test.go b/pkg/components/richtext/richtext_test.go new file mode 100644 index 00000000..e8aa1790 --- /dev/null +++ b/pkg/components/richtext/richtext_test.go @@ -0,0 +1,182 @@ +package richtext_test + +import ( + "testing" + + "github.com/johnfercher/maroto/v2/mocks" + "github.com/johnfercher/maroto/v2/pkg/components/richtext" + "github.com/johnfercher/maroto/v2/pkg/consts/align" + "github.com/johnfercher/maroto/v2/pkg/consts/fontstyle" + "github.com/johnfercher/maroto/v2/pkg/core/entity" + "github.com/johnfercher/maroto/v2/pkg/props" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestRichText_GetStructure(t *testing.T) { + t.Parallel() + // Arrange + sut := richtext.New( + richtext.NewChunk("Hello ", props.Text{Top: 2, Left: 1}), + richtext.NewChunk("World", props.Text{Style: fontstyle.Bold}), + ) + + // Act + tree := sut.GetStructure() + data := tree.GetData() + + // Assert + assert.Equal(t, "richtext", data.Type) + assert.Equal(t, 2, data.Value) + assert.Equal(t, "Hello ", data.Details["chunk_0_text"]) + assert.Equal(t, "World", data.Details["chunk_1_text"]) + assert.Equal(t, fontstyle.Bold, data.Details["chunk_1_prop_font_style"]) +} + +func TestRichText_GetHeight(t *testing.T) { + t.Parallel() + // Arrange + font := &props.Font{Family: "arial", Style: fontstyle.Normal, Size: 10} + sut := richtext.New( + richtext.NewChunk("first\nsecond", props.Text{ + Top: 2, + Bottom: 3, + VerticalPadding: 1, + PreserveLineBreaks: true, + }), + ) + sut.SetConfig(&entity.Config{DefaultFont: font}) + + cell := &entity.Cell{Width: 100} + + provider := mocks.NewProvider(t) + provider.EXPECT().GetFontHeight(mock.Anything).RunAndReturn(func(_ *props.Font) float64 { + return 5.0 + }) + provider.EXPECT().GetStringWidth(mock.Anything, mock.Anything).RunAndReturn(func(text string, _ *props.Font) float64 { + switch text { + case "first": + return 10.0 + case "second": + return 12.0 + default: + return 0.0 + } + }) + + // Act + height := sut.GetHeight(provider, cell) + + // Assert + assert.Equal(t, 16.0, height) +} + +func TestRichText_RenderPreservesChunkSpacing(t *testing.T) { + t.Parallel() + // Arrange + font := &props.Font{Family: "arial", Style: fontstyle.Normal, Size: 10} + sut := richtext.New( + richtext.NewChunk("red"), + richtext.NewChunk(", "), + richtext.NewChunk("blue", props.Text{Style: fontstyle.Bold}), + ) + sut.SetConfig(&entity.Config{DefaultFont: font}) + + cell := &entity.Cell{X: 0, Y: 0, Width: 100} + + provider := mocks.NewProvider(t) + provider.EXPECT().GetFontHeight(mock.Anything).RunAndReturn(func(_ *props.Font) float64 { + return 5.0 + }) + provider.EXPECT().GetStringWidth(mock.Anything, mock.Anything).RunAndReturn(func(text string, _ *props.Font) float64 { + switch text { + case "red": + return 10.0 + case ",": + return 2.0 + case " ": + return 3.0 + case "blue": + return 12.0 + default: + return 0.0 + } + }) + + var rendered []struct { + text string + x float64 + y float64 + } + + provider.EXPECT().AddText( + mock.Anything, + mock.Anything, + mock.Anything, + ).Run(func(text string, cell *entity.Cell, prop *props.Text) { + rendered = append(rendered, struct { + text string + x float64 + y float64 + }{ + text: text, + x: cell.X, + y: cell.Y, + }) + }).Return().Times(3) + + // Act + sut.Render(provider, cell) + + // Assert + assert.Equal(t, []struct { + text string + x float64 + y float64 + }{ + {text: "red", x: 0, y: 0}, + {text: ",", x: 10, y: 0}, + {text: "blue", x: 15, y: 0}, + }, rendered) +} + +func TestRichText_RenderRespectsCenterAlign(t *testing.T) { + t.Parallel() + // Arrange + font := &props.Font{Family: "arial", Style: fontstyle.Normal, Size: 10} + sut := richtext.New( + richtext.NewChunk("ab", props.Text{Align: align.Center}), + richtext.NewChunk("cd"), + ) + sut.SetConfig(&entity.Config{DefaultFont: font}) + + cell := &entity.Cell{X: 0, Y: 0, Width: 40} + + provider := mocks.NewProvider(t) + provider.EXPECT().GetFontHeight(mock.Anything).RunAndReturn(func(_ *props.Font) float64 { + return 5.0 + }) + provider.EXPECT().GetStringWidth(mock.Anything, mock.Anything).RunAndReturn(func(text string, _ *props.Font) float64 { + switch text { + case "ab", "cd": + return 10.0 + default: + return 0.0 + } + }) + + var xs []float64 + provider.EXPECT().AddText( + mock.Anything, + mock.Anything, + mock.Anything, + ).Run(func(_ string, cell *entity.Cell, _ *props.Text) { + xs = append(xs, cell.X) + }).Return().Times(2) + + // Act + sut.Render(provider, cell) + + // Assert + assert.Equal(t, []float64{10, 20}, xs) +} diff --git a/pkg/components/text/example_test.go b/pkg/components/text/example_test.go index fc906a9e..3bd6f8e8 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/breakline" + "github.com/johnfercher/maroto/v2/pkg/props" ) // ExampleNew demonstrates how to create a text component. @@ -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() diff --git a/pkg/consts/breakline/breakline.go b/pkg/consts/breakline/breakline.go index 12d884a1..b3617c89 100644 --- a/pkg/consts/breakline/breakline.go +++ b/pkg/consts/breakline/breakline.go @@ -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" ) diff --git a/pkg/core/provider.go b/pkg/core/provider.go index 579e491e..48f401ce 100644 --- a/pkg/core/provider.go +++ b/pkg/core/provider.go @@ -19,6 +19,7 @@ type Provider interface { AddText(text string, cell *entity.Cell, prop *props.Text) AddCheckbox(label string, cell *entity.Cell, prop *props.Checkbox) GetFontHeight(prop *props.Font) float64 + GetStringWidth(text string, prop *props.Font) float64 GetLinesQuantity(text string, textProp *props.Text, colWidth float64) int AddMatrixCode(code string, cell *entity.Cell, prop *props.Rect) AddQrCode(code string, cell *entity.Cell, rect *props.Rect) diff --git a/pkg/props/text.go b/pkg/props/text.go index a9e8b8dd..d3c2d281 100644 --- a/pkg/props/text.go +++ b/pkg/props/text.go @@ -28,6 +28,8 @@ type Text struct { BreakLineStrategy breakline.Strategy // VerticalPadding define an additional space between linet. VerticalPadding float64 + // PreserveLineBreaks keeps explicit '\n' as hard line breaks instead of collapsing them into spaces. + PreserveLineBreaks bool // Color define the font style color. Color *Color // Hyperlink define a link to be opened when the text is clicked. @@ -76,6 +78,10 @@ func (t *Text) ToMap() map[string]any { m["prop_vertical_padding"] = t.VerticalPadding } + if t.PreserveLineBreaks { + m["prop_preserve_line_breaks"] = true + } + if t.Color != nil { m["prop_color"] = t.Color.ToString() } diff --git a/test/maroto/examples/richtextgrid.json b/test/maroto/examples/richtextgrid.json new file mode 100755 index 00000000..afce7390 --- /dev/null +++ b/test/maroto/examples/richtextgrid.json @@ -0,0 +1,486 @@ +{ + "type": "maroto", + "details": { + "chunk_workers": 1, + "config_debug": true, + "config_margin_bottom": 20.0025, + "config_margin_left": 10, + "config_margin_right": 10, + "config_margin_top": 10, + "config_max_grid_sum": 12, + "config_provider_type": "gofpdf", + "generation_mode": "sequential", + "maroto_dimension_height": 297, + "maroto_dimension_width": 210, + "prop_font_color": "RGB(0, 0, 0)", + "prop_font_family": "arial", + "prop_font_size": 10 + }, + "nodes": [ + { + "type": "page", + "nodes": [ + { + "value": 10, + "type": "row", + "nodes": [ + { + "value": 0, + "type": "col", + "details": { + "is_max": true + }, + "nodes": [ + { + "value": "Bold word in a sentence", + "type": "text", + "details": { + "prop_align": "L", + "prop_bottom": 2, + "prop_breakline_strategy": "empty_space_strategy", + "prop_color": "RGB(0, 0, 0)", + "prop_font_family": "arial", + "prop_font_size": 9, + "prop_font_style": "B", + "prop_left": 2, + "prop_top": 3 + } + } + ] + } + ] + }, + { + "value": 7.527777777777779, + "type": "row", + "nodes": [ + { + "value": 12, + "type": "col", + "nodes": [ + { + "value": 3, + "type": "richtext", + "details": { + "chunk_0_prop_align": "L", + "chunk_0_prop_bottom": 2, + "chunk_0_prop_breakline_strategy": "empty_space_strategy", + "chunk_0_prop_color": "RGB(0, 0, 0)", + "chunk_0_prop_font_family": "arial", + "chunk_0_prop_font_size": 10, + "chunk_0_prop_left": 2, + "chunk_0_prop_top": 2, + "chunk_0_text": "This sentence has a ", + "chunk_1_prop_align": "L", + "chunk_1_prop_breakline_strategy": "empty_space_strategy", + "chunk_1_prop_color": "RGB(0, 0, 0)", + "chunk_1_prop_font_family": "arial", + "chunk_1_prop_font_size": 10, + "chunk_1_prop_font_style": "B", + "chunk_1_text": "bold", + "chunk_2_prop_align": "L", + "chunk_2_prop_breakline_strategy": "empty_space_strategy", + "chunk_2_prop_color": "RGB(0, 0, 0)", + "chunk_2_prop_font_family": "arial", + "chunk_2_prop_font_size": 10, + "chunk_2_text": " word in the middle." + } + } + ] + } + ] + }, + { + "value": 10, + "type": "row", + "nodes": [ + { + "value": 0, + "type": "col", + "details": { + "is_max": true + }, + "nodes": [ + { + "value": "Mixed styles and colors", + "type": "text", + "details": { + "prop_align": "L", + "prop_bottom": 2, + "prop_breakline_strategy": "empty_space_strategy", + "prop_color": "RGB(0, 0, 0)", + "prop_font_family": "arial", + "prop_font_size": 9, + "prop_font_style": "B", + "prop_left": 2, + "prop_top": 3 + } + } + ] + } + ] + }, + { + "value": 7.527777777777779, + "type": "row", + "nodes": [ + { + "value": 12, + "type": "col", + "nodes": [ + { + "value": 9, + "type": "richtext", + "details": { + "chunk_0_prop_align": "L", + "chunk_0_prop_bottom": 2, + "chunk_0_prop_breakline_strategy": "empty_space_strategy", + "chunk_0_prop_color": "RGB(0, 0, 0)", + "chunk_0_prop_font_family": "arial", + "chunk_0_prop_font_size": 10, + "chunk_0_prop_left": 2, + "chunk_0_prop_top": 2, + "chunk_0_text": "Normal, ", + "chunk_1_prop_align": "L", + "chunk_1_prop_breakline_strategy": "empty_space_strategy", + "chunk_1_prop_color": "RGB(0, 0, 0)", + "chunk_1_prop_font_family": "arial", + "chunk_1_prop_font_size": 10, + "chunk_1_prop_font_style": "B", + "chunk_1_text": "bold, ", + "chunk_2_prop_align": "L", + "chunk_2_prop_breakline_strategy": "empty_space_strategy", + "chunk_2_prop_color": "RGB(0, 0, 0)", + "chunk_2_prop_font_family": "arial", + "chunk_2_prop_font_size": 10, + "chunk_2_prop_font_style": "I", + "chunk_2_text": "italic, ", + "chunk_3_prop_align": "L", + "chunk_3_prop_breakline_strategy": "empty_space_strategy", + "chunk_3_prop_color": "RGB(255, 0, 0)", + "chunk_3_prop_font_family": "arial", + "chunk_3_prop_font_size": 10, + "chunk_3_prop_font_style": "B", + "chunk_3_text": "red", + "chunk_4_prop_align": "L", + "chunk_4_prop_breakline_strategy": "empty_space_strategy", + "chunk_4_prop_color": "RGB(0, 0, 0)", + "chunk_4_prop_font_family": "arial", + "chunk_4_prop_font_size": 10, + "chunk_4_text": ", ", + "chunk_5_prop_align": "L", + "chunk_5_prop_breakline_strategy": "empty_space_strategy", + "chunk_5_prop_color": "RGB(0, 255, 0)", + "chunk_5_prop_font_family": "arial", + "chunk_5_prop_font_size": 10, + "chunk_5_prop_font_style": "B", + "chunk_5_text": "green", + "chunk_6_prop_align": "L", + "chunk_6_prop_breakline_strategy": "empty_space_strategy", + "chunk_6_prop_color": "RGB(0, 0, 0)", + "chunk_6_prop_font_family": "arial", + "chunk_6_prop_font_size": 10, + "chunk_6_text": ", and ", + "chunk_7_prop_align": "L", + "chunk_7_prop_breakline_strategy": "empty_space_strategy", + "chunk_7_prop_color": "RGB(0, 0, 255)", + "chunk_7_prop_font_family": "arial", + "chunk_7_prop_font_size": 10, + "chunk_7_prop_font_style": "B", + "chunk_7_text": "blue", + "chunk_8_prop_align": "L", + "chunk_8_prop_breakline_strategy": "empty_space_strategy", + "chunk_8_prop_color": "RGB(0, 0, 0)", + "chunk_8_prop_font_family": "arial", + "chunk_8_prop_font_size": 10, + "chunk_8_text": " in one flowing paragraph." + } + } + ] + } + ] + }, + { + "value": 10, + "type": "row", + "nodes": [ + { + "value": 0, + "type": "col", + "details": { + "is_max": true + }, + "nodes": [ + { + "value": "Mixed font sizes", + "type": "text", + "details": { + "prop_align": "L", + "prop_bottom": 2, + "prop_breakline_strategy": "empty_space_strategy", + "prop_color": "RGB(0, 0, 0)", + "prop_font_family": "arial", + "prop_font_size": 9, + "prop_font_style": "B", + "prop_left": 2, + "prop_top": 3 + } + } + ] + } + ] + }, + { + "value": 9.644444444444446, + "type": "row", + "nodes": [ + { + "value": 12, + "type": "col", + "nodes": [ + { + "value": 4, + "type": "richtext", + "details": { + "chunk_0_prop_align": "L", + "chunk_0_prop_bottom": 2, + "chunk_0_prop_breakline_strategy": "empty_space_strategy", + "chunk_0_prop_color": "RGB(0, 0, 0)", + "chunk_0_prop_font_family": "arial", + "chunk_0_prop_font_size": 8, + "chunk_0_prop_left": 2, + "chunk_0_prop_top": 2, + "chunk_0_text": "Small ", + "chunk_1_prop_align": "L", + "chunk_1_prop_breakline_strategy": "empty_space_strategy", + "chunk_1_prop_color": "RGB(0, 0, 0)", + "chunk_1_prop_font_family": "arial", + "chunk_1_prop_font_size": 12, + "chunk_1_text": "Medium ", + "chunk_2_prop_align": "L", + "chunk_2_prop_breakline_strategy": "empty_space_strategy", + "chunk_2_prop_color": "RGB(0, 0, 0)", + "chunk_2_prop_font_family": "arial", + "chunk_2_prop_font_size": 16, + "chunk_2_prop_font_style": "B", + "chunk_2_text": "Large ", + "chunk_3_prop_align": "L", + "chunk_3_prop_breakline_strategy": "empty_space_strategy", + "chunk_3_prop_color": "RGB(0, 0, 0)", + "chunk_3_prop_font_family": "arial", + "chunk_3_prop_font_size": 8, + "chunk_3_text": "back to small." + } + } + ] + } + ] + }, + { + "value": 10, + "type": "row", + "nodes": [ + { + "value": 0, + "type": "col", + "details": { + "is_max": true + }, + "nodes": [ + { + "value": "Word wrapping across styles", + "type": "text", + "details": { + "prop_align": "L", + "prop_bottom": 2, + "prop_breakline_strategy": "empty_space_strategy", + "prop_color": "RGB(0, 0, 0)", + "prop_font_family": "arial", + "prop_font_size": 9, + "prop_font_style": "B", + "prop_left": 2, + "prop_top": 3 + } + } + ] + } + ] + }, + { + "value": 9.055555555555557, + "type": "row", + "nodes": [ + { + "value": 12, + "type": "col", + "nodes": [ + { + "value": 7, + "type": "richtext", + "details": { + "chunk_0_prop_align": "L", + "chunk_0_prop_breakline_strategy": "empty_space_strategy", + "chunk_0_prop_color": "RGB(0, 0, 0)", + "chunk_0_prop_font_family": "arial", + "chunk_0_prop_font_size": 10, + "chunk_0_prop_left": 2, + "chunk_0_prop_right": 2, + "chunk_0_prop_top": 2, + "chunk_0_text": "This is a longer paragraph that demonstrates how ", + "chunk_1_prop_align": "L", + "chunk_1_prop_breakline_strategy": "empty_space_strategy", + "chunk_1_prop_color": "RGB(0, 0, 0)", + "chunk_1_prop_font_family": "arial", + "chunk_1_prop_font_size": 10, + "chunk_1_prop_font_style": "B", + "chunk_1_text": "rich text", + "chunk_2_prop_align": "L", + "chunk_2_prop_breakline_strategy": "empty_space_strategy", + "chunk_2_prop_color": "RGB(0, 0, 0)", + "chunk_2_prop_font_family": "arial", + "chunk_2_prop_font_size": 10, + "chunk_2_text": " handles ", + "chunk_3_prop_align": "L", + "chunk_3_prop_breakline_strategy": "empty_space_strategy", + "chunk_3_prop_color": "RGB(0, 0, 0)", + "chunk_3_prop_font_family": "arial", + "chunk_3_prop_font_size": 10, + "chunk_3_prop_font_style": "I", + "chunk_3_text": "word wrapping", + "chunk_4_prop_align": "L", + "chunk_4_prop_breakline_strategy": "empty_space_strategy", + "chunk_4_prop_color": "RGB(0, 0, 0)", + "chunk_4_prop_font_family": "arial", + "chunk_4_prop_font_size": 10, + "chunk_4_text": " across multiple lines while ", + "chunk_5_prop_align": "L", + "chunk_5_prop_breakline_strategy": "empty_space_strategy", + "chunk_5_prop_color": "RGB(255, 0, 0)", + "chunk_5_prop_font_family": "arial", + "chunk_5_prop_font_size": 10, + "chunk_5_prop_font_style": "B", + "chunk_5_text": "preserving each chunk style", + "chunk_6_prop_align": "L", + "chunk_6_prop_breakline_strategy": "empty_space_strategy", + "chunk_6_prop_color": "RGB(0, 0, 0)", + "chunk_6_prop_font_family": "arial", + "chunk_6_prop_font_size": 10, + "chunk_6_text": " inside the same paragraph." + } + } + ] + } + ] + }, + { + "value": 10, + "type": "row", + "nodes": [ + { + "value": 0, + "type": "col", + "details": { + "is_max": true + }, + "nodes": [ + { + "value": "Alignment and line breaks", + "type": "text", + "details": { + "prop_align": "L", + "prop_bottom": 2, + "prop_breakline_strategy": "empty_space_strategy", + "prop_color": "RGB(0, 0, 0)", + "prop_font_family": "arial", + "prop_font_size": 9, + "prop_font_style": "B", + "prop_left": 2, + "prop_top": 3 + } + } + ] + } + ] + }, + { + "value": 9.055555555555557, + "type": "row", + "nodes": [ + { + "value": 6, + "type": "col", + "nodes": [ + { + "value": 4, + "type": "richtext", + "details": { + "chunk_0_prop_align": "C", + "chunk_0_prop_breakline_strategy": "empty_space_strategy", + "chunk_0_prop_color": "RGB(0, 0, 0)", + "chunk_0_prop_font_family": "arial", + "chunk_0_prop_font_size": 10, + "chunk_0_prop_left": 2, + "chunk_0_prop_preserve_line_breaks": true, + "chunk_0_prop_right": 2, + "chunk_0_prop_top": 2, + "chunk_0_text": "Centered rich text\nwith explicit line breaks", + "chunk_1_prop_align": "L", + "chunk_1_prop_breakline_strategy": "empty_space_strategy", + "chunk_1_prop_color": "RGB(0, 0, 0)", + "chunk_1_prop_font_family": "arial", + "chunk_1_prop_font_size": 10, + "chunk_1_text": " and ", + "chunk_2_prop_align": "L", + "chunk_2_prop_breakline_strategy": "empty_space_strategy", + "chunk_2_prop_color": "RGB(0, 0, 0)", + "chunk_2_prop_font_family": "arial", + "chunk_2_prop_font_size": 10, + "chunk_2_prop_font_style": "B", + "chunk_2_text": "bold emphasis", + "chunk_3_prop_align": "L", + "chunk_3_prop_breakline_strategy": "empty_space_strategy", + "chunk_3_prop_color": "RGB(0, 0, 0)", + "chunk_3_prop_font_family": "arial", + "chunk_3_prop_font_size": 10, + "chunk_3_text": "." + } + } + ] + }, + { + "value": 6, + "type": "col", + "nodes": [ + { + "value": 1, + "type": "richtext", + "details": { + "chunk_0_prop_align": "J", + "chunk_0_prop_breakline_strategy": "empty_space_strategy", + "chunk_0_prop_color": "RGB(0, 0, 0)", + "chunk_0_prop_font_family": "arial", + "chunk_0_prop_font_size": 10, + "chunk_0_prop_left": 2, + "chunk_0_prop_right": 2, + "chunk_0_prop_top": 2, + "chunk_0_text": "Justified text keeps the paragraph flowing while distributing the available width between words." + } + } + ] + } + ] + }, + { + "value": 174.186388888, + "type": "row", + "nodes": [ + { + "value": 12, + "type": "col" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/test/maroto/examples/textgrid.json b/test/maroto/examples/textgrid.json index 9ccc4423..e4707358 100755 --- a/test/maroto/examples/textgrid.json +++ b/test/maroto/examples/textgrid.json @@ -675,7 +675,203 @@ ] }, { - "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": 10, + "type": "row", + "nodes": [ + { + "value": 0, + "type": "col", + "details": { + "is_max": true + }, + "nodes": [ + { + "value": "Explicit line breaks", + "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, + "prop_font_style": "B" + } + } + ] + } + ] + }, + { + "value": 8, + "type": "row", + "nodes": [ + { + "value": 6, + "type": "col", + "nodes": [ + { + "value": "Default behavior", + "type": "text", + "details": { + "prop_align": "C", + "prop_breakline_strategy": "empty_space_strategy", + "prop_color": "RGB(0, 0, 0)", + "prop_font_family": "arial", + "prop_font_size": 10, + "prop_font_style": "B" + } + } + ] + }, + { + "value": 6, + "type": "col", + "nodes": [ + { + "value": "PreserveLineBreaks", + "type": "text", + "details": { + "prop_align": "C", + "prop_breakline_strategy": "empty_space_strategy", + "prop_color": "RGB(0, 0, 0)", + "prop_font_family": "arial", + "prop_font_size": 10, + "prop_font_style": "B" + } + } + ] + } + ] + }, + { + "value": 16.111111111111114, + "type": "row", + "nodes": [ + { + "value": 6, + "type": "col", + "details": { + "prop_border_color": "RGB(0, 0, 0)", + "prop_border_thickness": 0.1, + "prop_border_type": 15 + }, + "nodes": [ + { + "value": "First paragraph line 1.\nFirst paragraph line 2.\n\nSecond paragraph starts here.", + "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, + "prop_left": 2, + "prop_right": 2, + "prop_top": 2 + } + } + ] + }, + { + "value": 6, + "type": "col", + "details": { + "prop_border_color": "RGB(0, 0, 0)", + "prop_border_thickness": 0.1, + "prop_border_type": 15 + }, + "nodes": [ + { + "value": "First paragraph line 1.\nFirst paragraph line 2.\n\nSecond paragraph starts here.", + "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, + "prop_left": 2, + "prop_preserve_line_breaks": true, + "prop_right": 2, + "prop_top": 2 + } + } + ] + } + ] + }, + { + "value": 87.914166666, "type": "row", "nodes": [ {