diff --git a/packages/preview/framefit/0.1.0/LICENSE b/packages/preview/framefit/0.1.0/LICENSE new file mode 100644 index 0000000000..ca749f5f1f --- /dev/null +++ b/packages/preview/framefit/0.1.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Patrick Fürderer-Tosolini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/framefit/0.1.0/README.md b/packages/preview/framefit/0.1.0/README.md new file mode 100644 index 0000000000..66616c3978 --- /dev/null +++ b/packages/preview/framefit/0.1.0/README.md @@ -0,0 +1,281 @@ +# Framefit + +Fit text into fixed Typst frames by adjusting the text size. + +Framefit is useful when the frame size is fixed but the text is variable: +labels, badges, cards, flyers, product sheets, certificates, data-driven +templates, or any layout where user-provided copy must stay inside a known box. + +The package measures the rendered text and chooses a font-size percentage that +fits the available width and height. It can grow short text, shrink long text, +cap the allowed growth, or keep text within a maximum number of lines. + +## Preview + +Finite maximum: + +![Framefit example with finite max percentage](assets/grows-to-max.webp) + +No configured maximum: + +![Framefit example with max none](assets/no-maximum.webp) + +Shrink to minimum: + +![Framefit example shrinking text to the minimum size](assets/shrinks-to-min.webp) + +Maximum lines: + +![Framefit example limiting fitted text to three lines](assets/max-lines.webp) + +Font and line spacing sensitivity: + +![Framefit example with different fonts and paragraph leading](assets/max-lines-styles.webp) + +## Features + +- Fit text to a fixed `width` and `height`. +- Grow short text up to the largest fitting size. +- Shrink overflowing text down to a configured minimum. +- Use `max: none` or `max: -1` for no configured upper limit. +- Use a finite `max`, for example `max: 140%`, to cap growth. +- Use `max-lines`, for example `max-lines: 3`, to keep text within a measured + line count. +- Use `only-if-overflow: true` to keep normal text unchanged unless it would + overflow. + +## Installation + +Use the package from Typst Universe: + +```typst +#import "@preview/framefit:0.1.0": framefit, fit-copy +``` + +## Quick Start + +Create a fitted frame directly: + +```typst +#import "@preview/framefit:0.1.0": framefit + +#framefit( + width: 70mm, + height: 24mm, + min: 70%, + max: none, + inset: 6pt, + stroke: 0.5pt, +)[ + This text grows or shrinks until it fits the frame. +] +``` + +Use the lower-level helper inside an existing frame: + +```typst +#import "@preview/framefit:0.1.0": fit-copy + +#block(width: 70mm, height: 24mm, stroke: 0.5pt, inset: 6pt)[ + #fit-copy(min: 70%)[ + This text uses the surrounding block as the frame. + ] +] +``` + +## Common Recipes + +### Grow Until The Frame Is Full + +`max: none` is the default. Framefit grows the text until it first overflows, +then backs off to the largest fitting size. + +```typst +#framefit(width: 60mm, height: 18mm, min: 70%, max: none)[ + Short headline +] +``` + +`max: -1` is accepted as an alias for `max: none`. + +### Cap Growth + +Use a percentage `max` when text should grow, but not beyond a design limit. + +```typst +#framefit(width: 60mm, height: 18mm, min: 70%, max: 180%)[ + Short headline +] +``` + +### Shrink Only If Text Overflows + +Use `only-if-overflow: true` to keep text at `100%` when it already fits. + +```typst +#framefit( + width: 70mm, + height: 24mm, + min: 70%, + max: 130%, + only-if-overflow: true, +)[ + Text stays at normal size unless it would overflow. +] +``` + +### Limit The Number Of Lines + +Use `max-lines` when the text should stay within a fixed number of measured +lines as well as the physical frame. + +```typst +#framefit( + width: 70mm, + height: 30mm, + min: 60%, + max: none, + max-lines: 3, +)[ + This text is fitted while staying within three measured lines. +] +``` + +Line spacing is configured with Typst's normal paragraph setting: + +```typst +#set par(leading: 4pt) + +#framefit(width: 70mm, height: 30mm, max-lines: 3)[ + This text is fitted using the active paragraph leading. +] +``` + +## API + +### `framefit` + +```typst +#framefit( + width: auto, + height: auto, + min: 70%, + max: none, + max-lines: none, + steps: 24, + inset: 0pt, + stroke: none, + fill: none, + radius: 0pt, + only-if-overflow: false, + body, +) +``` + +Creates a `block` frame and fits `body` inside it. + +Key arguments: + +- `width`, `height`: frame dimensions passed to `block`. +- `min`: smallest allowed text size as a percentage of the current text size. +- `max`: largest allowed text size as a percentage, or `none` / `-1` for no + configured maximum. +- `max-lines`: maximum measured line count, or `none`. +- `steps`: binary-search iterations. The default is usually enough. +- `inset`, `stroke`, `fill`, `radius`: forwarded to the created `block`. +- `only-if-overflow`: if `true`, text that already fits stays at `100%`. + +### `fit-copy` + +```typst +#fit-copy( + min: 70%, + max: none, + max-lines: none, + steps: 24, + only-if-overflow: false, + body, +) +``` + +Fits `body` to the size of the surrounding layout container. Use this when you +already have a custom `block` or another layout container and only need the +text-fitting behavior. + +## How It Works + +Framefit uses Typst's layout measurement: + +1. Measure the content at a candidate font-size percentage. +2. Check whether the measured width and height fit the available frame. +3. If `max-lines` is set, measure an equivalent line-count sample in the + current text style and compare heights. +4. Use binary search to choose the largest fitting percentage. + +Conceptually, `max-lines` is the maximum allowed text height for the requested +line count. Framefit lets Typst calculate that height instead of multiplying +manually, because the real line box depends on font metrics, text size, +top/bottom edges, and `par(leading:)`. + +## Examples + +The `examples/` folder contains rendered documents for the main cases: + +- [`grows-to-max.typ`](examples/grows-to-max.typ): finite maximum percentage. +- [`no-maximum.typ`](examples/no-maximum.typ): calculated maximum with + `max: none`. +- [`shrinks-to-min.typ`](examples/shrinks-to-min.typ): tight frame that reaches + the minimum size. +- [`max-lines.typ`](examples/max-lines.typ): line-count limiting. +- [`max-lines-styles.typ`](examples/max-lines-styles.typ): different fonts and + paragraph leading. +- `demo.typ`: combined overview. + +Each example includes a visible "Settings used" block before each result. + +## Overflow Behavior + +If text still does not fit at `min`, compilation fails with a clear error: + +```text +framefit: content does not fit at the minimum size. +Make the frame larger, reduce the content, or lower `min`. +``` + +This is intentional. It prevents silent clipping or hidden layout failures. + +## Limitations + +- Designed for paged output: PDF, PNG, and SVG. +- The fitting calculation uses Typst layout measurement, so unusual content may + need manual checking. +- `max-lines` is intended for ordinary text. It is less exact for content that + changes style inside the body, includes non-text elements, uses manual line + breaks, or relies on hyphenation. +- The MVP focuses on text content. Other content can work, but is not the + primary target. + +## Development + +Compile all examples: + +```sh +for file in examples/*.typ; do + [ "$file" = "examples/_helpers.typ" ] && continue + docker run --rm -v "$PWD":/work -w /work ghcr.io/typst/typst:latest \ + compile --root /work "$file" "${file%.typ}.pdf" +done +``` + +Run the compile checks: + +```sh +for file in tests/*.typ; do + [ "$file" = "tests/impossible.typ" ] && continue + docker run --rm -v "$PWD":/work -w /work ghcr.io/typst/typst:latest \ + compile --root /work "$file" "${file%.typ}.svg" +done +``` + +`tests/impossible.typ` is expected to fail because it verifies the minimum-size +overflow error. diff --git a/packages/preview/framefit/0.1.0/assets/grows-to-max.webp b/packages/preview/framefit/0.1.0/assets/grows-to-max.webp new file mode 100644 index 0000000000..edbc441950 Binary files /dev/null and b/packages/preview/framefit/0.1.0/assets/grows-to-max.webp differ diff --git a/packages/preview/framefit/0.1.0/assets/max-lines-styles.webp b/packages/preview/framefit/0.1.0/assets/max-lines-styles.webp new file mode 100644 index 0000000000..f9805f3818 Binary files /dev/null and b/packages/preview/framefit/0.1.0/assets/max-lines-styles.webp differ diff --git a/packages/preview/framefit/0.1.0/assets/max-lines.webp b/packages/preview/framefit/0.1.0/assets/max-lines.webp new file mode 100644 index 0000000000..f7749717d3 Binary files /dev/null and b/packages/preview/framefit/0.1.0/assets/max-lines.webp differ diff --git a/packages/preview/framefit/0.1.0/assets/no-maximum.webp b/packages/preview/framefit/0.1.0/assets/no-maximum.webp new file mode 100644 index 0000000000..24b65f5782 Binary files /dev/null and b/packages/preview/framefit/0.1.0/assets/no-maximum.webp differ diff --git a/packages/preview/framefit/0.1.0/assets/shrinks-to-min.webp b/packages/preview/framefit/0.1.0/assets/shrinks-to-min.webp new file mode 100644 index 0000000000..fc60afdf84 Binary files /dev/null and b/packages/preview/framefit/0.1.0/assets/shrinks-to-min.webp differ diff --git a/packages/preview/framefit/0.1.0/examples/_helpers.typ b/packages/preview/framefit/0.1.0/examples/_helpers.typ new file mode 100644 index 0000000000..2e317cfdbe --- /dev/null +++ b/packages/preview/framefit/0.1.0/examples/_helpers.typ @@ -0,0 +1,14 @@ +#let settings(body) = block( + width: 100%, + inset: (x: 4pt, y: 3pt), + fill: luma(248), + stroke: 0.35pt + luma(205), + radius: 2pt, +)[ + #text(font: "DejaVu Sans Mono", size: 7.5pt)[ + *Settings used:* + #linebreak() + #body + ] +] + diff --git a/packages/preview/framefit/0.1.0/examples/demo.typ b/packages/preview/framefit/0.1.0/examples/demo.typ new file mode 100644 index 0000000000..7db7919aa5 --- /dev/null +++ b/packages/preview/framefit/0.1.0/examples/demo.typ @@ -0,0 +1,121 @@ +#import "@preview/framefit:0.1.0": framefit, fit-copy +#import "_helpers.typ": settings + +#set page(width: 260mm, height: 180mm, margin: 16mm) +#set text(font: "Libertinus Serif", size: 14pt) + +#let sample-long = [ + The copy is longer than the frame was designed for, so framefit reduces the + text size until the paragraph fits within the available area. +] + +#let sample-short = [Short headline] + += Framefit demo + +#grid( + columns: (1fr, 1fr), + gutter: 10mm, + [ + *Without framefit* + + #settings[ + `block(width: 100%, height: 32mm)` + #linebreak() + `text(font: "Libertinus Serif", size: 14pt)` + ] + + _Result:_ + + #block( + width: 100%, + height: 32mm, + inset: 6pt, + stroke: 0.6pt + red, + )[ #sample-long ] + ], + [ + *With framefit* + + #settings[ + `framefit(width: 100%, height: 32mm)` + #linebreak() + `min: 65%, max: 120%` + ] + + _Result:_ + + #framefit( + width: 100%, + height: 32mm, + min: 65%, + max: 120%, + inset: 6pt, + stroke: 0.6pt + green, + )[ #sample-long ] + ], +) + +#v(10mm) + +#grid( + columns: (1fr, 1fr), + gutter: 10mm, + [ + *Short copy at normal size* + + #settings[ + `block(width: 58mm, height: 14mm)` + #linebreak() + `text(size: 14pt)` + ] + + _Result:_ + + #block( + width: 58mm, + height: 14mm, + inset: 6pt, + stroke: 0.6pt + gray, + )[ #sample-short ] + ], + [ + *Short copy grown to max: 180%* + + #settings[ + `framefit(width: 58mm, height: 14mm)` + #linebreak() + `min: 65%, max: 180%` + ] + + _Result:_ + + #framefit( + width: 58mm, + height: 14mm, + min: 65%, + max: 180%, + inset: 6pt, + stroke: 0.6pt + blue, + )[ #sample-short ] + ], +) + +#v(10mm) + +*Existing frame helper* + +#settings[ + `block(width: 100%, height: 24mm)` + #linebreak() + `fit-copy(min: 70%, max: 130%, only-if-overflow: true)` +] + +_Result:_ + +#block(width: 100%, height: 24mm, inset: 6pt, stroke: 0.6pt + black)[ + #fit-copy(min: 70%, max: 130%, only-if-overflow: true)[ + With `only-if-overflow`, this helper keeps the original text size when it + already fits and shrinks only when the frame would overflow. + ] +] diff --git a/packages/preview/framefit/0.1.0/examples/grows-to-max.typ b/packages/preview/framefit/0.1.0/examples/grows-to-max.typ new file mode 100644 index 0000000000..28d0a4bccd --- /dev/null +++ b/packages/preview/framefit/0.1.0/examples/grows-to-max.typ @@ -0,0 +1,50 @@ +#import "@preview/framefit:0.1.0": framefit +#import "_helpers.typ": settings + +#set page(width: 140mm, height: 105mm, margin: 14mm) +#set text(font: "Libertinus Serif", size: 14pt) + += Text grows to max + +#grid( + columns: (1fr, 1fr), + gutter: 10mm, + [ + *Normal frame* + + #settings[ + `block(width: 58mm, height: 14mm)` + #linebreak() + `text(size: 14pt)` + ] + + _Result:_ + + #block( + width: 58mm, + height: 14mm, + inset: 6pt, + stroke: 0.6pt + gray, + )[Short headline] + ], + [ + *Framefit max: 180%* + + #settings[ + `framefit(width: 58mm, height: 14mm)` + #linebreak() + `min: 70%, max: 180%` + ] + + _Result:_ + + #framefit( + width: 58mm, + height: 14mm, + min: 70%, + max: 180%, + inset: 6pt, + stroke: 0.6pt + blue, + )[Short headline] + ], +) diff --git a/packages/preview/framefit/0.1.0/examples/max-lines-styles.typ b/packages/preview/framefit/0.1.0/examples/max-lines-styles.typ new file mode 100644 index 0000000000..6a3d9aed9a --- /dev/null +++ b/packages/preview/framefit/0.1.0/examples/max-lines-styles.typ @@ -0,0 +1,182 @@ +#import "@preview/framefit:0.1.0": framefit +#import "_helpers.typ": settings + +#set page(width: 220mm, height: 230mm, margin: 14mm) +#set text(size: 14pt) + +#let sample = [ + Different font metrics and line spacing change the measured three-line + height, so the fitted size can change too. +] + +#let frame-color = rgb("#2f6f9f") + += Maximum lines with style changes + +== Fonts + +#grid( + columns: (1fr, 1fr, 1fr), + gutter: 8mm, + [ + *Libertinus Serif* + + #set text(font: "Libertinus Serif") + + #settings[ + `font: "Libertinus Serif"` + #linebreak() + `width: 58mm, height: 34mm` + #linebreak() + `min: 45%, max: none, max-lines: 3` + ] + + _Result:_ + + #framefit( + width: 58mm, + height: 34mm, + min: 45%, + max: none, + max-lines: 3, + inset: 6pt, + stroke: 0.6pt + frame-color, + )[ #sample ] + ], + [ + *New Computer Modern* + + #set text(font: "New Computer Modern") + + #settings[ + `font: "New Computer Modern"` + #linebreak() + `width: 58mm, height: 34mm` + #linebreak() + `min: 45%, max: none, max-lines: 3` + ] + + _Result:_ + + #framefit( + width: 58mm, + height: 34mm, + min: 45%, + max: none, + max-lines: 3, + inset: 6pt, + stroke: 0.6pt + frame-color, + )[ #sample ] + ], + [ + *DejaVu Sans Mono* + + #set text(font: "DejaVu Sans Mono") + + #settings[ + `font: "DejaVu Sans Mono"` + #linebreak() + `width: 58mm, height: 34mm` + #linebreak() + `min: 45%, max: none, max-lines: 3` + ] + + _Result:_ + + #framefit( + width: 58mm, + height: 34mm, + min: 45%, + max: none, + max-lines: 3, + inset: 6pt, + stroke: 0.6pt + frame-color, + )[ #sample ] + ], +) + +#v(8mm) + +== Paragraph leading + +#grid( + columns: (1fr, 1fr, 1fr), + gutter: 8mm, + [ + *Tight leading* + + #set text(font: "Libertinus Serif") + #set par(leading: 2pt) + + #settings[ + `font: "Libertinus Serif"` + #linebreak() + `par(leading: 2pt)` + #linebreak() + `max-lines: 3` + ] + + _Result:_ + + #framefit( + width: 58mm, + height: 34mm, + min: 45%, + max: none, + max-lines: 3, + inset: 6pt, + stroke: 0.6pt + green, + )[ #sample ] + ], + [ + *Default leading* + + #set text(font: "Libertinus Serif") + + #settings[ + `font: "Libertinus Serif"` + #linebreak() + `par(leading: default)` + #linebreak() + `max-lines: 3` + ] + + _Result:_ + + #framefit( + width: 58mm, + height: 34mm, + min: 45%, + max: none, + max-lines: 3, + inset: 6pt, + stroke: 0.6pt + frame-color, + )[ #sample ] + ], + [ + *Loose leading* + + #set text(font: "Libertinus Serif") + #set par(leading: 8pt) + + #settings[ + `font: "Libertinus Serif"` + #linebreak() + `par(leading: 8pt)` + #linebreak() + `max-lines: 3` + ] + + _Result:_ + + #framefit( + width: 58mm, + height: 34mm, + min: 45%, + max: none, + max-lines: 3, + inset: 6pt, + stroke: 0.6pt + orange, + )[ #sample ] + ], +) diff --git a/packages/preview/framefit/0.1.0/examples/max-lines.typ b/packages/preview/framefit/0.1.0/examples/max-lines.typ new file mode 100644 index 0000000000..eb556cadea --- /dev/null +++ b/packages/preview/framefit/0.1.0/examples/max-lines.typ @@ -0,0 +1,58 @@ +#import "@preview/framefit:0.1.0": framefit +#import "_helpers.typ": settings + +#set page(width: 150mm, height: 90mm, margin: 14mm) +#set text(font: "Libertinus Serif", size: 14pt) + +#let text = [ + Product labels often need the largest readable text size while keeping the + result within a fixed number of lines. +] + += Maximum lines + +#grid( + columns: (1fr, 1fr), + gutter: 10mm, + [ + *No line limit* + + #settings[ + `framefit(width: 56mm, height: 34mm)` + #linebreak() + `min: 60%, max: none` + ] + + _Result:_ + + #framefit( + width: 56mm, + height: 34mm, + min: 60%, + max: none, + inset: 6pt, + stroke: 0.6pt + gray, + )[ #text ] + ], + [ + *Framefit max-lines: 3* + + #settings[ + `framefit(width: 56mm, height: 34mm)` + #linebreak() + `min: 60%, max: none, max-lines: 3` + ] + + _Result:_ + + #framefit( + width: 56mm, + height: 34mm, + min: 60%, + max: none, + max-lines: 3, + inset: 6pt, + stroke: 0.6pt + blue, + )[ #text ] + ], +) diff --git a/packages/preview/framefit/0.1.0/examples/no-maximum.typ b/packages/preview/framefit/0.1.0/examples/no-maximum.typ new file mode 100644 index 0000000000..05411e9246 --- /dev/null +++ b/packages/preview/framefit/0.1.0/examples/no-maximum.typ @@ -0,0 +1,50 @@ +#import "@preview/framefit:0.1.0": framefit +#import "_helpers.typ": settings + +#set page(width: 140mm, height: 105mm, margin: 14mm) +#set text(font: "Libertinus Serif", size: 14pt) + += No configured maximum + +#grid( + columns: (1fr, 1fr), + gutter: 10mm, + [ + *Normal frame* + + #settings[ + `block(width: 58mm, height: 18mm)` + #linebreak() + `text(size: 14pt)` + ] + + _Result:_ + + #block( + width: 58mm, + height: 18mm, + inset: 6pt, + stroke: 0.6pt + gray, + )[Short headline] + ], + [ + *Framefit max: none* + + #settings[ + `framefit(width: 58mm, height: 18mm)` + #linebreak() + `min: 70%, max: none` + ] + + _Result:_ + + #framefit( + width: 58mm, + height: 18mm, + min: 70%, + max: none, + inset: 6pt, + stroke: 0.6pt + blue, + )[Short headline] + ], +) diff --git a/packages/preview/framefit/0.1.0/examples/shrinks-to-min.typ b/packages/preview/framefit/0.1.0/examples/shrinks-to-min.typ new file mode 100644 index 0000000000..4ae2c08f9b --- /dev/null +++ b/packages/preview/framefit/0.1.0/examples/shrinks-to-min.typ @@ -0,0 +1,55 @@ +#import "@preview/framefit:0.1.0": framefit +#import "_helpers.typ": settings + +#set page(width: 150mm, height: 90mm, margin: 14mm) +#set text(font: "Libertinus Serif", size: 14pt) + +#let tight-copy = [ + This paragraph is intentionally long for the small frame, forcing framefit to + use the lower size bound to keep the text inside the available area. +] + += Text shrinks to min + +#grid( + columns: (1fr, 1fr), + gutter: 10mm, + [ + *Normal frame* + + #settings[ + `block(width: 46.3mm, height: 21.8mm)` + #linebreak() + `text(size: 14pt)` + ] + + _Result:_ + + #block( + width: 46.3mm, + height: 21.8mm, + inset: 6pt, + stroke: 0.6pt + red, + )[ #tight-copy ] + ], + [ + *Framefit min: 60%* + + #settings[ + `framefit(width: 46.3mm, height: 21.8mm)` + #linebreak() + `min: 60%, max: 120%` + ] + + _Result:_ + + #framefit( + width: 46.3mm, + height: 21.8mm, + min: 60%, + max: 120%, + inset: 6pt, + stroke: 0.6pt + green, + )[ #tight-copy ] + ], +) diff --git a/packages/preview/framefit/0.1.0/lib.typ b/packages/preview/framefit/0.1.0/lib.typ new file mode 100644 index 0000000000..481eb13bf0 --- /dev/null +++ b/packages/preview/framefit/0.1.0/lib.typ @@ -0,0 +1,180 @@ +#let _line-sample(lines) = { + for i in range(lines) { + if i > 0 { + linebreak() + } + + [Ag] + } +} + +#let _max-lines-height(lines, factor) = { + measure(text(size: 1em * factor)[#_line-sample(lines)]).height +} + +#let _fits(size, body, factor, max-lines: none) = { + let measured = measure( + width: size.width, + text(size: 1em * factor)[#body], + ) + + let fits-frame = measured.width <= size.width and measured.height <= size.height + let fits-lines = if max-lines == none { + true + } else { + measured.height <= _max-lines-height(max-lines, factor) + } + + fits-frame and fits-lines +} + +#let _is-unbounded-max(max) = max == none or max == -1 + +#let _validate-args(min, max, steps, max-lines, only-if-overflow) = { + if type(min) != ratio { + panic("framefit: `min` must be a percentage.") + } + + if not _is-unbounded-max(max) and type(max) != ratio { + panic("framefit: `max` must be a percentage, `none`, or `-1`.") + } + + if not _is-unbounded-max(max) and min > max { + panic("framefit: `min` must be less than or equal to `max`.") + } + + if steps < 1 { + panic("framefit: `steps` must be at least 1.") + } + + if max-lines != none and type(max-lines) != int { + panic("framefit: `max-lines` must be an integer or `none`.") + } + + if max-lines != none and max-lines < 1 { + panic("framefit: `max-lines` must be at least 1.") + } + + if only-if-overflow and min > 100% { + panic("framefit: `only-if-overflow` requires `min` to be 100% or less.") + } +} + +#let _bounded-fitting-factor(size, body, min, max, steps, max-lines) = { + if not _fits(size, body, min, max-lines: max-lines) { + panic( + "framefit: content does not fit at the minimum size. " + + "Make the frame larger, reduce the content, or lower `min`.", + ) + } + + if _fits(size, body, max, max-lines: max-lines) { + return max + } + + let low = min + let high = max + + for _ in range(steps) { + let mid = low + (high - low) / 2 + + if _fits(size, body, mid, max-lines: max-lines) { + low = mid + } else { + high = mid + } + } + + low +} + +#let _unbounded-fitting-factor(size, body, min, steps, max-lines) = { + if not _fits(size, body, min, max-lines: max-lines) { + panic( + "framefit: content does not fit at the minimum size. " + + "Make the frame larger, reduce the content, or lower `min`.", + ) + } + + let low = min + let high = if min < 100% { 100% } else { min * 2 } + + for _ in range(32) { + if _fits(size, body, high, max-lines: max-lines) { + low = high + high = high * 2 + } else { + return _bounded-fitting-factor(size, body, low, high, steps, max-lines) + } + } + + panic( + "framefit: could not find an overflowing size for unbounded `max`. " + + "Set a finite `max` for this content.", + ) +} + +#let _largest-fitting-factor(size, body, min, max, steps, max-lines) = { + if _is-unbounded-max(max) { + _unbounded-fitting-factor(size, body, min, steps, max-lines) + } else { + _bounded-fitting-factor(size, body, min, max, steps, max-lines) + } +} + +#let fit-copy( + min: 70%, + max: none, + max-lines: none, + steps: 24, + only-if-overflow: false, + body, +) = { + _validate-args(min, max, steps, max-lines, only-if-overflow) + + layout(size => { + if only-if-overflow and _fits(size, body, 100%, max-lines: max-lines) { + text(size: 1em)[#body] + } else { + let upper = if only-if-overflow { + if _is-unbounded-max(max) or max > 100% { 100% } else { max } + } else { + max + } + let factor = _largest-fitting-factor(size, body, min, upper, steps, max-lines) + text(size: 1em * factor)[#body] + } + }) +} + +#let framefit( + width: auto, + height: auto, + min: 70%, + max: none, + max-lines: none, + steps: 24, + inset: 0pt, + stroke: none, + fill: none, + radius: 0pt, + only-if-overflow: false, + body, +) = { + block( + width: width, + height: height, + inset: inset, + stroke: stroke, + fill: fill, + radius: radius, + )[ + #fit-copy( + min: min, + max: max, + max-lines: max-lines, + steps: steps, + only-if-overflow: only-if-overflow, + )[#body] + ] +} diff --git a/packages/preview/framefit/0.1.0/typst.toml b/packages/preview/framefit/0.1.0/typst.toml new file mode 100644 index 0000000000..cba8468d85 --- /dev/null +++ b/packages/preview/framefit/0.1.0/typst.toml @@ -0,0 +1,12 @@ +[package] +name = "framefit" +version = "0.1.0" +entrypoint = "lib.typ" +authors = ["Patrick Fürderer-Tosolini <@Hyperrick>"] +license = "MIT" +description = "Fit text to fixed frames by adjusting its size." +repository = "https://github.com/Hyperrick/typst-framefit" +keywords = ["framefit", "text-fit", "frame", "layout"] +categories = ["layout", "text", "utility"] +compiler = "0.14.2" +exclude = ["tests/**", "assets/*.webp", "examples/*.pdf", "examples/*.png", "examples/*.svg"]