From eaeb9ffb40d7b589ff2383902685c89d14d64378 Mon Sep 17 00:00:00 2001 From: Brent Rowland Date: Sun, 10 May 2026 12:01:17 -0700 Subject: [PATCH] Add max dimension caps for LTML media --- ltml/SYNTAX.md | 7 + ltml/canvas_draw_test.go | 56 ++++++++ ltml/dimensions.go | 105 ++++++++++++++- ltml/dimensions_test.go | 66 ++++++++-- ltml/layout_overflow_test.go | 34 ++--- ltml/media_fit.go | 129 +++++++++++++++++++ ltml/samples/test_016_image.pdf | Bin 16227 -> 16227 bytes ltml/samples/test_031_render_ltml_images.pdf | Bin 61094 -> 61042 bytes ltml/samples/test_034_svg_image.pdf | Bin 36080 -> 36077 bytes ltml/samples/test_042_svg_tag.pdf | Bin 30746 -> 30748 bytes ltml/samples/test_043_svg_advanced.pdf | Bin 52930 -> 52952 bytes ltml/std_draw.go | 47 +++---- ltml/std_image.go | 49 +++---- ltml/std_image_test.go | 82 ++++++++++++ ltml/std_page.go | 49 ++++++- ltml/std_svg.go | 49 +++---- ltml/std_svg_test.go | 38 ++++++ ltml/std_widget.go | 60 +++++++++ ltml/widget.go | 8 ++ 19 files changed, 658 insertions(+), 121 deletions(-) create mode 100644 ltml/media_fit.go 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 fda9503ff841c34d60a72bf8fc0004977a0ea2c4..6a79430d6e6a4ae148edfa412251c75b700b150b 100644 GIT binary patch delta 31 mcmaD{_qcAuAvqxq3k4Gc1v5iKJquF?3`V|1TVG1q) diff --git a/ltml/samples/test_031_render_ltml_images.pdf b/ltml/samples/test_031_render_ltml_images.pdf index 256845f1c5834f501b7a0419ca29fb6d74d7e644..274f66623a2ebc254d030441fbacd1d05d853e49 100644 GIT binary patch delta 470 zcmZ9HzfJ-{5XQrSN=~W#6AO!Nuv~X%cW-YaRQ{<Bzq3AKfVwrbpisK{kaql=FqmFa36W6WcS2821BuW7F ziQ+5@gR{gGryPtT%BLPtT2jzV;Z50KHyDXFcGsLSXSPYFY?O@r;kx`yCtU59njNp`ypiypu9O zC4L?(Cp8s#TAIYo+U=NNlp9ZBwhq%agJ2%?=^zvZBYey>d%@Gdt>AeAFY7NqtUPub zqrbe5#+~gYJ{+19FxzCB`7|)Q)+UPz&365Wal+z9_S{|-Tyrrj`9l7eV!u6o&?vPw mFf5q?R;{`nl!{e1mva{iVOS$}4Z-8rITsXnv)PsPRredKtAL3B delta 488 zcmZ{fze)o^5XQ+x?!sB5(13;LHnFf+c6Rn}KrjSt6np_21wqkhl)GT5m5oi<#>!Ic zRIc|4B&~(|0u~llVj=G3B51&EX6Ku4zu)ZpbNT9}eESdtT4nvTVLfGqe6iq z5;-+Q0#b`cxYO=@*4x=GO-jm?_5(w`M>C4Z!QHj3n>`4#)!mgKl1QbuWS8w$$)_m= znsDzIgahPh=+nUaKPKdGib%jahO;Tx=#1ASAM^zYh3EO80jZjV5i+U~q`T$2=Q0Z6AW{z*t4T*&1hwR6LilCJom5fn;C7~Je XQOO7~Dp@5ReQU(XFs|2^)|TUMF&=&@ diff --git a/ltml/samples/test_034_svg_image.pdf b/ltml/samples/test_034_svg_image.pdf index fb1102d08a6208c4715b9b510efb4d446b835f17..b4758c188c719c5639e42968562a4af94259547a 100644 GIT binary patch delta 211 zcmew`lj-eDrVaf}jAolBFfA6~F;n2Ov*Ri*DN0Suog64^K3PFRXmf{f6bpy3f{B@e zshR2KSK{uBECza(Mw?9~of)}|OcV^wEEEh4EGF*}m*!P40765MS`#CS&1Dr%v_KUyGBPzaLK8AJHa0^SGBGutyr##R)6kMjRn^tsjSB#c C9WmSh delta 214 zcmaDmlj*}urVaf}jFy`xFfA6~u~gu)v*Ri*DN0Suog64^K3PFRXmf{f6bpxif{B@e zshPp%SK{uBEQWdp2AfSKof)|dEfoySEEEh4jVJFBmljYk079UwDM-1Ak@4nLQu7NF zOcV@2Kp~F{OdFV+S{k7XnHw0Q3KhHz{ E02G@tQ~&?~ diff --git a/ltml/samples/test_042_svg_tag.pdf b/ltml/samples/test_042_svg_tag.pdf index 28dc33e7b49a330dbfa139ab63b043060bb9b28d..52f04057ee6e4c3c133020eb84326a2b26776a4a 100644 GIT binary patch delta 169 zcmbRBfpN|U#toTFOeO}KvzZJ8LG)ZhHz{0D_$+3IG5A delta 167 zcmbR9fpOLc#toTFOvV8d{!m-)tA<_#xVm`x2$CdX@rZ&qftR$;W<9Iu%x3KH9V-Y$(pz{1STT*1T)h%EJt zfnc+(yDOt0Sk%}MAw0R;!wRVPyq!7Y=D(i5Sh!*OKnDHyb7ka#YXOP)2JEe^H!@N% z00D(OE--CmU}k87CS+)8Xn-zcW@3ygWNcw%V2CbcX=#ZjWNBz<0T(JMn%sHLfYZ!^ LOI6j?-;E0Zalb)r delta 260 zcmcaHm-*0K<_#xVm`yA#CdX@rZ&qftR$(;W9Iu%x3KH9V-Y$)U!&1S-42VoNySlqF za#|`F8-lr$XM0!y)tn7XUyxKivQT 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