diff --git a/README.md b/README.md index 5b03256..8eaf37b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/gpdf-dev/gpdf.svg)](https://pkg.go.dev/github.com/gpdf-dev/gpdf) [![CI](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml/badge.svg)](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml) -![coverage](https://img.shields.io/badge/coverage-86.1%25-green) +![coverage](https://img.shields.io/badge/coverage-85.8%25-green) [![Go Report Card](https://goreportcard.com/badge/github.com/gpdf-dev/gpdf)](https://goreportcard.com/report/github.com/gpdf-dev/gpdf) [![Go Version](https://img.shields.io/badge/Go-%3E%3D1.22-blue)](https://go.dev/) [![Website](https://img.shields.io/badge/Website-gpdf.dev-blue)](https://gpdf.dev/) @@ -857,6 +857,7 @@ doc.Render(f) | Option | Description | |---|---| | `template.QRSize(value)` | Set QR code size | +| `template.QRMinSize(value)` | Minimum display size — overflow to next page instead of shrinking below | | `template.QRErrorCorrection(level)` | Set error correction (L/M/Q/H) | | `template.QRScale(n)` | Set module scale factor | diff --git a/README_es.md b/README_es.md index 2c97c17..2b7844a 100644 --- a/README_es.md +++ b/README_es.md @@ -796,6 +796,7 @@ doc.Render(f) | Opción | Descripción | |---|---| | `template.QRSize(value)` | Tamaño del código QR | +| `template.QRMinSize(value)` | Tamaño mínimo — desborda a la siguiente página en lugar de encogerse por debajo | | `template.QRErrorCorrection(level)` | Nivel de corrección de errores (L/M/Q/H) | | `template.QRScale(n)` | Factor de escala del módulo | diff --git a/README_ja.md b/README_ja.md index e4b3f6e..a10cdb8 100644 --- a/README_ja.md +++ b/README_ja.md @@ -789,6 +789,7 @@ doc.Render(f) | オプション | 説明 | |---|---| | `template.QRSize(value)` | QRコードのサイズを設定 | +| `template.QRMinSize(value)` | 最小表示サイズ — これを下回る場合は次ページへ送る | | `template.QRErrorCorrection(level)` | 誤り訂正レベルを設定 (L/M/Q/H) | | `template.QRScale(n)` | モジュールスケール係数を設定 | diff --git a/README_ko.md b/README_ko.md index 32e792c..36972f1 100644 --- a/README_ko.md +++ b/README_ko.md @@ -796,6 +796,7 @@ doc.Render(f) | 옵션 | 설명 | |---|---| | `template.QRSize(value)` | QR 코드 크기 설정 | +| `template.QRMinSize(value)` | 최소 표시 크기 — 이 값 미만이 되면 다음 페이지로 이동 | | `template.QRErrorCorrection(level)` | 오류 정정 레벨 설정 (L/M/Q/H) | | `template.QRScale(n)` | 모듈 스케일 팩터 설정 | diff --git a/README_pt.md b/README_pt.md index f7a3aab..5e45da7 100644 --- a/README_pt.md +++ b/README_pt.md @@ -796,6 +796,7 @@ merged, _ := gpdf.Merge( | Opção | Descrição | |---|---| | `template.QRSize(value)` | Tamanho do QR code | +| `template.QRMinSize(value)` | Tamanho mínimo — transborda para a próxima página em vez de encolher abaixo | | `template.QRErrorCorrection(level)` | Nível de correção de erros (L/M/Q/H) | | `template.QRScale(n)` | Fator de escala do módulo | diff --git a/README_zh.md b/README_zh.md index 0285f3d..4bf1c46 100644 --- a/README_zh.md +++ b/README_zh.md @@ -796,6 +796,7 @@ doc.Render(f) | 选项 | 说明 | |---|---| | `template.QRSize(value)` | 设置二维码大小 | +| `template.QRMinSize(value)` | 最小显示尺寸 — 低于此值时溢出到下一页 | | `template.QRErrorCorrection(level)` | 设置纠错等级(L/M/Q/H) | | `template.QRScale(n)` | 设置模块缩放因子 | diff --git a/_examples/builder/34_image_min_size_test.go b/_examples/builder/34_image_min_size_test.go new file mode 100644 index 0000000..acc37ce --- /dev/null +++ b/_examples/builder/34_image_min_size_test.go @@ -0,0 +1,55 @@ +package builder_test + +import ( + "image/color" + "testing" + + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/document" + "github.com/gpdf-dev/gpdf/template" +) + +// TestExample_34_ImageMinSize verifies that an image with MinDisplayHeight +// overflows to the next page when the remaining space would force it below +// the configured minimum, instead of being shrunk to fit. +func TestExample_34_ImageMinSize(t *testing.T) { + doc := template.New( + template.WithPageSize(document.A4), + template.WithMargins(document.UniformEdges(document.Mm(20))), + ) + + page := doc.AddPage() + + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("Image Minimum Display Size", template.FontSize(18), template.Bold()) + c.Spacer(document.Mm(5)) + }) + }) + + // Fill most of the page so the image's natural height cannot fit. + for i := 0; i < 23; i++ { + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") + }) + }) + } + + // A tall image (300x500 px → 0.6 aspect ratio). Displayed at 80mm width + // its natural height is ~133mm. The remaining space on page 1 is far + // smaller than the 100mm minimum, so the image must move to page 2. + imgData := testutil.TestImagePNG(t, 300, 500, color.RGBA{R: 100, G: 149, B: 237, A: 255}) + + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Image(imgData, + template.FitWidth(document.Mm(80)), + template.MinDisplayHeight(document.Mm(100)), + ) + }) + }) + + testutil.GeneratePDFSharedGolden(t, "34_image_min_size.pdf", doc) +} diff --git a/_examples/gotemplate/34_image_min_size_test.go b/_examples/gotemplate/34_image_min_size_test.go new file mode 100644 index 0000000..f620d98 --- /dev/null +++ b/_examples/gotemplate/34_image_min_size_test.go @@ -0,0 +1,58 @@ +package gotemplate_test + +import ( + "encoding/base64" + "image/color" + "testing" + + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/template" +) + +// TestTmpl_34_ImageMinSize is the Go-template counterpart of +// TestExample_34_ImageMinSize. It shares the same golden file. +func TestTmpl_34_ImageMinSize(t *testing.T) { + imgData := testutil.TestImagePNG(t, 300, 500, color.RGBA{R: 100, G: 149, B: 237, A: 255}) + + schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "{{.Title}}", "style": {"size": 18, "bold": true}}, + {"type": "spacer", "height": "5mm"} + ]} + ]}}, + {{range .Fillers}} + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "{{.}}"} + ]} + ]}}, + {{end}} + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "image", "image": {"src": "{{.ImgB64}}", "width": "80mm", "minHeight": "100mm"}} + ]} + ]}} + ] + }`) + + fillers := make([]string, 23) + for i := range fillers { + fillers[i] = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + } + + data := map[string]any{ + "Title": "Image Minimum Display Size", + "Fillers": fillers, + "ImgB64": base64.StdEncoding.EncodeToString(imgData), + } + + doc, err := template.FromJSON(schema, data) + if err != nil { + t.Fatalf("FromJSON error: %v", err) + } + testutil.GeneratePDFSharedGolden(t, "34_image_min_size.pdf", doc) +} diff --git a/_examples/json/34_image_min_size_test.go b/_examples/json/34_image_min_size_test.go new file mode 100644 index 0000000..71db7a0 --- /dev/null +++ b/_examples/json/34_image_min_size_test.go @@ -0,0 +1,53 @@ +package json_test + +import ( + "encoding/base64" + "fmt" + "image/color" + "strings" + "testing" + + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/template" +) + +// TestJSON_34_ImageMinSize is the JSON-schema counterpart of +// TestExample_34_ImageMinSize. It shares the same golden file. +func TestJSON_34_ImageMinSize(t *testing.T) { + imgData := testutil.TestImagePNG(t, 300, 500, color.RGBA{R: 100, G: 149, B: 237, A: 255}) + imgB64 := base64.StdEncoding.EncodeToString(imgData) + + // Build 23 filler rows identical to the Builder/GoTemplate cases. + var filler strings.Builder + for i := 0; i < 23; i++ { + filler.WriteString(`{"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."} + ]} + ]}},`) + } + + schema := []byte(fmt.Sprintf(`{ + "page": {"size": "A4", "margins": "20mm"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "text", "content": "Image Minimum Display Size", "style": {"size": 18, "bold": true}}, + {"type": "spacer", "height": "5mm"} + ]} + ]}}, + %s + {"row": {"cols": [ + {"span": 12, "elements": [ + {"type": "image", "image": {"src": "%s", "width": "80mm", "minHeight": "100mm"}} + ]} + ]}} + ] + }`, filler.String(), imgB64)) + + doc, err := template.FromJSON(schema, nil) + if err != nil { + t.Fatalf("FromJSON error: %v", err) + } + testutil.GeneratePDFSharedGolden(t, "34_image_min_size.pdf", doc) +} diff --git a/_examples/testdata/golden/34_image_min_size.pdf b/_examples/testdata/golden/34_image_min_size.pdf new file mode 100644 index 0000000..fd0e0b5 Binary files /dev/null and b/_examples/testdata/golden/34_image_min_size.pdf differ diff --git a/docs/05-elements.md b/docs/05-elements.md index cc26514..700677b 100644 --- a/docs/05-elements.md +++ b/docs/05-elements.md @@ -339,6 +339,7 @@ c.QRCode("HELLO", "qrcode": { "data": "https://gpdf.dev", "size": "30mm", + "minSize": "20mm", "errorCorrection": "M" } } @@ -349,6 +350,7 @@ c.QRCode("HELLO", | Option | Description | |---|---| | `QRSize(v)` | Display size (width = height) | +| `QRMinSize(v)` | Minimum display size. When the layout would shrink the QR below this value it overflows to the next page instead of rendering at an unscannable size | | `QRErrorCorrection(level)` | Error correction: `LevelL`, `LevelM` (default), `LevelQ`, `LevelH` | | `QRScale(s)` | Pixels per QR module (affects image resolution) | diff --git a/document/image.go b/document/image.go index e8c82b2..9b67bd6 100644 --- a/document/image.go +++ b/document/image.go @@ -37,6 +37,15 @@ type Image struct { DisplayWidth Value // DisplayHeight is the explicit display height set by template options. DisplayHeight Value + // MinDisplayWidth prevents the layout engine from shrinking the image + // below this width to fit the remaining space. When the clamped width + // would fall below this value the image is moved to the next page + // instead. UnitAuto (the zero value) preserves the original behavior. + MinDisplayWidth Value + // MinDisplayHeight is the vertical counterpart to MinDisplayWidth. + // When the clamped height would fall below this value the image is + // moved to the next page. UnitAuto preserves the original behavior. + MinDisplayHeight Value } // ImageFitMode controls how an image is scaled within its layout bounds. diff --git a/document/layout/block.go b/document/layout/block.go index 2c460f7..f41c11f 100644 --- a/document/layout/block.go +++ b/document/layout/block.go @@ -186,6 +186,13 @@ func (bl *BlockLayout) layoutVerticalChild(bc *blockContext, child document.Docu return bc.overflowResult(placed, cursorY, children[i:]), true } + // When a child overflows without producing any bounds (e.g. an image + // whose clamped size would violate MinDisplay* constraints), defer the + // entire child to the next page instead of emitting a zero-size placeholder. + if childResult.Overflow != nil && childResult.Bounds.Width == 0 && childResult.Bounds.Height == 0 { + return bc.overflowResult(placed, cursorY, children[i:]), true + } + return childResult, false } @@ -546,7 +553,12 @@ func (bl *BlockLayout) layoutImage(child document.DocumentNode, constraints Cons } } - displayW, displayH = clampImageSize(img.FitMode, displayW, displayH, aspectRatio, constraints) + clampedW, clampedH := clampImageSize(img.FitMode, displayW, displayH, aspectRatio, constraints) + if shouldOverflowImageForMinimum(displayW, clampedW, img.MinDisplayWidth, constraints.AvailableWidth) || + shouldOverflowImageForMinimum(displayH, clampedH, img.MinDisplayHeight, constraints.AvailableHeight) { + return Result{Overflow: img} + } + displayW, displayH = clampedW, clampedH return Result{ Bounds: document.Rectangle{ @@ -621,6 +633,41 @@ func clampImageSize(fitMode document.ImageFitMode, w, h, aspectRatio float64, co return w, h } +// shouldOverflowImageForMinimum reports whether shrinking an image to fit +// the available space would violate a configured minimum display dimension. +// When true, the caller should move the image to the next page instead of +// rendering it at a shrunken size. +func shouldOverflowImageForMinimum(original, clamped float64, minimum document.Value, available float64) bool { + resolvedMinimum, ok := resolveImageMinimum(minimum, available) + if !ok { + return false + } + if !wasImageShrunk(original, clamped) { + return false + } + // Only overflow when the original size would have honored the minimum. + // This prevents an infinite loop when the minimum itself exceeds the + // full page's available space. + return original >= resolvedMinimum && clamped < resolvedMinimum +} + +// resolveImageMinimum converts a configured minimum image dimension into +// points and reports whether a usable minimum was provided. +func resolveImageMinimum(minimum document.Value, available float64) (float64, bool) { + const defaultFontSize = 12.0 + if minimum.Unit == document.UnitAuto || minimum.Amount <= 0 { + return 0, false + } + return minimum.Resolve(available, defaultFontSize), true +} + +// wasImageShrunk reports whether clamping reduced a dimension by more than +// a small floating-point tolerance. +func wasImageShrunk(original, clamped float64) bool { + const shrinkEpsilon = 1e-6 + return clamped < original-shrinkEpsilon +} + // resolveBorderWidths extracts the four border widths as resolved edges. func resolveBorderWidths(border document.BorderEdges, parentWidth, fontSize float64) document.ResolvedEdges { var top, right, bottom, left float64 diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 7d31087..bd6ff3d 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,4 +3,4 @@ package buildinfo // Version is the library version. It is the single source of truth used by // the public gpdf.Version constant and the default PDF Producer metadata. -const Version = "1.0.5" +const Version = "1.0.6" diff --git a/schema/gpdf.schema.json b/schema/gpdf.schema.json index 4cc51b0..8210cfc 100644 --- a/schema/gpdf.schema.json +++ b/schema/gpdf.schema.json @@ -406,6 +406,10 @@ "$ref": "#/$defs/dimension", "description": "QR code size (width and height)." }, + "minSize": { + "$ref": "#/$defs/dimension", + "description": "Minimum display size. If the layout would shrink the QR code below this value it overflows to the next page instead." + }, "errorCorrection": { "type": "string", "description": "Error correction level. L=~7%, M=~15% (default), Q=~25%, H=~30%.", diff --git a/template/component.go b/template/component.go index 56a6ab8..451c00e 100644 --- a/template/component.go +++ b/template/component.go @@ -83,10 +83,12 @@ func Strikethrough() TextOption { type ImageOption func(*imageConfig) type imageConfig struct { - width document.Value - height document.Value - fitMode document.ImageFitMode - align document.TextAlign + width document.Value + height document.Value + minWidth document.Value + minHeight document.Value + fitMode document.ImageFitMode + align document.TextAlign } // FitWidth sets the image to fit within the specified width. @@ -119,6 +121,24 @@ func WithAlign(align document.TextAlign) ImageOption { } } +// MinDisplayWidth sets a minimum display width for the image. If the layout +// engine would need to shrink the image below this width to fit the remaining +// space, the image is moved to the next page instead. +func MinDisplayWidth(width document.Value) ImageOption { + return func(cfg *imageConfig) { + cfg.minWidth = width + } +} + +// MinDisplayHeight sets a minimum display height for the image. If the layout +// engine would need to shrink the image below this height to fit the remaining +// space, the image is moved to the next page instead. +func MinDisplayHeight(height document.Value) ImageOption { + return func(cfg *imageConfig) { + cfg.minHeight = height + } +} + // --- Table Options --- // TableOption configures a Table element. @@ -216,6 +236,7 @@ type QRCodeOption func(*qrCodeConfig) type qrCodeConfig struct { size document.Value + minSize document.Value ecLevel qrcode.ErrorCorrectionLevel scale int } @@ -227,6 +248,15 @@ func QRSize(v document.Value) QRCodeOption { } } +// QRMinSize sets a minimum display size (width = height) for the QR code. +// When the layout would shrink the QR code below this value it is moved to +// the next page instead, preserving scannability. +func QRMinSize(v document.Value) QRCodeOption { + return func(cfg *qrCodeConfig) { + cfg.minSize = v + } +} + // QRErrorCorrection sets the error correction level (L/M/Q/H). func QRErrorCorrection(level qrcode.ErrorCorrectionLevel) QRCodeOption { return func(cfg *qrCodeConfig) { diff --git a/template/grid.go b/template/grid.go index b8a35ef..b29cc07 100644 --- a/template/grid.go +++ b/template/grid.go @@ -209,6 +209,12 @@ func (c *ColBuilder) Image(src []byte, opts ...ImageOption) { if imgCfg.height.Amount > 0 { imgNode.DisplayHeight = imgCfg.height } + if imgCfg.minWidth.Amount > 0 { + imgNode.MinDisplayWidth = imgCfg.minWidth + } + if imgCfg.minHeight.Amount > 0 { + imgNode.MinDisplayHeight = imgCfg.minHeight + } c.nodes = append(c.nodes, imgNode) } @@ -410,6 +416,11 @@ func (c *ColBuilder) QRCode(data string, opts ...QRCodeOption) { imgNode.DisplayHeight = cfg.size } + if cfg.minSize.Amount > 0 { + imgNode.MinDisplayWidth = cfg.minSize + imgNode.MinDisplayHeight = cfg.minSize + } + c.nodes = append(c.nodes, imgNode) } diff --git a/template/schema.go b/template/schema.go index 8c2945a..7be4142 100644 --- a/template/schema.go +++ b/template/schema.go @@ -127,11 +127,13 @@ type SchemaStyle struct { // SchemaImage defines an image element. type SchemaImage struct { - Src string `json:"src"` // base64, data URI, or file path - Width string `json:"width,omitempty"` // dimension - Height string `json:"height,omitempty"` // dimension - Fit string `json:"fit,omitempty"` // "contain"|"cover"|"stretch"|"original" - Align string `json:"align,omitempty"` // "left"|"center"|"right" + Src string `json:"src"` // base64, data URI, or file path + Width string `json:"width,omitempty"` // dimension + Height string `json:"height,omitempty"` // dimension + MinWidth string `json:"minWidth,omitempty"` // minimum display width; overflow to next page when violated + MinHeight string `json:"minHeight,omitempty"` // minimum display height; overflow to next page when violated + Fit string `json:"fit,omitempty"` // "contain"|"cover"|"stretch"|"original" + Align string `json:"align,omitempty"` // "left"|"center"|"right" } // SchemaTable defines a table element. @@ -160,6 +162,7 @@ type SchemaLine struct { type SchemaQRCode struct { Data string `json:"data"` Size string `json:"size,omitempty"` + MinSize string `json:"minSize,omitempty"` // minimum display size; overflow to next page when violated ErrorCorrection string `json:"errorCorrection,omitempty"` // "L", "M", "Q", "H" } @@ -627,6 +630,16 @@ func buildSchemaImage(c *ColBuilder, img *SchemaImage) { opts = append(opts, FitHeight(v)) } } + if img.MinWidth != "" { + if v, err := parseValue(img.MinWidth); err == nil { + opts = append(opts, MinDisplayWidth(v)) + } + } + if img.MinHeight != "" { + if v, err := parseValue(img.MinHeight); err == nil { + opts = append(opts, MinDisplayHeight(v)) + } + } if img.Fit != "" { if mode, ok := parseFitMode(img.Fit); ok { opts = append(opts, WithFitMode(mode)) @@ -749,6 +762,11 @@ func buildSchemaQRCode(c *ColBuilder, qr *SchemaQRCode) { opts = append(opts, QRSize(v)) } } + if qr.MinSize != "" { + if v, err := parseValue(qr.MinSize); err == nil { + opts = append(opts, QRMinSize(v)) + } + } if qr.ErrorCorrection != "" { if level, ok := parseQRErrorCorrection(qr.ErrorCorrection); ok { opts = append(opts, QRErrorCorrection(level))