From 62bf11a63f7fc976382a8f092ab84d932bf02ebb Mon Sep 17 00:00:00 2001 From: JustAnyone Date: Thu, 29 Oct 2026 12:15:48 +0200 Subject: [PATCH] feat: add automatic management of figure breakability --- src/figures.typ | 99 +++++++++++++++++++++++++++++++++++++++++++++---- src/lib.typ | 24 ++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/figures.typ b/src/figures.typ index 7367992..73b1ef1 100644 --- a/src/figures.typ +++ b/src/figures.typ @@ -374,6 +374,65 @@ // Figure layout // --------------------------------------------------------------------------- +/// Default layout per figure kind (keyed like `__figure-kinds`; custom kinds +/// and unlisted ones use `__FALLBACK_FIGURE_LAYOUT`). +/// - breakable: whether the body may be split across pages. The caption is then +/// attached to the start of the body (sticky), so it is never left alone at +/// the bottom of a page. If `false`, body and caption form one unbreakable +/// block that moves to the next page as a whole. +/// - caption: `top` or `bottom`. +#let __DEFAULT_FIGURE_LAYOUT = ( + table: (breakable: true, caption: top), + raw: (breakable: true, caption: bottom), +) +#let __FALLBACK_FIGURE_LAYOUT = (breakable: false, caption: bottom) + +/// Author overrides, set via `ktu-paper(figureLayout: ...)`. +#let __USER_FIGURE_LAYOUT = state("ktu-user-figure-layout", (:)) + +/// Stack of local overrides set by `breakable-figures` / `unbreakable-figures`. +#let __FIGURE_BREAK_OVERRIDE = state("ktu-figure-break-override", ()) + +/// Validates author-supplied layouts: `(kind: (breakable: bool, caption: top | bottom))`, +/// every field optional. +#let __normalize-figure-layout(layouts) = { + for (key, spec) in layouts { + for (field, value) in spec { + assert( + field in ("breakable", "caption"), + message: "figureLayout." + key + ": unknown option `" + field + "`, expected `breakable` or `caption`", + ) + if field == "breakable" { + assert(type(value) == bool, message: "figureLayout." + key + ".breakable must be a bool") + } else { + assert( + value == top or value == bottom, + message: "figureLayout." + key + ".caption must be `top` or `bottom`", + ) + } + } + } + layouts +} + +/// Resolves the layout of a figure kind key. Must be called in a context. +#let __figure-layout(key) = { + let layout = __FALLBACK_FIGURE_LAYOUT + __DEFAULT_FIGURE_LAYOUT.at(key, default: (:)) + layout = layout + __USER_FIGURE_LAYOUT.get().at(key, default: (:)) + let overrides = __FIGURE_BREAK_OVERRIDE.get() + if overrides.len() > 0 { + layout.breakable = overrides.last() + } + layout +} + +/// Runs `body` with every figure's `breakable` setting forced to `mode`. +#let __with-break-override(mode, body) = { + __FIGURE_BREAK_OVERRIDE.update(stack => stack + (mode,)) + body + __FIGURE_BREAK_OVERRIDE.update(stack => stack.slice(0, -1)) +} + /// Formats a figure with a caption and number. #let __format-figure(it) = { let caption = it.caption @@ -384,13 +443,39 @@ set text(size: 11pt) let caption-line = [*#__caption-label(it)* #caption.body] + let key = __figure-key(it) - // Tables have captions at the top - if it.kind == table { - align(left, caption-line) - it.body - } else { - it.body - align(center, caption-line) + context { + let layout = __figure-layout(key) + let on-top = layout.caption == top + // Entire table is by default left aligned, unlike other figures which are centered + let body = if key == "table" { align(left, it.body) } else { it.body } + let caption-block = block( + sticky: on-top and layout.breakable, + width: 100%, + align(if on-top { left } else { center }, caption-line), + ) + if layout.breakable { + // The body may split across pages. A caption on top is sticky and stays + // with the first rows; a caption at the bottom cannot be glued to the end + // of a splittable body, so it may end up alone on the next page. + if on-top { + caption-block + body + } else { + body + caption-block + } + } else { + block(breakable: false, width: 100%, { + if on-top { + caption-block + body + } else { + body + caption-block + } + }) + } } } diff --git a/src/lib.typ b/src/lib.typ index 17ea005..f6befd2 100644 --- a/src/lib.typ +++ b/src/lib.typ @@ -17,6 +17,9 @@ __equation-numbering, __normalize-figure-kinds, __USER_FIGURE_KINDS, + __USER_FIGURE_LAYOUT, + __normalize-figure-layout, + __with-break-override, __format-ref, __format-figure, @@ -286,6 +289,19 @@ __ktu_outline(title: heading(title, outlined: true), target: figure.where(kind: kind)) } +/// Lets figures in `body` split across pages: the body may break, the caption stays +/// attached to its start (a caption at the bottom may end up alone, see `figureLayout`). +/// Use it to opt out of keeping a figure on one page, e.g. for a long code listing. +/// - body (content): Content containing figures. +/// -> content +#let breakable-figures(body) = __with-break-override(true, body) + +/// Keeps every figure in `body` on one page: body and caption move to the next +/// page together. Use it to opt out of splitting, e.g. for a short table. +/// - body (content): Content containing figures. +/// -> content +#let unbreakable-figures(body) = __with-break-override(false, body) + /// `code` option defaults for the library entrypoint. #let __CODE_DEFAULTS = ( font: "Hack", @@ -516,6 +532,12 @@ /// - `single-name`: full word used when the figure is the only one of its kind /// and is left unnumbered, e.g. "Komanda." and "(žr. komanda)". Defaults to `supplement`. /// Currently only tables and images are left unnumbered when single, so this only matters for kinds added there. +/// - figureLayout (dictionary): Page-break behaviour and caption position per figure kind, +/// `(kind: (breakable: bool, caption: top | bottom))`, every field optional. Kinds are +/// `table`, `image`, `raw` (code), or a key of `figureKinds`. Defaults: `table` and `raw` +/// are breakable; everything else is unbreakable. Example: `(raw: (breakable: false, caption: bottom))`. +/// A breakable figure's top caption stays attached to the start of the body. +/// Use `breakable-figures[...]` / `unbreakable-figures[...]` to override it locally. /// - code (dictionary | none): Code block configuration, or `none` to leave raw blocks unstyled. Keys: /// - `font` (str): Monospace font. Defaults to "Hack". /// - `spacing` (relative): Word spacing of code text. Defaults to `80%`. @@ -529,6 +551,7 @@ font: "Times New Roman", figureNumberingPerSection: false, figureKinds: (:), + figureLayout: (:), code: (:), // Not sure about formal conformity of deviating // from the defaults of the next options, @@ -544,6 +567,7 @@ ) __FIG_PER_SECTION.update(figureNumberingPerSection) __USER_FIGURE_KINDS.update(__normalize-figure-kinds(figureKinds)) + __USER_FIGURE_LAYOUT.update(__normalize-figure-layout(figureLayout)) __UPPERCASE_SECTION_TITLES.update(uppercaseSectionTitles) __setup-code(code, body) }