How does `scale` work within grids?

Can someone help me out to understand the behaviour of the scale function inside of a grid.

The scale function has three parameters, which affect the scaling of the body: A positional parameter factor and two named parameters x and y - all of which can either be a length, a ratio or auto.

I thought, that the factor scales the body symmetrically in x- and y-direction, but this isn’t always the case. For illustration see the following example code + screenshot

#let sq = rect(fill: blue, 
               stroke: red,
               width: 15mm,
               height: 15mm)

#let sz = 5mm

#let scale-list = ("scale(sz, sq)",
                   "scale(sz, sq)",
                   "scale(sz, x: auto, sq)",
                   "scale(sz, y: auto, sq)",
                   "scale(auto, x: auto, y: sz, sq)",
                   "scale(auto, x: sz, y: auto, sq)",
                   "scale(sz, x:100%, sq)",
                   "scale(sz, x:1.3 * sz, y: 1.1 * sz, sq)",
                   "",
                   "scale(sz, y: 100%, sq)",
                   "",
                   "scale(sz, x: 100%, y: 100%, sq)",
                   "")

#let grid-list = ()
#for i in range(scale-list.len()) { 
  grid-list.push(raw(str(i + 1) + ": " + scale-list.at(i)))
  grid-list.push(eval(scale-list.at(i), scope: (sz: sz, sq: sq)))
}

#grid(
    stroke: gray,
    gutter: 0mm,
    column-gutter: 0mm,
    row-gutter: 0mm,
    inset: 0mm,
    columns: (1fr, auto),
    rows: (20mm, 10mm),
    align: horizon,
    fill: yellow.lighten(90%),
    ..grid-list)

Screenshot (as a guide for the eye I placed 5mm x 5mm grid lines in front of the image):

The following specific questions arise

  • Why is the “square” in row 2 rectangular and not square?
  • Why is the square in row 3 not 5mm x 5mm?
  • Why is the factor in row 7 ignored and width and height are different?
  • What is going on in row 8?
  • Why is the square height in row 10 and 11 larger than the row height even though the y parameter is 100%?

And in general

  • There are 3 parameters for scaling in 2 dimensions. What is the overall logic if different combinations of auto, ratio and length parameters are given?
  • What is the precedence if all three parameters are specified?
  • How does the auto keyword work in conjunction with the other parameters?
2 Likes

Update: You could skip to My Conclusions below.

There are 3 parameters for scaling in 2 dimensions. What is the overall logic if different combinations of auto, ratio and length parameters are given?

The factor is just an optional shorthand notation for setting x and y to the same value.

#assert.eq(
  scale(100%, rect()),
  scale(x: 100%, y: 100%, rect()),
)
#repr(scale(100%, rect()))

The above code compiles and gives the following.

scale(x: 100%, y: 100%, body: rect())

In your example:

Code
#let scale-list = (
  "scale(sz, sq)",
  "scale(sz, sq)",
  "scale(sz, x: auto, sq)",
  "scale(sz, y: auto, sq)",
  "scale(auto, x: auto, y: sz, sq)",
  "scale(auto, x: sz, y: auto, sq)",
  "scale(sz, x:100%, sq)",
  "scale(sz, x:1.3 * sz, y: 1.1 * sz, sq)",
  "",
  "scale(sz, y: 100%, sq)",
  "",
  "scale(sz, x: 100%, y: 100%, sq)",
  "",
)

#enum(
  tight: false,
  ..scale-list
    .map(expr => {
      set raw(lang: "typc")
      [Input: ]
      raw(expr.replace("sz", "10pt").replace("sq", "[]"))
      linebreak()
      [Output: ]
      raw(repr(eval(expr, scope: (sz: 10pt, sq: []))))
    })
    .map(enum.item),
)

In factor, the factor field is marked as #[external]. It means that “it appears in the documentation, but is otherwise ignored”, and it’s added only “to do something manually for more flexibility”.

Implementation details

I cannot fully understand its behaviour either, but it looks like the logic is here:

Rust code

