Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ltml/SYNTAX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 `<pen>` style. |
Expand Down Expand Up @@ -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 `<pen>` style. |
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions ltml/canvas_draw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,62 @@ func captureTexts(w *labelTestWriter) string {
return b.String()
}

func TestStdDraw_MaxHeightFitsAspectRatio(t *testing.T) {
doc := parseCanvasDoc(t, `
<ltml>
<canvas key="sliver" width="38" height="1080" />
<page>
<draw key="sliver" max-height="100" />
</page>
</ltml>`)
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, `
<ltml>
<canvas key="sliver" width="38" height="1080" />
<page>
<draw key="sliver" />
</page>
</ltml>`)
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(`
<ltml>
Expand Down
105 changes: 104 additions & 1 deletion ltml/dimensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -45,6 +53,7 @@ type dimensionState struct {
type dimensionsState struct {
width dimensionState
height dimensionState
max maxDimensions
}

var (
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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
}
66 changes: 56 additions & 10 deletions ltml/dimensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
})
}
}
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 14 additions & 20 deletions ltml/layout_overflow_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ltml

import (
"errors"
"slices"
"strings"
"testing"
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading
Loading