diff --git a/ltml/SYNTAX.md b/ltml/SYNTAX.md index 96b0a3d..a929fd2 100644 --- a/ltml/SYNTAX.md +++ b/ltml/SYNTAX.md @@ -212,6 +212,7 @@ like an image-style placement widget. |-----------|-------------| | `key` | Required canvas key to place. | | `width`, `height` | Optional explicit placement dimensions. If both are omitted, LTML uses the canvas natural size. If only one is supplied, LTML preserves aspect ratio. If both are supplied, LTML stretches to the exact box. | +| `max-width`, `max-height` | Optional maximum widget dimensions. For image-style widgets, caps preserve aspect ratio and choose whichever dimension dominates. | | `margin`, `margin-top`, `margin-right`, `margin-bottom`, `margin-left` | Outer spacing around the widget box. | | `padding`, `padding-top`, `padding-right`, `padding-bottom`, `padding-left` | Inner spacing inside the widget box. | | `border` | Optional enclosing widget border, separate from the captured canvas content. | @@ -548,6 +549,7 @@ Places an image file into the document using the PDF image API. |-----------|-------------| | `src` | Path to the source image file. | | `width`, `height` | Optional explicit dimensions. If only one is supplied, the other is inferred from the image aspect ratio. | +| `max-width`, `max-height` | Optional maximum widget dimensions. Caps preserve aspect ratio and choose whichever dimension dominates. | | `margin`, `margin-top`, `margin-right`, `margin-bottom`, `margin-left` | Outer spacing around the widget box. | | `padding`, `padding-top`, `padding-right`, `padding-bottom`, `padding-left` | Inner spacing inside the widget box. | | `border` | Reference to a named `` style. | @@ -603,6 +605,7 @@ optional network loading. |-----------|-------------| | `src` | Optional path or URL to an external SVG document. When both `src` and inline SVG body are present, `src` wins. | | `width`, `height` | Optional explicit dimensions. If only one is supplied, the other is inferred from the SVG aspect ratio. | +| `max-width`, `max-height` | Optional maximum widget dimensions. Caps preserve aspect ratio and choose whichever dimension dominates. | | `margin`, `margin-top`, `margin-right`, `margin-bottom`, `margin-left` | Outer spacing around the widget box. | | `padding`, `padding-top`, `padding-right`, `padding-bottom`, `padding-left` | Inner spacing inside the widget box. | | `border` | Reference to a named `` style. | @@ -1388,6 +1391,10 @@ Measurements can be expressed in several forms: | Relative | `+10`, `-5` | Offset from the container's content dimension. | | Auto | `auto` | Automatic layout-managed size. Supported by `hbox` width, `vbox` height, table column width, and table row height; elsewhere it behaves like omitting the dimension. | +`max-width` and `max-height` accept bare numbers, unit-suffixed measurements, +percentages, and relative values. `auto` or an omitted max attribute means +there is no cap. + **Supported units:** `pt` (points), `in` (inches, 72pt), `cm` (centimeters, 28.35pt). Implementation note: LTML stores and computes measurements internally in diff --git a/ltml/canvas_draw_test.go b/ltml/canvas_draw_test.go index f35fb8a..7d9883c 100644 --- a/ltml/canvas_draw_test.go +++ b/ltml/canvas_draw_test.go @@ -88,6 +88,62 @@ func captureTexts(w *labelTestWriter) string { return b.String() } +func TestStdDraw_MaxHeightFitsAspectRatio(t *testing.T) { + doc := parseCanvasDoc(t, ` + + + + + +`) + page := doc.Root().Page(0) + draw, ok := page.children[0].(*StdDraw) + if !ok { + t.Fatalf("child type = %T, want *StdDraw", page.children[0]) + } + w := &canvasTestWriter{labelTestWriter: labelTestWriter{t: t}} + + wantWidth := 100.0 * 38.0 / 1080.0 + if got := draw.PreferredHeight(w); got != 100 { + t.Fatalf("PreferredHeight() = %v, want 100", got) + } + if got := draw.PreferredWidth(w); got < wantWidth-0.001 || got > wantWidth+0.001 { + t.Fatalf("PreferredWidth() = %v, want approx %v", got, wantWidth) + } +} + +func TestStdDraw_DrawContent_FitsResolvedCellWithoutStretching(t *testing.T) { + doc := parseCanvasDoc(t, ` + + + + + +`) + page := doc.Root().Page(0) + draw, ok := page.children[0].(*StdDraw) + if !ok { + t.Fatalf("child type = %T, want *StdDraw", page.children[0]) + } + draw.ResolveWidth(200) + draw.ResolveHeight(100) + w := &canvasTestWriter{labelTestWriter: labelTestWriter{t: t}} + + if err := draw.DrawContent(w); err != nil { + t.Fatal(err) + } + if len(w.drawCalls) != 1 { + t.Fatalf("draw call count = %d, want 1", len(w.drawCalls)) + } + wantWidth := 100.0 * 38.0 / 1080.0 + if got := w.drawCalls[0].width; got < wantWidth-0.001 || got > wantWidth+0.001 { + t.Fatalf("width = %v, want approx %v", got, wantWidth) + } + if got := w.drawCalls[0].height; got != 100 { + t.Fatalf("height = %v, want 100", got) + } +} + func TestParse_CanvasMustBeDirectChildOfDocument(t *testing.T) { _, err := Parse([]byte(` diff --git a/ltml/dimensions.go b/ltml/dimensions.go index 93cb874..774af2f 100644 --- a/ltml/dimensions.go +++ b/ltml/dimensions.go @@ -33,6 +33,14 @@ type Dimensions struct { heightMode DimensionMode widthValid bool heightValid bool + max maxDimensions +} + +type maxDimensions struct { + widthValue float32 + heightValue float32 + widthMode DimensionMode + heightMode DimensionMode } type dimensionState struct { @@ -45,6 +53,7 @@ type dimensionState struct { type dimensionsState struct { width dimensionState height dimensionState + max maxDimensions } var ( @@ -135,6 +144,97 @@ func (d *Dimensions) SetAttrs(attrs map[string]string, units Units) { d.SetHeight(height) } } + if width, ok := attrs["max-width"]; ok { + d.setMaxWidthAttr(strings.TrimSpace(width), units) + } + if height, ok := attrs["max-height"]; ok { + d.setMaxHeightAttr(strings.TrimSpace(height), units) + } +} + +func (d *Dimensions) setMaxWidthAttr(width string, units Units) { + if width == "" || width == "auto" { + d.ClearMaxWidth() + } else if rePct.MatchString(width) { + widthPct, _ := strconv.ParseFloat(width[:len(width)-1], 64) + d.SetMaxWidthPct(widthPct) + } else if reRel.MatchString(width) { + widthRel, _ := strconv.ParseFloat(width, 64) + d.SetMaxWidthRel(widthRel) + } else { + d.SetMaxWidth(ParseMeasurement(width, units)) + } +} + +func (d *Dimensions) setMaxHeightAttr(height string, units Units) { + if height == "" || height == "auto" { + d.ClearMaxHeight() + } else if rePct.MatchString(height) { + heightPct, _ := strconv.ParseFloat(height[:len(height)-1], 64) + d.SetMaxHeightPct(heightPct) + } else if reRel.MatchString(height) { + heightRel, _ := strconv.ParseFloat(height, 64) + d.SetMaxHeightRel(heightRel) + } else { + d.SetMaxHeight(ParseMeasurement(height, units)) + } +} + +func (d *Dimensions) SetMaxWidth(value float64) { + d.max.widthValue = float32(value) + d.max.widthMode = DimLiteral +} + +func (d *Dimensions) SetMaxWidthPct(value float64) { + d.max.widthValue = float32(value) + d.max.widthMode = DimPct +} + +func (d *Dimensions) SetMaxWidthRel(value float64) { + d.max.widthValue = float32(value) + d.max.widthMode = DimRel +} + +func (d *Dimensions) ClearMaxWidth() { + d.max.widthValue = 0 + d.max.widthMode = DimUnspecified +} + +func (d *Dimensions) MaxWidthIsSet() bool { + return maxDimensionIsSet(d.max.widthMode) +} + +func (d *Dimensions) SetMaxHeight(value float64) { + d.max.heightValue = float32(value) + d.max.heightMode = DimLiteral +} + +func (d *Dimensions) SetMaxHeightPct(value float64) { + d.max.heightValue = float32(value) + d.max.heightMode = DimPct +} + +func (d *Dimensions) SetMaxHeightRel(value float64) { + d.max.heightValue = float32(value) + d.max.heightMode = DimRel +} + +func (d *Dimensions) ClearMaxHeight() { + d.max.heightValue = 0 + d.max.heightMode = DimUnspecified +} + +func (d *Dimensions) MaxHeightIsSet() bool { + return maxDimensionIsSet(d.max.heightMode) +} + +func maxDimensionIsSet(mode DimensionMode) bool { + switch mode { + case DimLiteral, DimPct, DimRel: + return true + default: + return false + } } func (d *Dimensions) SetHeight(value float64) { @@ -297,7 +397,7 @@ func (d *Dimensions) HeightMode() DimensionMode { } func (d *Dimensions) SaveState() dimensionsState { - return dimensionsState{ + state := dimensionsState{ width: dimensionState{ resolved: d.width, value: d.widthValue, @@ -310,7 +410,9 @@ func (d *Dimensions) SaveState() dimensionsState { mode: d.heightMode, valid: d.heightValid, }, + max: d.max, } + return state } func (d *Dimensions) RestoreState(state dimensionsState) { @@ -322,4 +424,5 @@ func (d *Dimensions) RestoreState(state dimensionsState) { d.heightValue = state.height.value d.heightMode = state.height.mode d.heightValid = state.height.valid + d.max = state.max } diff --git a/ltml/dimensions_test.go b/ltml/dimensions_test.go index 1186b32..791d678 100644 --- a/ltml/dimensions_test.go +++ b/ltml/dimensions_test.go @@ -9,16 +9,18 @@ import ( func TestDimensions_SetAttrs(t *testing.T) { tests := []struct { - name string - attrs map[string]string - wantWidth float64 - wantWidthValue float64 - wantWidthMode DimensionMode - wantHeight float64 - wantHeightValue float64 - wantHeightMode DimensionMode - wantWidthSet bool - wantHeightSet bool + name string + attrs map[string]string + wantWidth float64 + wantWidthValue float64 + wantWidthMode DimensionMode + wantHeight float64 + wantHeightValue float64 + wantHeightMode DimensionMode + wantWidthSet bool + wantHeightSet bool + wantMaxWidthSet bool + wantMaxHeightSet bool }{ {name: "Width", attrs: map[string]string{"width": "30"}, wantWidth: 30, wantWidthValue: 30, wantWidthMode: DimLiteral, wantWidthSet: true}, {name: "WidthPct", attrs: map[string]string{"width": "40%"}, wantWidthValue: 40, wantWidthMode: DimPct, wantWidthSet: true}, @@ -30,6 +32,9 @@ func TestDimensions_SetAttrs(t *testing.T) { {name: "HeightRelPlus", attrs: map[string]string{"height": "+50"}, wantHeightValue: 50, wantHeightMode: DimRel, wantHeightSet: true}, {name: "HeightRelMinus", attrs: map[string]string{"height": "-60"}, wantHeightValue: -60, wantHeightMode: DimRel, wantHeightSet: true}, {name: "HeightAuto", attrs: map[string]string{"height": "auto"}, wantHeightMode: DimAuto}, + {name: "MaxWidth", attrs: map[string]string{"max-width": "30"}, wantMaxWidthSet: true}, + {name: "MaxWidthAutoClears", attrs: map[string]string{"max-width": "auto"}}, + {name: "MaxHeight", attrs: map[string]string{"max-height": "40%"}, wantMaxHeightSet: true}, } for _, tc := range tests { @@ -62,6 +67,12 @@ func TestDimensions_SetAttrs(t *testing.T) { if got := d.HeightIsSet(); got != tc.wantHeightSet { t.Errorf("HeightIsSet: expected %v, got %v", tc.wantHeightSet, got) } + if got := d.MaxWidthIsSet(); got != tc.wantMaxWidthSet { + t.Errorf("MaxWidthIsSet: expected %v, got %v", tc.wantMaxWidthSet, got) + } + if got := d.MaxHeightIsSet(); got != tc.wantMaxHeightSet { + t.Errorf("MaxHeightIsSet: expected %v, got %v", tc.wantMaxHeightSet, got) + } }) } } @@ -272,6 +283,41 @@ func TestStdWidget_ResolveAutoWidthOverridesSideResolutionUntilCleared(t *testin } } +func TestStdWidget_MaxDimensionsCapResolvedAndContainerRelativeSizes(t *testing.T) { + page := &StdPage{pageStyle: &PageStyle{width: 200, height: 120}} + widget := &StdWidget{} + _ = widget.SetContainer(page) + widget.SetWidthPct(75) + widget.SetHeightRel(-10) + widget.SetMaxWidth(80) + widget.SetMaxHeightPct(50) + + if got := widget.MaxWidth(); got != 80 { + t.Fatalf("MaxWidth() = %v, want 80", got) + } + if got := widget.MaxHeight(); got != 60 { + t.Fatalf("MaxHeight() = %v, want 60", got) + } + if got := widget.Width(); got != 80 { + t.Fatalf("Width() = %v, want capped 80", got) + } + if got := widget.Height(); got != 60 { + t.Fatalf("Height() = %v, want capped 60", got) + } + + widget.ClearMaxWidth() + widget.ClearMaxHeight() + if widget.MaxWidthIsSet() || widget.MaxHeightIsSet() { + t.Fatalf("max dimensions should be clear") + } + if got := widget.Width(); got != 150 { + t.Fatalf("Width() after clear = %v, want 150", got) + } + if got := widget.Height(); got != 110 { + t.Fatalf("Height() after clear = %v, want 110", got) + } +} + func TestDimensions_ClearResolvedHeightPreservesSpecifiedHeight(t *testing.T) { var d Dimensions d.SetHeight(24) diff --git a/ltml/layout_overflow_test.go b/ltml/layout_overflow_test.go index b26f174..f9b89c6 100644 --- a/ltml/layout_overflow_test.go +++ b/ltml/layout_overflow_test.go @@ -1,6 +1,7 @@ package ltml import ( + "errors" "slices" "strings" "testing" @@ -173,35 +174,28 @@ func TestLayoutVBox_DirectChildSplitTableUsesOuterOverflowInsteadOfSelfClipping( } } -func TestStdPage_OverflowStopsWithoutPrintingAnyOnceChild(t *testing.T) { +func TestStdPage_OverflowNoProgressReturnsLayoutOverflowError(t *testing.T) { page := &StdPage{pageStyle: &PageStyle{width: 200, height: 100}} page.layout = defaultLayouts["vbox"].Clone() page.overflow = true doc := newFlowPageDoc(page) - var headerPages, bodyPages []int - - header := &flowTestWidget{name: "header", preferredHeight: 80, printedOn: &headerPages} - _ = header.SetContainer(page) - header.SetAttrs(map[string]string{"align": "top", "display": "always"}) - page.AddChild(header) + first := &flowTestWidget{name: "first", preferredHeight: 20} + _ = first.SetContainer(page) + page.AddChild(first) - body := &flowTestWidget{name: "body", preferredHeight: 40, printedOn: &bodyPages} - _ = body.SetContainer(page) - page.AddChild(body) + second := &flowTestWidget{name: "second", preferredHeight: 120} + _ = second.SetContainer(page) + page.AddChild(second) w := &labelTestWriter{t: t} - if err := doc.Print(w); err != nil { - t.Fatal(err) - } - if w.pageCount != 1 { - t.Fatalf("page count = %d, want 1", w.pageCount) - } - if len(headerPages) != 1 { - t.Fatalf("header printed %d times, want 1", len(headerPages)) + err := doc.Print(w) + var overflowErr *LayoutOverflowError + if !errors.As(err, &overflowErr) { + t.Fatalf("Print error = %v, want LayoutOverflowError", err) } - if len(bodyPages) != 0 { - t.Fatalf("body printed %d times, want 0", len(bodyPages)) + if overflowErr.AvailableHeight != 100 || overflowErr.RequiredHeight != 120 { + t.Fatalf("overflow sizes = available %v required %v, want 100 and 120", overflowErr.AvailableHeight, overflowErr.RequiredHeight) } } diff --git a/ltml/media_fit.go b/ltml/media_fit.go new file mode 100644 index 0000000..94860ba --- /dev/null +++ b/ltml/media_fit.go @@ -0,0 +1,129 @@ +package ltml + +import "math" + +func imageLikeLayoutSize(widget *StdWidget, naturalWidth, naturalHeight float64) (width, height float64) { + if naturalWidth <= 0 || naturalHeight <= 0 { + return widget.Width(), widget.Height() + } + if width, height, ok := imageLikeLegacyLayoutSize(widget, naturalWidth, naturalHeight); ok { + return width, height + } + + contentWidth, contentHeight := imageLikeContentSize(widget, naturalWidth, naturalHeight) + return contentWidth + NonContentWidth(widget), contentHeight + NonContentHeight(widget) +} + +func imageLikeLegacyLayoutSize(widget *StdWidget, naturalWidth, naturalHeight float64) (width, height float64, ok bool) { + authoredWidth := widgetWidthSpecified(widget) + authoredHeight := widgetHeightSpecified(widget) + if widget.MaxWidthIsSet() || widget.MaxHeightIsSet() || + ((widget.widthValid || widget.heightValid) && !authoredWidth && !authoredHeight) { + return 0, 0, false + } + switch { + case authoredWidth && authoredHeight: + return widget.Width(), widget.Height(), true + case authoredWidth: + return widget.Width(), widget.Width()*naturalHeight/naturalWidth + NonContentHeight(widget), true + case authoredHeight: + return widget.Height()*naturalWidth/naturalHeight + NonContentWidth(widget), widget.Height(), true + default: + return naturalWidth + NonContentWidth(widget), naturalHeight + NonContentHeight(widget), true + } +} + +func imageLikeContentSize(widget *StdWidget, naturalWidth, naturalHeight float64) (width, height float64) { + nonContentWidth := NonContentWidth(widget) + nonContentHeight := NonContentHeight(widget) + authoredWidth := widgetWidthSpecified(widget) + authoredHeight := widgetHeightSpecified(widget) + + switch { + case authoredWidth && authoredHeight: + width = max(widget.uncappedWidth()-nonContentWidth, 0) + height = max(widget.uncappedHeight()-nonContentHeight, 0) + case authoredWidth: + width = max(widget.uncappedWidth()-nonContentWidth, 0) + height = width * naturalHeight / naturalWidth + case authoredHeight: + height = max(widget.uncappedHeight()-nonContentHeight, 0) + width = height * naturalWidth / naturalHeight + default: + width = naturalWidth + height = naturalHeight + } + + maxWidth, maxHeight := imageLikeContentBounds(widget, nonContentWidth, nonContentHeight, authoredWidth, authoredHeight) + scale := 1.0 + if maxWidth >= 0 && width > maxWidth && width > 0 { + scale = min(scale, maxWidth/width) + } + if maxHeight >= 0 && height > maxHeight && height > 0 { + scale = min(scale, maxHeight/height) + } + return width * scale, height * scale +} + +func imageLikePlacementSize(widget *StdWidget, naturalWidth, naturalHeight float64) (width, height *float64) { + if naturalWidth <= 0 || naturalHeight <= 0 { + return imageLikeFallbackPlacementSize(widget) + } + + authoredWidth := widgetWidthSpecified(widget) + authoredHeight := widgetHeightSpecified(widget) + constrained := widget.MaxWidthIsSet() || widget.MaxHeightIsSet() || + ((widget.widthValid || widget.heightValid) && !authoredWidth && !authoredHeight) + + if constrained || (authoredWidth && authoredHeight) { + contentWidth, contentHeight := imageLikeContentSize(widget, naturalWidth, naturalHeight) + return &contentWidth, &contentHeight + } + if authoredWidth { + contentWidth := max(widget.Width()-NonContentWidth(widget), 0) + return &contentWidth, nil + } + if authoredHeight { + contentHeight := max(widget.Height()-NonContentHeight(widget), 0) + return nil, &contentHeight + } + return nil, nil +} + +func imageLikeFallbackPlacementSize(widget *StdWidget) (width, height *float64) { + authoredWidth := widgetWidthSpecified(widget) + authoredHeight := widgetHeightSpecified(widget) + if authoredWidth || (widget.widthValid && !authoredWidth) { + contentWidth := max(ContentWidth(widget), 0) + width = &contentWidth + } + if authoredHeight || (widget.heightValid && !authoredHeight) { + contentHeight := max(ContentHeight(widget), 0) + height = &contentHeight + } + return width, height +} + +func imageLikeContentBounds(widget *StdWidget, nonContentWidth, nonContentHeight float64, authoredWidth, authoredHeight bool) (maxWidth, maxHeight float64) { + maxWidth = math.Inf(1) + maxHeight = math.Inf(1) + if widget.MaxWidthIsSet() { + maxWidth = max(widget.MaxWidth()-nonContentWidth, 0) + } + if widget.MaxHeightIsSet() { + maxHeight = max(widget.MaxHeight()-nonContentHeight, 0) + } + if widget.widthValid && !authoredWidth && !authoredHeight { + maxWidth = min(maxWidth, max(widget.Width()-nonContentWidth, 0)) + } + if widget.heightValid && !authoredWidth && !authoredHeight { + maxHeight = min(maxHeight, max(widget.Height()-nonContentHeight, 0)) + } + if math.IsInf(maxWidth, 1) { + maxWidth = -1 + } + if math.IsInf(maxHeight, 1) { + maxHeight = -1 + } + return maxWidth, maxHeight +} diff --git a/ltml/samples/test_016_image.pdf b/ltml/samples/test_016_image.pdf index fda9503..6a79430 100644 Binary files a/ltml/samples/test_016_image.pdf and b/ltml/samples/test_016_image.pdf differ diff --git a/ltml/samples/test_031_render_ltml_images.pdf b/ltml/samples/test_031_render_ltml_images.pdf index 256845f..274f666 100644 Binary files a/ltml/samples/test_031_render_ltml_images.pdf and b/ltml/samples/test_031_render_ltml_images.pdf differ diff --git a/ltml/samples/test_034_svg_image.pdf b/ltml/samples/test_034_svg_image.pdf index fb1102d..b4758c1 100644 Binary files a/ltml/samples/test_034_svg_image.pdf and b/ltml/samples/test_034_svg_image.pdf differ diff --git a/ltml/samples/test_042_svg_tag.pdf b/ltml/samples/test_042_svg_tag.pdf index 28dc33e..52f0405 100644 Binary files a/ltml/samples/test_042_svg_tag.pdf and b/ltml/samples/test_042_svg_tag.pdf differ diff --git a/ltml/samples/test_043_svg_advanced.pdf b/ltml/samples/test_043_svg_advanced.pdf index aa57887..6e74761 100644 Binary files a/ltml/samples/test_043_svg_advanced.pdf and b/ltml/samples/test_043_svg_advanced.pdf differ diff --git a/ltml/std_draw.go b/ltml/std_draw.go index c974aa5..b92c123 100644 --- a/ltml/std_draw.go +++ b/ltml/std_draw.go @@ -18,6 +18,16 @@ type StdDraw struct { key string } +func (d *StdDraw) LayoutWidget(Writer) { + naturalWidth, naturalHeight, ok := d.naturalSize() + if !ok || naturalWidth <= 0 || naturalHeight <= 0 { + return + } + width, height := imageLikeLayoutSize(&d.StdWidget, naturalWidth, naturalHeight) + d.ResolveWidth(width) + d.ResolveHeight(height) +} + func (d *StdDraw) DrawContent(w Writer) error { return withGraphicAccessibility(w, &d.StdWidget, "Figure", func() error { canvas, err := d.resolveCanvas() @@ -52,31 +62,21 @@ func (d *StdDraw) DrawContent(w Writer) error { } func (d *StdDraw) PreferredHeight(Writer) float64 { - if d.height != 0 { - return float64(d.height) - } naturalWidth, naturalHeight, ok := d.naturalSize() if !ok || naturalWidth <= 0 { return NonContentHeight(d) } - if d.width != 0 { - return float64(d.width)*naturalHeight/naturalWidth + NonContentHeight(d) - } - return naturalHeight + NonContentHeight(d) + _, height := imageLikeLayoutSize(&d.StdWidget, naturalWidth, naturalHeight) + return height } func (d *StdDraw) PreferredWidth(Writer) float64 { - if d.width != 0 { - return float64(d.width) - } naturalWidth, naturalHeight, ok := d.naturalSize() if !ok || naturalHeight <= 0 { return NonContentWidth(d) } - if d.height != 0 { - return float64(d.height)*naturalWidth/naturalHeight + NonContentWidth(d) - } - return naturalWidth + NonContentWidth(d) + width, _ := imageLikeLayoutSize(&d.StdWidget, naturalWidth, naturalHeight) + return width } func (d *StdDraw) SetAttrs(attrs map[string]string) { @@ -92,24 +92,7 @@ func (d *StdDraw) placementSize(canvas *StdCanvas) (width, height float64) { if canvas == nil { return 0, 0 } - if d.WidthIsSet() && d.HeightIsSet() { - return ContentWidth(d), ContentHeight(d) - } - if d.WidthIsSet() { - width = ContentWidth(d) - if canvas.Width() == 0 { - return width, 0 - } - return width, width * canvas.Height() / canvas.Width() - } - if d.HeightIsSet() { - height = ContentHeight(d) - if canvas.Height() == 0 { - return 0, height - } - return height * canvas.Width() / canvas.Height(), height - } - return canvas.Width(), canvas.Height() + return imageLikeContentSize(&d.StdWidget, canvas.Width(), canvas.Height()) } func (d *StdDraw) naturalSize() (width, height float64, ok bool) { diff --git a/ltml/std_image.go b/ltml/std_image.go index 5cd071d..8a2a204 100644 --- a/ltml/std_image.go +++ b/ltml/std_image.go @@ -13,6 +13,16 @@ type StdImage struct { src string } +func (img *StdImage) LayoutWidget(w Writer) { + infoWidth, infoHeight, err := img.imageDimensions(w) + if err != nil || infoWidth <= 0 || infoHeight <= 0 { + return + } + width, height := imageLikeLayoutSize(&img.StdWidget, float64(infoWidth), float64(infoHeight)) + img.ResolveWidth(width) + img.ResolveHeight(height) +} + func (img *StdImage) DrawContent(w Writer) error { return withGraphicAccessibility(w, &img.StdWidget, "Figure", func() error { ref, err := img.assetSource() @@ -22,37 +32,28 @@ func (img *StdImage) DrawContent(w Writer) error { if ref.identifier == "" { return fmt.Errorf("image src must be specified") } - _, _, err = w.PrintImageFile(ref.identifier, ContentLeft(img), ContentTop(img), img.widthForWriter(), img.heightForWriter()) + width, height := img.placementSizeForWriter(w) + _, _, err = w.PrintImageFile(ref.identifier, ContentLeft(img), ContentTop(img), width, height) return err }) } func (img *StdImage) PreferredHeight(w Writer) float64 { - if img.height != 0 { - return float64(img.height) - } infoWidth, infoHeight, err := img.imageDimensions(w) if err != nil || infoWidth == 0 { return NonContentHeight(img) } - if img.width != 0 { - return float64(img.width)*float64(infoHeight)/float64(infoWidth) + NonContentHeight(img) - } - return float64(infoHeight) + NonContentHeight(img) + _, height := imageLikeLayoutSize(&img.StdWidget, float64(infoWidth), float64(infoHeight)) + return height } func (img *StdImage) PreferredWidth(w Writer) float64 { - if img.width != 0 { - return float64(img.width) - } infoWidth, infoHeight, err := img.imageDimensions(w) if err != nil || infoHeight == 0 { return NonContentWidth(img) } - if img.height != 0 { - return float64(img.height)*float64(infoWidth)/float64(infoHeight) + NonContentWidth(img) - } - return float64(infoWidth) + NonContentWidth(img) + width, _ := imageLikeLayoutSize(&img.StdWidget, float64(infoWidth), float64(infoHeight)) + return width } func (img *StdImage) imageDimensions(w Writer) (width, height int, err error) { @@ -87,20 +88,12 @@ func (img *StdImage) String() string { return fmt.Sprintf("StdImage src=%s %s", img.src, &img.StdWidget) } -func (img *StdImage) widthForWriter() *float64 { - if img.WidthIsSet() { - width := ContentWidth(img) - return &width - } - return nil -} - -func (img *StdImage) heightForWriter() *float64 { - if img.HeightIsSet() { - height := ContentHeight(img) - return &height +func (img *StdImage) placementSizeForWriter(w Writer) (width, height *float64) { + infoWidth, infoHeight, err := img.imageDimensions(w) + if err != nil { + return imageLikeFallbackPlacementSize(&img.StdWidget) } - return nil + return imageLikePlacementSize(&img.StdWidget, float64(infoWidth), float64(infoHeight)) } func init() { diff --git a/ltml/std_image_test.go b/ltml/std_image_test.go index 6e6a304..1fe650d 100644 --- a/ltml/std_image_test.go +++ b/ltml/std_image_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -113,6 +114,37 @@ func TestStdImage_PreferredWidth_InfersAspectRatioFromHeight(t *testing.T) { } } +func TestStdImage_MaxHeightFitsSliverAspectRatio(t *testing.T) { + img := &StdImage{src: "sliver.jpg"} + img.SetDoc(newDocWithOptions(WithAssetFS(testingMapFS("sliver.jpg", "image-data")))) + img.SetMaxHeight(100) + w := &imageTestWriter{dimensions: map[string][2]int{"sliver.jpg": {38, 1080}}} + + wantWidth := 100.0 * 38.0 / 1080.0 + if got := img.PreferredHeight(w); got != 100 { + t.Fatalf("PreferredHeight() = %v, want 100", got) + } + if got := img.PreferredWidth(w); got < wantWidth-0.001 || got > wantWidth+0.001 { + t.Fatalf("PreferredWidth() = %v, want approx %v", got, wantWidth) + } +} + +func TestStdImage_MaxWidthAndMaxHeightUseDominantCap(t *testing.T) { + img := &StdImage{src: "sliver.jpg"} + img.SetDoc(newDocWithOptions(WithAssetFS(testingMapFS("sliver.jpg", "image-data")))) + img.SetMaxWidth(20) + img.SetMaxHeight(100) + w := &imageTestWriter{dimensions: map[string][2]int{"sliver.jpg": {38, 1080}}} + + wantWidth := 100.0 * 38.0 / 1080.0 + if got := img.PreferredHeight(w); got != 100 { + t.Fatalf("PreferredHeight() = %v, want height cap 100", got) + } + if got := img.PreferredWidth(w); got < wantWidth-0.001 || got > wantWidth+0.001 { + t.Fatalf("PreferredWidth() = %v, want approx %v", got, wantWidth) + } +} + func TestStdImage_DrawContent_UsesContentBoxDimensions(t *testing.T) { img := &StdImage{src: "fixture.jpg"} img.SetDoc(newDocWithOptions(WithAssetFS(testingMapFS("fixture.jpg", "image-data")))) @@ -147,6 +179,56 @@ func TestStdImage_DrawContent_UsesContentBoxDimensions(t *testing.T) { } } +func TestStdImage_DrawContent_FitsResolvedCellWithoutStretching(t *testing.T) { + img := &StdImage{src: "sliver.jpg"} + img.SetDoc(newDocWithOptions(WithAssetFS(testingMapFS("sliver.jpg", "image-data")))) + img.ResolveWidth(200) + img.ResolveHeight(100) + w := &imageTestWriter{dimensions: map[string][2]int{"sliver.jpg": {38, 1080}}} + + if err := img.DrawContent(w); err != nil { + t.Fatal(err) + } + if len(w.fileCalls) != 1 { + t.Fatalf("file call count = %d, want 1", len(w.fileCalls)) + } + call := w.fileCalls[0] + wantWidth := 100.0 * 38.0 / 1080.0 + if call.width == nil || *call.width < wantWidth-0.001 || *call.width > wantWidth+0.001 { + t.Fatalf("width = %v, want approx %v", call.width, wantWidth) + } + if call.height == nil || *call.height != 100 { + t.Fatalf("height = %v, want 100", call.height) + } +} + +func TestStdImage_TableWithCappedSliverPrintsFollowingCell(t *testing.T) { + doc, err := Parse([]byte(` + + + + + +`), WithAssetFS(testingMapFS("sliver.jpg", "image-data"))) + if err != nil { + t.Fatal(err) + } + w := &imageTestWriter{ + labelTestWriter: labelTestWriter{t: t}, + dimensions: map[string][2]int{"sliver.jpg": {38, 1080}}, + } + + if err := doc.Print(w); err != nil { + t.Fatal(err) + } + if len(w.fileCalls) != 1 { + t.Fatalf("file call count = %d, want 1", len(w.fileCalls)) + } + if got := captureTexts(&w.labelTestWriter); !strings.Contains(got, "after") { + t.Fatalf("printed text = %q, want following table cell to print", got) + } +} + func TestParse_ImageTag_UsesAssetFSReferenceWithoutLoadingBytes(t *testing.T) { doc, err := Parse([]byte(` diff --git a/ltml/std_page.go b/ltml/std_page.go index 0e2dfc3..c62e2fb 100644 --- a/ltml/std_page.go +++ b/ltml/std_page.go @@ -263,8 +263,28 @@ func (p *StdPage) drawGrid(w Writer) error { }) } +type LayoutOverflowError struct { + PagePath string + WidgetPath string + AvailableHeight float64 + RequiredHeight float64 +} + var errNoProgressPage = errors.New("page overflow retry would print no display=once direct children") +func (err *LayoutOverflowError) Error() string { + if err == nil { + return "" + } + return fmt.Sprintf( + "ltml layout overflow: page %s cannot place pending widget %s (available height %.2fpt, required height %.2fpt); set max-height, reduce content size, or enable compatible splitting", + err.PagePath, + err.WidgetPath, + err.AvailableHeight, + err.RequiredHeight, + ) +} + func (p *StdPage) drawVisibleChildren(w Writer) (int, error) { printedOnce := 0 children := slices.Clone(p.Widgets()) @@ -376,7 +396,11 @@ func (p *StdPage) preparePhysicalPage(w Writer, force bool) error { doc.physicalPageNo = savedPhysicalPageNo doc.pendingStart = savedPendingStart } - return errNoProgressPage + err := p.newLayoutOverflowError(probe) + if err == nil { + return errNoProgressPage + } + return err } p.rebuildActiveChildren() p.newPhysicalPage(w) @@ -478,6 +502,29 @@ func (p *StdPage) initFlowItems() { } } +func (p *StdPage) newLayoutOverflowError(w Writer) error { + for _, child := range p.Widgets() { + if child.Display() != DisplayOnce || child.Printed() || widgetZeroFootprint(child) { + continue + } + available := p.availableHeightForChild(child) + required := child.Height() + if required == 0 || !child.HeightIsSet() { + required = child.PreferredHeight(w) + } + if required <= available+layoutFitEpsilon { + continue + } + return &LayoutOverflowError{ + PagePath: p.Path(), + WidgetPath: child.Path(), + AvailableHeight: available, + RequiredHeight: required, + } + } + return nil +} + func (p *StdPage) rebuildActiveChildren() { if len(p.flowItems) == 0 { p.activeChildren = nil diff --git a/ltml/std_svg.go b/ltml/std_svg.go index 3660d1d..72a9c57 100644 --- a/ltml/std_svg.go +++ b/ltml/std_svg.go @@ -13,6 +13,13 @@ type StdSVG struct { } func (svg *StdSVG) LayoutWidget(w Writer) { + infoWidth, infoHeight, err := svg.svgDimensions(w) + if err != nil || infoWidth <= 0 || infoHeight <= 0 { + return + } + width, height := imageLikeLayoutSize(&svg.StdComponent.StdWidget, float64(infoWidth), float64(infoHeight)) + svg.ResolveWidth(width) + svg.ResolveHeight(height) } func (svg *StdSVG) DrawContent(w Writer) error { @@ -25,44 +32,36 @@ func (svg *StdSVG) DrawContent(w Writer) error { if ref.identifier == "" { return fmt.Errorf("svg src or inline body must be specified") } - _, _, err = w.PrintSVGFile(ref.identifier, ContentLeft(svg), ContentTop(svg), svg.widthForWriter(), svg.heightForWriter()) + width, height := svg.placementSizeForWriter(w) + _, _, err = w.PrintSVGFile(ref.identifier, ContentLeft(svg), ContentTop(svg), width, height) return err } body := svg.Body() if strings.TrimSpace(body) == "" { return fmt.Errorf("svg src or inline body must be specified") } - _, _, err := w.PrintSVG([]byte(body), ContentLeft(svg), ContentTop(svg), svg.widthForWriter(), svg.heightForWriter()) + width, height := svg.placementSizeForWriter(w) + _, _, err := w.PrintSVG([]byte(body), ContentLeft(svg), ContentTop(svg), width, height) return err }) } func (svg *StdSVG) PreferredHeight(w Writer) float64 { - if svg.height != 0 { - return float64(svg.height) - } infoWidth, infoHeight, err := svg.svgDimensions(w) if err != nil || infoWidth == 0 { return NonContentHeight(svg) } - if svg.width != 0 { - return float64(svg.width)*float64(infoHeight)/float64(infoWidth) + NonContentHeight(svg) - } - return float64(infoHeight) + NonContentHeight(svg) + _, height := imageLikeLayoutSize(&svg.StdComponent.StdWidget, float64(infoWidth), float64(infoHeight)) + return height } func (svg *StdSVG) PreferredWidth(w Writer) float64 { - if svg.width != 0 { - return float64(svg.width) - } infoWidth, infoHeight, err := svg.svgDimensions(w) if err != nil || infoHeight == 0 { return NonContentWidth(svg) } - if svg.height != 0 { - return float64(svg.height)*float64(infoWidth)/float64(infoHeight) + NonContentWidth(svg) - } - return float64(infoWidth) + NonContentWidth(svg) + width, _ := imageLikeLayoutSize(&svg.StdComponent.StdWidget, float64(infoWidth), float64(infoHeight)) + return width } func (svg *StdSVG) svgDimensions(w Writer) (width, height int, err error) { @@ -91,20 +90,12 @@ func (svg *StdSVG) String() string { return fmt.Sprintf("StdSVG src=%s %s", svg.source.src, &svg.StdComponent.StdWidget) } -func (svg *StdSVG) widthForWriter() *float64 { - if svg.WidthIsSet() { - width := ContentWidth(svg) - return &width - } - return nil -} - -func (svg *StdSVG) heightForWriter() *float64 { - if svg.HeightIsSet() { - height := ContentHeight(svg) - return &height +func (svg *StdSVG) placementSizeForWriter(w Writer) (width, height *float64) { + infoWidth, infoHeight, err := svg.svgDimensions(w) + if err != nil { + return imageLikeFallbackPlacementSize(&svg.StdComponent.StdWidget) } - return nil + return imageLikePlacementSize(&svg.StdComponent.StdWidget, float64(infoWidth), float64(infoHeight)) } func init() { diff --git a/ltml/std_svg_test.go b/ltml/std_svg_test.go index 3658970..30ca443 100644 --- a/ltml/std_svg_test.go +++ b/ltml/std_svg_test.go @@ -102,6 +102,44 @@ func TestStdSVG_PreferredWidth_InfersAspectRatioFromHeight(t *testing.T) { } } +func TestStdSVG_MaxHeightFitsAspectRatio(t *testing.T) { + svg := &StdSVG{} + svg.body = `` + svg.SetMaxHeight(100) + w := &svgTestWriter{inlineDimensions: [2]int{38, 1080}} + + wantWidth := 100.0 * 38.0 / 1080.0 + if got := svg.PreferredHeight(w); got != 100 { + t.Fatalf("PreferredHeight() = %v, want 100", got) + } + if got := svg.PreferredWidth(w); got < wantWidth-0.001 || got > wantWidth+0.001 { + t.Fatalf("PreferredWidth() = %v, want approx %v", got, wantWidth) + } +} + +func TestStdSVG_DrawContent_FitsResolvedCellWithoutStretching(t *testing.T) { + svg := &StdSVG{} + svg.body = `` + svg.ResolveWidth(200) + svg.ResolveHeight(100) + w := &svgTestWriter{inlineDimensions: [2]int{38, 1080}} + + if err := svg.DrawContent(w); err != nil { + t.Fatal(err) + } + if len(w.inlineCalls) != 1 { + t.Fatalf("inline call count = %d, want 1", len(w.inlineCalls)) + } + call := w.inlineCalls[0] + wantWidth := 100.0 * 38.0 / 1080.0 + if call.width == nil || *call.width < wantWidth-0.001 || *call.width > wantWidth+0.001 { + t.Fatalf("width = %v, want approx %v", call.width, wantWidth) + } + if call.height == nil || *call.height != 100 { + t.Fatalf("height = %v, want 100", call.height) + } +} + func TestStdSVG_DrawContent_UsesContentBoxDimensionsForInlineSVG(t *testing.T) { svg := &StdSVG{} svg.body = `` diff --git a/ltml/std_widget.go b/ltml/std_widget.go index 7214fff..0b45d0e 100644 --- a/ltml/std_widget.go +++ b/ltml/std_widget.go @@ -762,6 +762,10 @@ func (widget *StdWidget) Units() Units { } func (widget *StdWidget) Width() float64 { + return widget.capWidth(widget.uncappedWidth()) +} + +func (widget *StdWidget) uncappedWidth() float64 { if widget.widthValid { return float64(widget.width) } @@ -780,6 +784,10 @@ func (widget *StdWidget) Width() float64 { } func (widget *StdWidget) Height() float64 { + return widget.capHeight(widget.uncappedHeight()) +} + +func (widget *StdWidget) uncappedHeight() float64 { if widget.heightValid { return float64(widget.height) } @@ -797,6 +805,58 @@ func (widget *StdWidget) Height() float64 { return 0 } +func (widget *StdWidget) MaxWidth() float64 { + switch widget.max.widthMode { + case DimPct: + if widget.container == nil { + return 0 + } + return float64(widget.max.widthValue) / 100.0 * ContentWidth(widget.container) + case DimRel: + if widget.container == nil { + return float64(widget.max.widthValue) + } + return ContentWidth(widget.container) + float64(widget.max.widthValue) + case DimLiteral: + return float64(widget.max.widthValue) + default: + return 0 + } +} + +func (widget *StdWidget) MaxHeight() float64 { + switch widget.max.heightMode { + case DimPct: + if widget.container == nil { + return 0 + } + return float64(widget.max.heightValue) / 100.0 * ContentHeight(widget.container) + case DimRel: + if widget.container == nil { + return float64(widget.max.heightValue) + } + return ContentHeight(widget.container) + float64(widget.max.heightValue) + case DimLiteral: + return float64(widget.max.heightValue) + default: + return 0 + } +} + +func (widget *StdWidget) capWidth(width float64) float64 { + if widget.MaxWidthIsSet() { + width = min(width, widget.MaxWidth()) + } + return max(width, 0) +} + +func (widget *StdWidget) capHeight(height float64) float64 { + if widget.MaxHeightIsSet() { + height = min(height, widget.MaxHeight()) + } + return max(height, 0) +} + func (widget *StdWidget) HeightIsSet() bool { return widget.Dimensions.HeightIsSet() || (widget.sides[topSide].IsSet && widget.sides[bottomSide].IsSet) } diff --git a/ltml/widget.go b/ltml/widget.go index 84fc5cc..d7b860a 100644 --- a/ltml/widget.go +++ b/ltml/widget.go @@ -46,10 +46,18 @@ type Widget interface { SetWidthRel(value float64) ResolveWidth(value float64) ClearResolvedWidth() + SetMaxHeight(value float64) + SetMaxWidth(value float64) + ClearMaxHeight() + ClearMaxWidth() Height() float64 HeightIsSet() bool HeightMode() DimensionMode + MaxHeight() float64 + MaxHeightIsSet() bool + MaxWidth() float64 + MaxWidthIsSet() bool Width() float64 WidthMode() DimensionMode WidthPctIsSet() bool