Function signature

/// Resolves scale parameters, preserving aspect ratio if one of the scales
/// is set to `auto`.
fn resolve_scale(
    elem: &Packed<ScaleElem>,
    engine: &mut Engine,
    locator: Locator,
    container: Size,
    styles: StyleChain,
) -> SourceResult<Axes<Ratio>> {

Resolve auto | length | ratio to auto | ratio

Ok(match axis {
    Smart::Auto => Smart::Auto,
    Smart::Custom(amt) => Smart::Custom(match amt {
        ScaleAmount::Ratio(ratio) => ratio,
        ScaleAmount::Length(length) => {
            let length = length.resolve(styles);
            Ratio::new(length / body()?)
        }
    }),
})

Resolve auto | ratio to ratio

match (x, y) {
    (Smart::Auto, Smart::Auto) => {
        bail!(elem.span(), "x and y cannot both be auto")
    }
    (Smart::Custom(x), Smart::Custom(y)) => Ok(Axes::new(x, y)),
    (Smart::Auto, Smart::Custom(v)) | (Smart::Custom(v), Smart::Auto) => {
        Ok(Axes::splat(v))
    }
}

Use ratio

let size = region
    .size
    .zip_map(scale, |r, s| if r.is_finite() { Ratio::new(1.0 / s).of(r) } else { r })
    .map(Abs::abs);

measure_and_layout(
    engine,
    locator,
    region,
    size,
    styles,
    &elem.body,
    Transform::scale(scale.x, scale.y),
    elem.origin.resolve(styles),
    elem.reflow.get(styles),
)

What are the effects of factor, x, and y of ScaleElem? - Search | DeepWiki

Related: `scale`-d grid doesn't get correctly layouted · Issue #3148 · typst/typst · GitHub.

I’ve created a dedicated issue:

(I’ve updated my previous post to answer your first general question, in case you missed that.)

We get weird result because

  • there’s no enough space to put sq — the height of sq is 15 mm, but only 10 mm is available in the row (expect the first row)
  • the scale factor is specified as an absolute length, instead of ratio.

According to the rust code, I think the relation between these heights is the following:

#let calc-result-height(
  available-height,
  content-height,
  scale-abs,
) = {
  let scale-ratio = (
    scale-abs / calc.min(available-height, content-height)
  )
  content-height * scale-ratio
}

Code
#let calc-result-height(available: 0pt, content: 0pt, scale-abs: 0pt) = {
  // Here `0pt` are only placeholders. This function only expects positive lengths.
  let scale-ratio = scale-abs / calc.min(available, content)
  content * scale-ratio
}

#let scale-abs = 10pt
#let content-height = 10pt
#let available-heights = (20pt, 15pt, 10pt, 8pt, 4pt, 2pt, 1pt, 0.5pt)

#assert.eq(type(scale-abs), length)

#let it = scale(scale-abs, box(
  fill: orange.transparentize(50%),
  width: 10pt,
  height: content-height,
))

#set page(width: auto, height: auto, margin: 1em)

A #content-height square: #box(width: 10pt, height: content-height, fill: purple, baseline: 20%)

#let raw-repr(it) = raw(repr(it), lang: "typc")

#table(
  columns: 2 + available-heights.len(),
  align: (x, _) => if x > 0 { center } else { start } + horizon,
  stroke: (x, y) => if y > 0 { (top: 0.5pt) } + if x > 0 { (left: 0.5pt) },

  [*Height of \ available space*],
  $+oo$,
  ..available-heights.map(raw-repr),

  [
    *Height of* \
    #raw(
      lang: "typc",
      ```typc
      scale(
        {scale-abs},
        box(height: {content-height})
      )```
        .text
        .replace("{scale-abs}", repr(scale-abs))
        .replace("{content-height}", repr(content-height)),
    )
  ],
  grid(
    align: horizon,
    columns: 4,
    column-gutter: 2pt,
    raw-repr(content-height),
    box(width: 0.5pt, height: content-height, fill: black),
    it,
  ),
  ..available-heights.map(h => {
    let result = calc-result-height(
      available: h,
      content: content-height,
      scale-abs: scale-abs,
    )

    grid(
      align: horizon,
      columns: 4,
      column-gutter: 2pt,
      raw-repr(result),
      box(width: 0.5pt, height: result, fill: black),
      box(height: h, stroke: gray, it),
    )
  }),
)

My conclusions

(for typst 0.13.1)

Short answers to specific questions

Why is the “square” in row 2 rectangular and not square?
row 2: scale(sz, sq)

  • width: 5 mm, as specified.

  • height: there’s no enough space to put the original 15 mm sq, and the result height is determined by:

    let scale-ratio = scale-abs / calc.min(available-height, content-height)
    // = 5mm / calc.min(10mm, 15mm)
    // = 50%
    
    let result-height = content-height * scale-ratio
    // = 15mm * 50%
    // = 7.5mm
    

Why is the square in row 3 not 5mm x 5mm?
row 3: scale(sz, x: auto, sq)

auto means to preserve the aspect ratio.

  1. height: 7.5 mm, the same as row 2.
  2. width: 15 mm * 50% = 7.5 mm, as implied by x: auto, which means the same ratio in y axis.

Why is the factor in row 7 ignored and width and height are different?
row 7: scale(sz, x: 100%, sq)

The precedence of factor, x, y is:

  • Resolved x = first value that is not none in the list (x, factor, 100%).
  • Resolved y = first value that is not none in the list (y, factor, 100%).

Therefore,

  • width: 15 mm * 100% = 15 mm, as specified by x: 100% — it’s relative to the original size of the content.
  • height: 7.5 mm, the same as row 2.

What is going on in row 8?
row 8: scale(sz, x:1.5 * sz, y: 1.1 * sz, sq)

As explained in row 7, the resolved x is 1.5 * sz, and resolved y is 1.1 * sz.

  • width: 1.5 * 5 mm = 7.5 mm, as specified by x: 1.5 * sz.
  • height: 15mm * ((1.1 * 5mm) / 10mm), as specified by y: 1.1 * sz and similar to row 2.

Why is the square height in row 10 and 12 larger than the row height even though the y parameter is 100%?
row 10: scale(sz, y: 100%, sq)
row 12: scale(sz, x: 100%, y: 100%, sq)

ratios are relative to the original size of the content, not available region.

Row 10:

  • width: 5 mm, as specified.
  • height: 15 mm * 100% = 15 mm, as specified.

Row 12:

  • width: 15 mm * 100% = 15 mm, as specified.
  • height: 15 mm * 100% = 15 mm, as specified.

Answer to general questions

  1. Resolve (factor, x, y) into (x, y).

    • Resolved x = first value that is not none in the list (x, factor, 100%).
    • Resolved y = first value that is not none in the list (y, factor, 100%).

    Here factor, x, y can be auto | length | ratio.

  2. Convert auto | length | ratio to ratio.

    1. lengthratio: As explained in row 2, length ↦ length / min(available-size, content-size).
    2. autoratio: Use the ratio in the other axis, keeping the aspect ratio.
    3. ratio is relative to content-size.
1 Like

Thanks for your detailed analysis. I learned a lot :smile:

overall logic

The factor is just an optional shorthand notation for setting x and y to the same value.

To make it more explicit:
If factor is given, then both named parameters x and y are set to that value. If later also named parameters are given, then those overwrite the previously set values. If a named parameter is auto, then scaling is according to the other parameter and the dimension of the auto parameter is scaled such that the original aspect ratio is preserved.

If the type of a parameter is a ratio then scaling is performed relative to the original content size and not relative to the available space.

The default for both x and y parameters is 100%.

Strange things can happen, if the unscaled (sic!) content does not fit into the available space.

implementation details

While I am happy to read code, rust is a different kind of beast …

This

let scale-ratio = scale-abs / calc.min(available-height, content-height)

really gives weird results.

Anyhow, thanks for the issue 6636.

1 Like