Introduction

Welcome to Elembic! Elembic is a framework that lets you create your own elements and types in Typst, including support for type-checking and casting on the fly.

WARNING: Elembic is currently experimental. Expect breaking changes before 0.1.0 is released and it is published to the package manager.

Elembic supports Typst 0.11.0 or later.

Elements

Elembic allows you to create custom elements, which are reusable visual components of the document with configurable properties (through styling).

Elembic's elements support styling through show and set rules, which allow changing the default values of element properties in bulk. They are scoped (that is, lose effect at the end of the current #[] block) and do not use state or counter by default, making it comparatively fast.

However, there are some important limitations, so make sure to read the "Limitations" page, which explains them in detail.

In addition, Elembic includes some extras not natively available such as revoke and reset rules (which can be used to temporarily "undo" the effect of an earlier set rule, or group of set rules, for a limited scope). Also, Elembic can guarantee type-safety and more helpful errors by typechecking inputs to elements' fields.

Elembic also supports custom reference and outline support for your element, per-element counter support, get rules (accessing the current defaults of fields, considering set rules so far); folding (e.g. setting a stroke to 4pt and, later, to red will result in a combined stroke of 4pt + red); scopes (you can have some constants and functions attached to your element); and so on. Read the chapters under "Elements" to learn more.

Types

Elembic ships with a considerably flexible type system through its types module, with its main purpose being to typecheck fields, but it can be used anywhere you want through the e.types.cast(value, type) function.

Not only does Elembic support using and combining Typst-native types (e.g. a field can take e.types.union(int, stroke) to support integers or strokes), but it also supports declaring your own custom, nominal types. They are represented as dictionaries, but are strongly-typed, such that a dictionary with the same fields won't cast to your custom type and vice-versa by default (unless you specify otherwise). For example, a data type representing a person won't cast to another type owner even if they share the same fields.

Custom types optionally support arbitrary casting from other types. For example, you may allow automatic casts from integers and floats to your custom type. This is mostly useful when creating "ad-hoc" types for certain elements with fully customized behavior. You can read more in the "Custom types" chapter.

License

Elembic is licensed under MIT or Apache-2.0, at your option.

About

Some basic information about Elembic.

Installation

Currently, Elembic must be installed as a local package (or by copying it to your project in the web app).

To install Elembic as a local package in your system, see https://github.com/typst/packages?tab=readme-ov-file#local-packages for instructions.

In particular, it involves downloading Elembic's files from either GitHub (pgbiel/elembic) or Codeberg (pgbiel/elembic) and then copying it to $LOCAL_PACKAGES_DIR/elembic/0.0.1.

If you're using a Linux platform, there is the following one-liner command to install the latest development version (note: does not remove a prior installation):

pkgbase="${XDG_DATA_HOME:-$HOME/.local/share}/typst/packages/local/elembic" && mkdir -p "$pkgbase/0.0.1" && curl -L https://github.com/PgBiel/elembic/archive/main.tar.gz | tar xz --strip-components=1 --directory="$pkgbase/0.0.1"

Limitations

Please keep in mind the following limitations when using Elembic.

Rule limit

Elembic, in its default and most efficient mode, has a limit of up to 30 non-consecutive rules within the same scope, by default. This is due to a limitation in the Typst compiler regarding maximum function call depth, as we use show rules with context { } for set rules to work without using state (see also typst#4171 for more information).

Usually, this isn't a problem unless you hit the infamous "max show rule depth exceeded" error. If you ever receive it, you may have to switch to stateful mode, which has no set rule limit, however it is slower as it uses state.

However, there are some easy things to keep in mind that will let you avoid this error very easily, which include:

  1. Grouping rules together with apply
  2. Scoping temporary rules and not misusing revoke
  3. (As a last resort) Switching to either of the other styling modes (leaky or stateful)

Grouping rules together with apply

First of all, note that, for the purposes of this limit, rule may be a set rule, but also an apply rule, which has the power of applying multiple consecutive rules at the cost of one. That is, an apply rule, by itself, can hold way more than 30 rules (maybe 100, 500, you name it!) at once without a problem as it only counts for one towards the limit.

Therefore, it is recommended to always group set rules together into a single apply rule whenever possible.

Note that elembic is smart enough to do this automatically whenever it is absolutely safe to do so - that is, when they are consecutive (there are no elements or text between them, only whitespace or show/set rules).

That is, doing either of these is OK:

#import "@local/elembic:0.0.1" as e

// OK: Explicitly paying for the cost of only a single rule
#show: e.apply(
  e.set_(elem, fieldA: 1)
  e.set_(elem, fieldB: 2)
  e.set_(elem, fieldC: 3)
)

// OR

// OK: elembic automatically merges consecutive rules
// (with nothing or only whitespace / set and show rules inbetween)
#show: e.set_(elem, fieldA: 1)
#show: e.set_(elem, fieldB: 1)

#show: e.set_(elem, fieldC: 1)

but please avoid adding text and other elements between them - elembic does not merge them as it may be unsafe (text may be converted into custom elements by show rules, and other elements may contain custom elements within them, for example):

#import "@local/elembic:0.0.1" as e

// AVOID THIS! Paying for 3 rules instead of 1
// (Cannot safely move down the text between the rules
// automatically)
#show: e.set_(elem, fieldA: 1)

Some text (please move me below the rules)

#show: e.set_(elem, fieldB: 2)

Some text (please move me below the rules)

#show: e.set_(elem, fieldC: 3)

As a general rule of thumb, prefer using explicit apply rules whenever possible. It's not only safer (it's easy to accidentally disable the automatic merging by adding text like above), it's also easier to write and much cleaner!

Scoping temporary rules and not misusing revoke

Are you only using a set rule for a certain part of your document? Please, scope it instead of using it and revoking it. The latter will permanently cost two rules from the limit, while the former will only cost one and only during the scope.

That is, do this:

// Default color
#superbox()

#[
  #show: e.set_(superbox, color: red)

  // All superboxes are temporarily red now
  #superbox()
  #superbox()
  #superbox()
]

// Back to default color!
// (The set rule is no longer in effect.)
#superbox()

But do not do this:

// Default color
#superbox()

#show: e.named("red", e.set_(superbox, color: red))

// All superboxes are red from here onwards
#superbox()
#superbox()
#superbox()

// AVOID THIS!
// While the rule was revoked and the color is back
// to the default, BOTH rules are still unnecessarily
// active and counting towards the limit.
#show: e.revoke("red")

// Back to default color!
// However, that is because both rules are in effect.
#superbox()

A good usage of revoke is to only temporarily (for a certain scope) undo a previous set rule:

// Default color
#superbox()

#show: e.named("red", e.set_(superbox, color: red))

// All superboxes are red from here onwards
#superbox()
#superbox()
#superbox()

#[
  // OK: This is scoped and only temporary
  #show: e.revoke("red")

  // Back to default color!
  // (Only temporary)
  // Both rules are in effect here
  // (the second nullifies the first).
  #superbox()
]

// This is red again now (the "red" rule is back).
// The revoke rule is no longer in effect.
// Only the set rule.
#superbox()

Switching to other styling modes

If those measures are still not enough to fix the error, then you will have to switch to another styling mode.

There are three styling modes available for set rules (as well as apply rules, revoke rules and so on):

  1. Normal mode (default): The safest mode, uses show: set rules on existing elements to store set rule data, and then immediately restores the current value of those rules, so it is fast (as it only uses show / set, so it causes no document relayouts) and hygienic (the inner workings of Elembic have no effect on your document in that case).
    • In this mode, you are limited to around 30 simultaneous rules in the same scope as each set rule has two nested function calls (contributing twice towards the limit of 64, minus 3 due to elements themselves).
    • It is worth the reminder that this number refers to ungrouped, non-consecutive set rules. If you group set rules together, they are unlimited in number.
    • This is the default mode when using e.set_(...), e.revoke(...) and others.
  2. Leaky mode: It is as fast as normal mode, but has double the limit of rules (around 60). However, it is not hygienic as it resets existing #set bibliography(title: ...) rules to their first known values. (That is, any bibliography.title set rules after the first set rule are lost, and the initial value is reset with each leaky rule.) If this is acceptable to you, then feel free to use leaky rules to easily increase the limit.
    • This mode can be used by packages' internal elements, for example, since that set rule is probably unimportant then.
    • To switch to this mode, replace all set rules with their e.leaky counterparts. For example, you'd replace e.set_(element, field: value) with e.leaky.set_(element, field: value). (You can create an alias such as import e: leaky as lk and then lk.set_(...) for ease of use.)
  3. Stateful mode: Rules in this mode do not have any usage limits. You can nest them as much as you want, even if you don't group them. However, the downside is that this mode uses state, which can cause document relayouts and be slower.
    • Note that you can restrict this mode change to a single scope and use other modes elsewhere in the document.
    • Other rule modes are compatible with stateful mode. You can still use non-stateful set rules when stateful mode is enabled; while they will still have a limit, they will read and update set rule data using state as well, so they stay in sync. In addition, the limit of normal-mode rules is doubled just by enabling stateful mode in the containing scope, since they will automatically switch to a more lightweight code path. Therefore, package authors can use normal-mode rules without problems.

The easiest solution is to just switch to stateful mode, which uses state to keep track of set rules. It is slower and may trigger document relayouts in some cases, but has no usage limits (other than nesting many elements inside each other, which is a limitation shared by native elements as well and is unavoidable).

To do this, there are two steps (unlike the single step for leaky mode):

  1. Enabling stateful mode: Writing #show: e.stateful.enable() (note the parentheses) for either a certain scope (to only enable it for a certain portion of your document) or at the very top of the document (to enable it for all of the document);

    • This is used to inform rules in normal mode that they should read and write to the state. It also increases their limits by doing so.
    • Without this step, stateful-only set rules (below) don't do anything.
  2. Replacing existing set rules with their stateful-only counterparts. That is, replace all occurrences of e.set_, e.apply, e.revoke etc. with e.stateful.set_, e.stateful.apply and e.stateful.revoke, respectively, in the scopes where stateful mode was enabled. (The previous rules remain working even under stateful mode, but still have usage limits, albeit a bit larger.)

    • While a Ctrl+F fixes it, it's a bit of a mouthful, so you may want to consider aliasing e.stateful to a variable such as #import e: stateful as st, then you can write st.set_, st.apply etc. instead.
    • Stateful-only rules have no usage limits, but they only work in scopes where stateful mode is enabled. Otherwise, they have no effect, since the state changes will be ignored.

The two steps above should be enough to fix the error for good, provided you understand the potential performance consequences. Of course, it just might not be at all significant for your document.

Performance

Elembic's performance is, in general, satisfactory, and is logged on CI, but may get worse if you use more features. Here's a brief discussion about potential offenders:

  • Elements with contextual: true have reduced benefits from memoization. As such, if too many of them are placed (say, in the hundreds), they can cause a noticeable performance penalty. However, it is not a problem if only a few of them are placed.
  • Typechecking of fields can add some overhead on each element instantiation, however this is expected to be mostly irrelevant in the average case (when using mostly native types or combinations of them) compared to Elembic's other features. Still, you can improve this by overriding Elembic's default argument parser, or even disabling typechecking altogether with the typecheck: false option on elements, if that proves to be a bottleneck for your particular element.
  • Special rules such as revoke rules could potentially add some overhead. However, that overhead is expected to be very insignificant, especially if you aren't using hundreds of them.

The only way to know whether Elembic is a good fit for you is to give it a try on your own document!

Frequently Asked Questions

Where does the name "Elembic" come from?

Elembic's name comes from "element" + "alembic", to indicate that one of Elembic's goals is to experiment with different approaches for this functionality, and to help shape a future update to Typst that includes native custom elements, which will eventually remove the need for using this package.

Creating custom elements

This chapter will explain everything you need to know about the creation of an element using Elembic. It is useful not only for package authors, but interested users who would like to make reusable components in their document.

Elements, in their essence, are reusable components of the document which can be used to ensure certain parts of it share the same appearance - for example, headings, figures, as well as blocks, which are available by default.

In addition, however, elements have other properties. They can be configured by users through styles, that is, show and set rules, which can be used to, respectively, replace an element's whole appearance with some other, or change the values of some of that element's fields, if they are unspecified when the element is created.

Last but not least, Elembic can guarantee some level of type-safety by typechecking user input to element fields. Whenever you create a field, you must specify its type, which will allow Elembic to do this check.

Throughout the chapter, we will create and manipulate a sample element named "theorem".

Declaring a custom element

Want to make a reusable and stylable component for your users? You can start by creating your own element as described below.

First steps

You can use the element.declare function to create a custom element. It will return the constructor for this element, which you should export from your package or file:

#import "@local/elembic:0.0.1" as e

#let theorem = e.element.declare(
  // Element name.
  "theorem",

  // A prefix to disambiguate from elements with the same name.
  // Elements with the same name and prefix are treated as equal
  // by the library, which may cause bugs when mixing them up,
  // so conflicts should be avoided.
  prefix: "@preview/my-package,v1",

  // Default show rule: how this element displays itself.
  display: it => [Hello world!],

  // No fields for now.
  fields: ()
)

// Place it in the document:
#theorem()

This will display "Hello world!" in the document. Great!

Note that the element prefix is fundamental for distinguishing elements with the same name. The idea is for the element name to be simple (as that's what is displayed in errors and such), but the element prefix should be as unique as possible. (However, try to not make it way too long either!)

Importantly, if you ever change the prefix (say, after a major update to your package), users of the element with the old prefix (i.e. in older versions) will not be compatible with the element with the new prefix (that is, their set rules won't target them and such). While this could be frustrating at first, it is necessary if you change up your element's fields in a breaking way to avoid bugs and incompatibility problems. Therefore, you may want to consider adding a version number to the prefix (could be your library's major version number or just a number specific to that element) which is changed on each breaking change to the element's fields.

Adding fields

You may want to have your element's appearance be configurable by end users through fields. Let's add a color field to change the fill of text inside our theorem:

#import "@local/elembic:0.0.1" as e

#let theorem = e.element.declare(
  "theorem",
  prefix: "my-package",

  // Default show rule receives the constructed element.
  display: it => text(fill: it.fill)[Hello world!],

  fields: (
    // Specify field name, type, brief description and default.
    // This allows us to override the color if desired.
    e.field("fill", e.types.paint, doc: "The text fill.", default: red),
  )
)

// This theorem will display "Hello world!" in red.
#theorem()

// This theorem will display "Hello world!" in blue.
#theorem(fill: blue)

Here we use e.types.paint instead of just color because the fill could be a gradient or tiling as well, for example; paint is a shorthand for e.types.union(color, gradient, tiling).

To read more about the types that can be used for fields, read the Type system chapter.

Note that omitting default: red in the field creation would have caused an error, as Elembic cannot infer a default value for the color type.

However, that isn't a problem if, say, we add a required field with required: true. These fields do not need a default.

By default, required fields are positional, although one can also force them to be named through named: true (and vice-versa: you can have non-required fields be positional with named: false).

Let's give it a shot by allowing the user to customize what goes inside the element:

#import "@local/elembic:0.0.1" as e

#let theorem = e.element.declare(
  "theorem",
  prefix: "my-package",

  display: it => text(fill: it.fill)[#fields.body],

  fields: (
    // Force this field to be specified.
    e.field("body", content, required: true),
    e.field("fill", e.types.paint, doc: "The text fill.", default: red),
  )
)

// This theorem will display "Wowzers!" in red.
#theorem[Wowzers!]

// This theorem will display "Some content" in blue.
#theorem(fill: blue)[Some content]

Note that this also allows users to override the default values of fields through set rules (see Styling elements for more information):

#import "@local/elembic:0.0.1" as e

#let theorem = e.element.declare(
  "theorem",
  prefix: "my-package",

  display: it => text(fill: it.fill)[#fields.body],

  fields: (
    // Force this field to be specified.
    e.field("body", content, required: true),
    e.field("fill", e.types.paint, doc: "The text fill.", default: red),
  )
)

#show: e.set_(theorem, fill: green)

// This theorem will display "Impressed!" in green.
#theorem[Impressed!]

Accessing context

If you need to access the current context within display (or other element functions receiving fields), you can use e.data or its related functions. In particular, e.counter(it) provides the element's counter, whereas e.func(it) provides the element constructor itself. Check the page about custom references for more information.

Specifying fields

The previous section on declaring custom elements provided examples on how to declare fields to specify user-configurable data for your element. Here, we will go more in depth.

When specifying an element's fields, you should use the field function for each field. It has the following arguments:

  1. name (string, positional): the field name, by which it will be accessed in the dictionary returned by e.fields(element instance).

  2. type_ (type / typeinfo, positional): the field's type, defines how to check whether user input to this field is valid (read more about types in the Type system chapter).

  3. doc (string, named, optional): documentation for the field. It is recommended to always set this. The documentation (and other field metadata) can later be retrieved by accessing e.data(elem).all-fields, and can be used to auto-generate documentation, for example.

  4. required (boolean, named, optional): whether this field is required (and thus can only be specified at the constructor, as it will have no default). This defaults to false.

  5. named (boolean or auto, named, optional): whether this field should be specified positionally (false - that is, without its name) to the constructor and set rules, or named (true - the user must specify the field by name). By default, this is auto, which is true for required fields and false for optional fields (but you can have required named fields and optional positional fields by changing both parameters accordingly).

  6. default (any, named, optional): the default value for this field if required is false. If this argument is omitted, the type's default is used (as explained below), or otherwise the field has no default (only possible if it is required). If a value is specified, that value becomes the field's default value (it will be cast to the given field type). It was done this way so you can also specify none as a default (which is common).

    Note that many types have their own default values, so you can usually omit default entirely, as the field will then just use that type's default. For example, if the type is int, then default will automatically be set to 0 for such optional fields. If the type doesn't have its own default and the field is optional, you must specify your own default.

  7. synthesized (boolean, named, optional): this is used to indicate that this is an automatically generated field during synthesis. A synthesized field cannot be manually set by the user - be it through the constructor or through set rules. However, it can be matched on in filters, such as for selecting elements through e.select. For example, if you have a synthesized field called level, a user can match on elem.with(level: 10).

    Therefore, you should always list automatically generated fields as synthesized fields. Elembic won't forbid you from adding "hidden fields" during synthesis, but then they will be seen as inexistent by the framework, so it is recommended to always list auto-generated fields as synthesized fields, with proper documentation for them.

    Read more about synthesis below.

Synthesizing fields

Some elements will have conveniently auto-generated fields, which are created after set rules are applied, but before show rules. To do this, there are two steps:

  1. List those fields as "synthesized" fields in the fields array. Such fields cannot be manually specified by users, however they can be matched on by functions such as e.select.

  2. Create a synthesis step for your element with synthesize: fields => updated fields. Here, you can, for example, access Typst context, as well as use e.counter(it) to read the counter, and so on.

Here's a bit of an artificial example just to showcase the functionality: we combine a stroke field and a body field to generate an applied field, which is what is effectively displayed.

#import "@local/elembic:0.0.1" as e: field, types

#let frame = e.element.declare(
  "frame",
  prefix: "@preview/my-package,v1",
  display: it => it.applied,
  fields: (
    field("body", content, doc: "Body to display.", required: true),
    field("stroke", types.option(stroke), doc: "Stroke to add around the body."),
    field("applied", content, doc: "Frame applied to the body.", synthesized: true)
  ),
  synthesize: it => {
    it.applied = block(stroke: it.stroke, it.body)
    it
  }
)

#show e.selector(frame): it => {
  let applied = e.fields(it).applied
  [The applied field was #raw(repr(applied)), as seen below:]
  it
}

#frame(stroke: red + 2pt)[abc]

"The applied field was block(stroke: 2pt + rgb("#ff4136"), body: [abc]), as seen below:", followed by "abc" inside a box with red stroke

Overriding the constructor and argument parsing

Disabling typechecking

You can use typecheck: false to generate an argument parser that doesn't check fields' types. This is useful to retain type information but disable checking if that's needed. The performance difference is likely to not be too significant, so that likely wouldn't be enough of a reason, unless too many advanced typesystem features are used.

Custom constructor

You can use construct: default-constructor => (..args) => value to override the default constructor for your custom type. You should use construct: rather than creating a wrapper function to ensure that data retrieval functions, such as e.data(func), still work.

Custom argument parsing

You can use parse-args: (default arg parser, fields: dictionary, typecheck: bool) => (args, include-required: bool) => dictionary with fields to override the built-in argument parser. This is used both for the constructor and for set rules.

Here, args is an arguments and include-required: true indicates the function is being called in the constructor, so required fields must be parsed and enforced.

However, include-required: false indicates a call in set rules, so required fields must not be parsed and forbidden.

Argument sink

Here's how you'd use this to implement a positional argument sink:

#let sunk = e.element.declare(
  "sunk",
  display: it => {
    (it.run)(it)
  },
  fields: (
    field("values", e.types.array(stroke), required: true),
    field("run", function, required: true, named: true),
    field("color", color, default: red),
    field("inner", content, default: [Hello!]),
  ),
  parse-args: (default-parser, fields: none, typecheck: none) => (args, include-required: false) => {
    let args = if include-required {
      // Convert positional arguments into a single 'values' argument
      let values = args.pos()
      arguments(values, ..args.named())
    } else if args.pos() == () {
      args
    } else {
      assert(false, message: "element 'sunk': unexpected positional arguments\n  hint: these can only be passed to the constructor")
    }

    default-parser(args, include-required: include-required)
  },
  prefix: ""
)

// Use 'run: func' as an example to test and ensure we received the correct fields
#sunk(
  5pt, 10pt, black, 5pt + black,
  run: it => assert.eq(it.values, (5pt, 10pt, black, 5pt + black).map(stroke))
)

Labels and references

Labelable elements (with labelable: true, the default) can be labelled with #elem(label: <label-name>). Compared to what would be a more usual syntax (#elem() <label-name>, which should not be used), using label as an argument not only allows accessing the element's final fields in show rules, it also allows references to work, when properly setup.

To add reference support to an element, add reference: (...) in the element's declaration. It requires the keys supplement and numbering, which can be their usual values (content and string) or functions final fields => value, if you want the user to be able to override those values through supplement and numbering fields in the element. However, your reference can also be fully customized with (custom: fields => content).

Then, you must tell your user to call #show: e.prepare() at the top of their own document, so that references will work properly.

By default, the number used by references is the element's own counter (accessible with e.counter(elem)), stepped by one for each element. You can use e.g. count: counter => counter.update(n => n + 2) or even count: counter => fields => (something using fields) to change this behavior.

#import "@local/elembic:0.0.1" as e: field

// The line below must be written by the END USER for references to work!
#show: e.prepare()

#let theorem = e.element.declare(
  "theorem",
  prefix: "my-package",

  display: it => [*Theorem #e.counter(it).display("1"):* #text(fill: it.fill)[#it.body]],

  reference: (
    supplement: [Theorem],
    numbering: "1"
  ),

  fields: (
    e.field("body", content, required: true),
    e.field("fill", e.types.paint, doc: "The text fill.", default: red),
  )
)

#theorem(label: <my-thm>)[*Hello world*]
#theorem(fill: blue, label: <other-thm>)[*1 + 1 = 2*]

Here is @my-thm

Here is @other-thm

"Theorem 1: Hello world" (in red), "Theorem 2: 1 + 1 = 2" (in blue), "Here is Theorem 1", "Here is Theorem 2"

Outline

To enable outline support, you may either use outline: auto on an element that already supports references - in which case it will simply reuse the reference supplement and numbering in the outline - or use outline: (caption: fields => content), which will show an extra caption beside supplement and numbering if they exist, otherwise (if the element doesn't support references, or uses (custom: ...) for references) it will simply display the caption by itself.

Note that the user doesn't need #show: e.prepare() for outline support to work, but it's good practice since it's needed for references.

The user may then display the element's outline using #outline(target: e.selector(elem, outline: true)).

#import "@local/elembic:0.0.1" as e: field

#show: e.prepare()

#let theorem = e.element.declare(
  "theorem",
  prefix: "my-package",

  display: it => [*Theorem #e.counter(it).display("1"):* #text(fill: it.fill)[#it.body]],

  reference: (
    supplement: [Theorem],
    numbering: "1"
  ),

  outline: auto,

  fields: (
    e.field("body", content, required: true),
    e.field("fill", e.types.paint, doc: "The text fill.", default: red),
  )
)

#outline(target: e.selector(theorem, outline: true))

#theorem(label: <my-thm>)[*Hello world*]
#theorem(fill: blue, label: <other-thm>)[*1 + 1 = 2*]

Outline shows: "Contents", "Theorem 1 ... 1", "Theorem 2 ... 1", referring to the two theorems below

Extra declaration options

Setting overridable default styles with template

You can have a custom template for your element with the template option. It's a function displayed element => content where you're supposed to apply default styles, such as #set par(justify: true), which the user can then override using the element's outer selector (e.selector(elem, outer: true)) in a show-set rule:

#import "@local/elembic:0.0.1" as e: field

#let elem = e.element.declare(
  "elem",
  // ...
  display: _ => [Hello world!]
  template: it => {
    set par(justify: true)
    it
  },
)

// Par justify is enabled
#elem()

// Overriding:
#show e.selector(elem, outer: true): set par(justify: false)

// Par justify is disabled
#elem()

Extra preparation with prepare

If your element needs some special, document-wide preparation (in particular, show and set rules) to function, you can specify prepare: (elem, doc) => ... to declare.

Then, the end user will need to write #show: e.prepare(your-elem, /* any other elems... */) at the top of their document to apply those rules.

Note that e.prepare, with or without arguments, is also used to enable references to custom elements, as noted in the relevant page.

#import "@local/elembic:0.0.1" as e: field

#let elem = e.element.declare(
  "elem",
  // ...
  display: it => {
    figure(supplement: [Special Figure], numbering: "1.", kind: "some special figure created by your element")[abc]
  },
  prepare: (elem, it) => {
    // As an example, ensure some special figure you create has some properties
    show figure.where(kind: "some special figure created by your element"): set text(red)
    it
  },
)

// End user:
#show: e.prepare(elem)

// Now the generated figure has red text
#elem()

Making more context available with contextual: true

Some elements may need to access values from other elements' set rules in their display functions or in the functions used to generate reference supplements or outline captions, for example. If that is the case, you will need to enable contextual: true, which enables the usage of (e.ctx(it).get)(elem).field-name to get the latest value for that field considering set rules.

In addition, this option might also need to be enabled particularly in the off-chance you need to access the currently set value specifically of bibliography.title from context, due to how Elembic uses that property in its inner workings. Other values - such as text.fill or par.justify -, however, are already available to display and other functions by default, so you do not have to enable this option in those cases.

Regardless, this option should be avoided if possible: it decreases the benefits from memoization, which can lead to a significant performance penalty in the case where the element is used too many times (say, several hundred). If the element isn't meant to be used too many times (maybe once or twice, in the case of template-internal elements, for example), then that shouldn't be a concern. (Otherwise, this option would be enabled by default.)

Using elements

This chapter is meant to provide insight on how end users of custom elements created with Elembic can use them to their full potential, including customization and features.

Prepare

One important procedure to remember is to use e.prepare if the element demands it. Most elements might only need support for custom references, which can be enabled with the following show rule at the top of the end user's document:

#show: e.prepare()

// Great! Now custom references from all elements will work

However, some elements have their own custom document-wide preparation functions. If this is the case for one or more elements you'll need to use (read their documentation to know), you can include them as arguments to prepare:

#show: e.prepare(elem1, elem2)

// Great! Those elements have run their 'prepare' functions (if needed - depends on the element).

Using, styling and configuring elements

Are you writing a document and using an element created by a third-party package using elembic? This page has some useful information for you.

Creating and customizing an element instance

A package will expose the constructor function for the element. This function may receive some arguments (fields) to configure the element's appearance, and creates content which you can then place in your document.

For example, a package might expose a container element with a single required field, its body. Those fields are usually specified positionally, without their names. It might also have a few optional fields, such as the box's width, which is auto (to adjust with the body) by default. Such fields are usually specified by their names.

Here's how we'd place a container into the document, exhibiting what the package defined that it should look like:

// Sample package name (it doesn't actually exist!)
#import "@preview/container-package:0.0.1": container

#container([Hello world!], width: 1cm)

// OR (syntactically equivalent)

#container(width: 1cm)[Hello world!]

Show rules: fully overriding appearance

You can fully override the appearance of an element using show rules. For this and other operations with custom elembic elements, you will have to import elembic. It is common to alias it to just e for simplicity.

You can use the e.selector(element) function to retrieve a selector you can use in show rules. This selector will match all occurrences of the element. You can then use e.fields(it) inside the show rule (where it is the element instance being replaced) to receive the final value provided for each of its fields. This can be used to conditionally override its appearance based on the element's properties.

For example, here's a show rule that would display the width field alongside each container.

#import "@local/elembic:0.0.1" as e
#import "@preview/container-package:0.0.1": container

#show e.selector(container): it => {
  let fields = e.fields(it)
  [Here's a container with width #fields.width: #it]
}

// This will display:
// "Here's a container with width 1cm: Hello world!"
#container(width: 1cm)[Hello world!]

Conditional show rules: matching elements with certain properties

You can also only apply show rules to elements with certain properties. For example, you may want to remove all containers with a red or blue fill property. This requires using the e.select(..filters, (..selectors) => your document) function, which will generate selectors for the given filters to allow you to apply show rules on them:

#import "@local/elembic:0.0.1" as e
#import "@preview/container-package:0.0.1": container

#e.select(
  container.with(fill: red),
  container.with(fill: blue),
  (red-container, blue-container) => [
    // Remove red and blue fill containers
    #show red-container: none
    #show blue-container: none

    // This container is removed.
    #container(fill: red)[Hello world!]
    // This container is also removed.
    #container(fill: blue)[Hello world!]

    // But this container is kept.
    #container(fill: yellow)[Hello world!]
  ]
)

Set rules: Configuring default values for fields

Often, you will want to have a common style for a particular element across your document, without repeating that configuration by hand all the time. For example, you might want that all container instances have a red border. You might also want them to have a fixed height of 1cm. Let's assume that element has two fields, border and height, which configure exactly those properties.

You may then use elembic's set rules through #show: e.set_(element, field: value, ...), which are similar to Typst's own set rules: they change the values of unspecified fields for all instances of an element within a certain scope. When they are not within a scope, they apply to the whole document.

Tip: Make sure to group your set rules together at the start of the document, if possible (or wherever they are going to be applied). That is, avoid adding text and other elements between them. This causes elembic to group them up and apply them in one go, avoiding one of its main limitations in its default style mode: a limit of up to ~30 non-consecutive set rules. (The limitation is circumventable, but at the cost of reduced performance. Read more at the Limitations page.)

Here's how you would set the borders of all container instances to red:

#import "@local/elembic:0.0.1" as e
#import "@preview/container-package:0.0.1": container

#show: e.set_(container, border: red)

// This will implicitly have a red border
#container(width: 1cm)[Hello world!]

Now, let's set all containers' heights to 1cm as well. Note that you can use e.apply to conveniently and safely apply multiple rules at once (while also circumventing the limitation mentioned above):

#import "@local/elembic:0.0.1" as e
#import "@preview/container-package:0.0.1": container

#show: e.apply(
  e.set_(container, border: red)
  e.set_(container, height 1cm)
)

// This will implicitly have a red border and a height of 1cm
#container(width: 1cm)[Hello world!]

Scoping set rules

As a general tip, and also having the above limitation in mind, it is useful to always restrict temporary set rules to a certain scope so they don't apply to the whole document. This not only avoids unintended behavior and signals intent, but also ensures you will keep a minimal amount of set rules active at once.

You can create a scope with #[]:

// This container has the default border
#container[Hello world!]

#[
  #show: e.set_(container, border: red)

  // These containers have a red border
  #container[Hello world!]
  #container[Hello world!]
  #container[Hello world!]
]

// This container has the default border again
// (The set rule is no longer in effect)
#container[Hello world!]

Show-set rules

You can conditionally apply native set rules on custom elements using #show e.selector(element, outer: true): set something(..fields). The outer selector ensures that any accesses to style context within the element's code will return the updated field values.

However, there is currently no support for conditional custom element set rules. While writing #show selector: e.set_(...) might appear to work, with selector either being a native selector or a non-native selector coming from either e.selector or e.select, the set (or revoke, reset...) rules will apply in reverse order that way due to how Typst's show rules work. A fix is expected to come in the future, but for now, such rules must be written in reverse (which is unfortunately not possible when nesting scopes as opposed to just applying rules in the same scope).

Note, however, that #show selector: e.apply(e.set_(...), e.set_(...), e.revoke(...)) will apply those particular rules in the correct order (first the two sets then the revoke, rather than the revoke first), which is an alternative if you have no other option.

Revoke and reset rules: temporarily reverting the effect of a set rule

Now, about the opposite situation of set rule scoping: what if you want to ensure a particular set rule has no effect for a limited part of the document? For example, you might be setting all container borders to red, but maybe there's this particular, small section of the document where you want them to use the default border instead.

To do this, you can give a name to the set rule which you can then revoke in a limited scope, with elembic's revoke rules:

// 1. Give a name to the rule
#show: e.named("red border", e.set_(container, border: red))

// This container has a red border
#container[Hello world!]

#[
  // Temporarily revoke our border-changing set rule from earlier
  #show: e.revoke("red border")

  // These containers have the default border again!
  #container[Hello world!]
  #container[Hello world!]
  #container[Hello world!]
]

// This container has a red border again
// (The revoke rule is no longer in effect,
// so the initial set rule is back in action)
#container[Hello world!]

However, if you have many set rules you want to revert, or if you want to revert unnamed set rules that a particular template chose and you cannot change, you can even use a reset rule to temporarily undo all set rules to a certain element, reverting it to its default state:

// A bunch of set rules which we may want to revert later
// (We could just name the 'apply' and it will set the names
// of all of them, but let's pretend we can't do that)
#show: e.apply(
  e.set_(container, border: red),
  e.set_(container, height: 2cm),
  e.set_(container, fill: yellow),
  e.set_(container, width: 1cm),
)

// This container has a red border, yellow fill,
// 2cm of height and 1cm of width (that's a lot
// of changes!)
#container[Hello world!]

#[
  // Temporarily reset ALL set rules for containers here
  #show: e.reset(container)

  // These containers have the default appearance again!
  #container[Hello world!]
  #container[Hello world!]

  // Might even want to temporarily change some properties
  // for these containers...
  #[
    #show: e.set_(container, border: red)
    #container[Hello world!]
    #container[Hello world!]
  ]
]

// This container has all the field values we set earlier
// again (red border, yellow fill, 2cm height, 1cm width),
// since the 'reset' is no longer in effect
#container[Hello world!]

Scripting with Elements

Elements naturally contain data, such as the fields specified for them, as well as their names, unique IDs (eid), counters, and so on.

You can retrieve this data using the dedicated data-extraction functions made available by Elembic. They are all based around e.data, the main function which returns a data dictionary for lots of different types. Here are some of the most useful functions and tasks:

Accessing fields

You can use e.fields(instance). Note that the returned dictionary will be incomplete if the element was just created. It is only complete in show rules, when set rules and default fields have been resolved.

#show: e.set_(elem, field-c: 10)

#let instance = elem("abc", field-a: 5, field-b: 6)

// Field information incomplete: set rules not yet resolved
#assert.eq(e.fields(instance), (pos-field: "abc", field-a: 5, field-b: 6))

#show e.selector(elem): it => {
  // Field information is complete in show rules
  assert.eq(e.fields(it), (some-non-required-field: "default value", field-c: 10, pos-field: "abc", field-a: 5, field-b: 6))
}

#instance

Accessing element ID and constructor

You can use e.eid(instance) or e.eid(elem) to obtain the corresponding unique element ID. This is always the same for types produced from the same element.

Similarly, e.func(instance) will give you the constructor used to create this instance. However, note that the constructor may change between versions of a package without changing the element ID. That is, e.eid(a) == e.eid(b) might hold (they come from the same element), but e.func(a) == e.func(b) might not. This leads us to the point below.

Comparing elements

To compare two element instances a and b, the recommended procedure is

#let eq(a, b) = e.eid(a) == e.eid(b) and e.fields(a) == e.fields(b)

This ensures tiny changes to the element's declaration between two versions of a package, without changing the eid, won't cause the comparison to become false if you were to compare a == b directly, or e.func(a) == e.func(b) (which would also change).

Type system

In order to ensure type-safety for your element's fields, Elembic has its own type system which is worth being aware about. It not only allows you to customize how types are checked for each field, but even create your own, brand new types, much like you can create elements!

Purpose

Types are an important guarantee that users of your elements will specify the correct types for each field. However, note that Elembic's type-checking utilities can be used anywhere, not only for elements!

Note that, for elements, when a field is created with field, it is necessary to specify a field name and a type, or any to accept any type:

#let elem = e.element.declare(
  // ...
  fields: (
    field("amount", int /* <--- here! */, required: true),
    field("anything", e.types.any, default: none)  // <--- anything goes!
  )
)

#elem(5)  // OK!
// #elem("abc")  // Error: "expected integer, found string"
#elem(5, anything: "string")  // OK!
#elem(5, anything: 20pt)  // OK!

Native types (such as int) or any are not the only types which can be specified for element fields. In general, anything that is representable in the typeinfo format, described below, can be used as a field type.

Typeinfo

"Typeinfo" is the structure (represented as a Typst dictionary) that describes, in each field:

  1. type-kind: The kind of a type - "native" for native types, "custom" for custom types, and other values for other special types (such as "any", "never", "union" for the output of types.union, "wrapped" as the output of types.wrap, "collection" for types.array, and so on);
  2. name: the name of a type;
  3. input: which basic types may be cast to it (e.g.: integer or string). This uses the type ID format for custom types, obtained with types.typeid(value), of the form (tid: ..., name: ...). For native types, the output of type(value) is used;
  4. output: which basic types the input may be cast to (e.g.: just string). This uses the same format as input;
  5. check: an extra check (function input value => true / false) that tells whether a basic input type may be cast to this type, or none for no such check;
  6. cast: an optional function to cast an input value that passed the check to an output value (function input value => output value). Must always succeed, or panic. If none, the input type is kept unchanged as an output after casting to this type. An integer would remain an integer without a cast, for example (which may be the intention).
  7. error: an optional function that is called when the check fails (input value => string), indicating why the check may have failed.
  8. default: the default value for the type when using it for a field. This is used if the field isn't required and the element author didn't specify a default. This must either be an empty array () for no default, or a singleton array (value,) (note the trailing comma) to specify a default.
  9. fold: an optional function (outer output value, inner output value) => new output value to indicate how to combine two consecutive values of this type, where inner is more prioritized over outer. For example, for the stroke type, one would have (5pt, black) => 5pt + black, but (5pt + black, red) => 5pt + red since red is the inner value in that case. If this is none, the inner value always completely overrides the outer value (the default behavior). This is used in set rules: they don't completely override the value of the field if its type has fold.
    • Note that fold may also be auto to mean (a, b) => a + b, for performance reasons.
  10. data: some extra data giving an insight into how this typeinfo was generated. What this is varies per kind of typeinfo. For example, for native types such as int, this will be the native type itself.

Special types

There are some special types which don't correspond precisely to a single native or custom type, but have special behavior.

types.any: Accepting any values

You can use types.any to ensure a field can receive any value (disables typechecking, folding, and any kind of special behavior).

types.never: Accepts no values

This exists more for completeness, but this is a type with no input types, so it is impossible to cast to it.

types.literal: Accepting only a single value of a type

You can use types.literal("abc") to only accept values equal to "abc". As a shorthand, you can write just "abc" as the type for the field (unless it is a dictionary or function, as it'd then be ambiguous with other types).

To accept more than one literal, you can use types.union, e.g. types.union("abc", "def") for either "abc" or "def".

Note that casting is preserved: types.literal(5.0) will accept either the integer 5 or the float 5.0. To accept just the float 5.0, you can use types.exact(types.literal(5.0)), for example.

types.custom-type: Accept custom types themselves

To accept any custom type as a value, you can use types.custom-type. However, note that, due to ambiguity when passing functions, the user will have to pass e.data(the-custom-type-constructor) instead of just the constructor itself for the cast to work / for the field to typecheck.

The same goes for literals: to receive one of multiple custom types, you'll have to use union(literal(e.data(type1)), literal(e.data(type2))). Note that you have to use literal to disambiguate.

For native types, you can just use type as the type, and they will be accepted without any special ceremony by the user (they can pass int to a type field and it works). However, note that writing literal(int) explicitly, for example, is still required to only accept certain native types' constructors.

Native and custom elements are not currently supported on their own, although you may have some success in accepting function.

Type combinators

Here's some information about some special types combining other types.

types.union: Either of multiple types

You can use types.union(int, str) to indicate that a field depends on either an integer or a string.

As special aliases, there are types.option(typ) and types.smart(typ) for types.union(none, typ) and types.union(auto, typ) respectively.

Note: Unions are ordered. This means that types.union(int, float) != types.union(float, int). This is relevant when two or more types in the union can accept the same native type, with differing checks or casts. In the case of int and float, the integer 5 will remain the integer 5 when casting to types.union(int, float), but will be casted to the float 5.0 when casted to types.union(float, int). (Of course, a float such as 4.0 will remain a float in both cases, since it isn't accepted by int). Keep this in mind!

Folding in unions

At the moment, general unions (so, other than option and smart) completely disable folding, e.g. types.union(int, stroke) will disable folding between 4pt and black, with black overriding the previous value. This could change in the future for some cases, but it cannot be kept in all cases since fold operates on output types, of which we have already lost the original type information, so it is generally ambiguous on which fold function to use.

However, option and smart are exceptions: they will fold the non-none and non-auto type (respectively) if it can be folded. However, an explicit none or auto always takes priority. For example, with types.option(stroke), when setting none followed by 4pt, the result is stroke(4pt); when setting 4pt followed by black, the result is 4pt + black; when setting black followed by none, the result is none. (Same would happen with types.smart(stroke) and auto.)

types.exact: Disable casting for a type

You can use types.exact(typ) to ensure there is no casting involved for this type. For example, types.exact(float) ensures integers won't cast to floats (they are normally accepted). Also, types.exact(stroke) ensures only stroke(5pt) can be passed to a field with that type, not 5pt itself. Finally, types.exact(my-custom-type), where my-custom-type has custom casts from existing types, disables those casts, allowing only an instance of my-custom-type itself to be used for a field with that type.

types.array: Array of a type

You can use types.array(typ) to accept arrays of elements of the same type.

types.dict: Dictionary with values of a type

You can use types.dict(typ) to accept dictionaries with values of the same type. (Note that dictionary keys are all strings.)

For example, (a: 5, b: 6) is a valid dict(int), but not a valid dict(str).

Wrapping types

You may use the types.wrap(type, ..overrides) to override certain aspects of a type. For example, you can use types.wrap(int, default: (5,)) to have an int type with a default value of 5 (note that we use an array with a single element, as opposed to () (empty array) which means no default), types.wrap(int, check: old-check => value => value > 0) to only accept positive integers, or types.wrap(int, output: (float,), cast: old-cast => float) to cast all integers to floats.

It is important you update the output: if you override cast: to indicate the new list of possibly casted-to types. Otherwise, Elembic will just guess and replace it with ("any",).

For each override, if you pass a function, you receive the old value and must return the new value (which can, itself, be a function, if that property supports it).

For more information on which properties are supported, you can read about the typeinfo format in the chapter's top-level page.

Native types

Typst-native types, such as int and str, are internally represented by typeinfos of "native" type-kind, which can be obtained with e.types.native.typeinfo(type). They can generally be specified directly on type positions (e.g. types.union(int, float)) without using that function, as Elembic will automatically convert them.

For fill-like fields, there is also e.types.paint, an alias for types.union(color, gradient, tiling).

Of note, some native types, such as float, stroke and content, supporting casting, e.g. str => content, int => float and length => stroke. You can use e.types.exact to disable casting for a type.

In addition, some native types support folding, a special behavior when specifying consecutive set rules over the same field with that type. The most notable one is stroke: specifying a stroke of 4pt and then black generates 4pt + black. There is also array: specifying an array (2, 3) and then (4, 5) on set rules generates (2, 3, 4, 5) at the end. Finally, alignment is worthy of mention: specifying left + bottom, right and then top, in that order, generates the final value of right + top.

You can disable folding with e.types.wrap, setting fold: none.

Elements as types

Custom elements

Custom elements can be used directly as types (in fields etc.) to specify that you only want to accept a certain custom element as input. Note that you can use a union to accept more than one custom element.

#import "@local/elembic:0.0.1" as e

#let elem = e.element.declare(...)

#assert.eq(
  e.types.cast(
    elem(field: 5),
    elem
  ),
  (true, elem(field: 5))
)

Native elements

You can use e.types.native-elem(native element function) to only accept instances of a particular native element.

For example, e.types.native-elem(heading) only accepts headings. (You can use a union to accept more than one native element.)

#import "@local/elembic:0.0.1" as e

#assert.eq(
  e.types.cast(
    [= hello!],
    e.types.native-elem(heading)
  ),
  (true, [= hello!])
)

Helper functions

You can use types.cast(element, type) to try to cast an element to a type; this will return either (true, casted-value) or (false, error-message).

There are also types.typeid(value) to obtain the "type ID" of this value (its type if it's a native type instance, or (tid: ..., name: ...) if it's an instance of a custom type, as well as "custom type" if it's a custom type literal obtained with e.data(custom type)), which is the format used in input and output.

In addition, types.typename(value) returns the name of the type of that value as a string, similar to str(type(native type)) but extended to custom types.

Finally, types.typeinfo(type) will try to obtain a typeinfo object from that type (always succeeds if it's a typeinfo object by itself), returning (true, typeinfo) on success and (false, error-message) on failure.

Custom types

Elembic supports creating your own custom types, which are used to represent data structures with specific formats and fields. They do not compare equal to existing types, not even to dictionaries, even though they are themselves represented by dictionaries. They have their own unique ID based on prefix and name, similar to custom elements. It is assumed that custom types with the same unique ID are equal, so it should be changed if breaking changes ensue.

Custom types can be used as the types of fields in elements, or on their own through types.cast.

Custom types have typechecked fields in the constructor and support casting from other types, meaning you can accept e.g. an integer for a field taking a custom type.

Declaring a custom type

You can use e.types.declare. Make sure to specify a unique prefix to distinguish your type from others with the same name.

You should specify fields created with e.field. They can have an optional documentation with doc.

#import "@local/elembic:0.0.1" as e: field, types

#let person = e.types.declare(
  "person",
  prefix: "@preview/my-package,v1",
  fields: (
    field("name", str, doc: "Person's name", required: true),
    field("age", int, doc: "Person's age", default: 40),
    field("preference", types.any, doc: "Anything the person likes", default: none)
  ),
)

#assert.eq(
  e.repr(person("John", age: 50, preference: "soup")),
  "person(age: 50, preference: \"soup\", name: \"John\")"
)

Your type, in this case person, can then be used as the type of an element's field, or used with e.types.cast in other scenarios.

Take a look at the following chapters, such as Casts, to read about more options that can be used to customize your new type.

Casts

You can add casts from native types (or any types supported by the type system, such as literals) to your custom type, allowing fields receiving your type to also accept the casted-from types.

This is done through the casts: (cast1, cast2, ...) parameter. Each cast takes at least (from: typename, with: constructor => value => constructor(...)), where value was already casted to typename beforehand (e.g. if typename is float, then value will always have type float, even if the user passes an integer). It may optionally take check: value => bool as well to only accept that typename if check(value) is true.

In the future, automatic casting from dictionaries will be supported (although it can already be manually implemented).

Note: Casts are ordered. This means that specifying a cast from int and then float is different from specifying a cast from float followed by int, for example. This is relevant when two or more types in the union can accept the same native type, with differing checks or casts. In the case of int and float, the integer 5 will trigger the cast from int as you'd expect if the int cast comes first, but will converted to 5.0 before triggering the cast from float if the float cast is specified first. (Of course, a float such as 4.0 will trigger the cast from float in both cases, since it isn't accepted by int). Keep this in mind!

These principles are made evident in the example below:

#import "@local/elembic:0.0.1" as e: field, types

#let person = e.types.declare(
  "person",
  prefix: "@preview/my-package,v1",
  fields: (
    field("name", str, doc: "Person's name", required: true),
    field("age", int, doc: "Person's age", default: 40),
    field("preference", types.any, doc: "Anything the person likes", default: none)
  ),
  casts: (
    (from: "Johnson", with: person => name => person(name, age: 45)),
    (from: str, check: name => name.starts-with("Alfred "), with: person => name => person(name, age: 30)),
    (from: str, with: person => name => person(name)),
  )
)

// Manually invoke typechecking and cast
// Notice how the first succeeding cast is always made
#assert.eq(
  types.cast("Johnson", person),
  (true, person("Johnson", age: 45))
)
#assert.eq(
  types.cast("Alfred Notexistent", person),
  (true, person("Alfred Notexistent", age: 30))
)
#assert.eq(
  types.cast("abc", person),
  (true, person("abc", age: 40))
)

Wait, that sounds a lot like a union!

That's right: most of the casting generation code is shared with union! The union code also contains optimizations for simple types, which we take advantage of here.

The main difference here is that these casts become part of the custom type itself. This means that they will always be there when using this custom type as the type of a field.

However, it's possible to override this behavior: users of the type can disable the casts by wrapping the custom type in the types.exact combinator.

Other custom type options

Folding

If all of your fields may be omitted (for example), or if you just generally want to be able to combine fields, you could consider adding folding to your custom type with fold: auto, which will combine each field individually using their own fold methods. You can also use fold: default constructor => (outer, inner) => combine inner with outer, giving priority to inner for full customization.

Custom constructor and argument parsing

Much like elements, you can use construct: default-constructor => (..args) => value to override the default constructor for your custom type. You should use construct: rather than create a wrapper function to ensure that data retrieval functions, such as e.data(func), still work.

You can use parse-args: (default arg parser, fields: dictionary, typecheck: bool) => (args, include-required: true) => dictionary with fields to override the built-in argument parser to the constructor (instead of overriding the entire constructor). include-required is always true and is simply a remnant from elements' own argument parser (which share code with the one used for custom types).

Argument sink

Here's how you'd use this to implement a positional argument sink:

#let sunk = e.types.declare(
  "sunk",
  fields: (
    field("values", e.types.array(stroke), required: true),
    field("color", color, default: red),
    field("inner", content, default: [Hello!]),
  ),
  parse-args: (default-parser, fields: none, typecheck: none) => (args, include-required: true) => {
    let args = if include-required {
      // Convert positional arguments into a single 'values' argument
      let values = args.pos()
      arguments(values, ..args.named())
    } else if args.pos() == () {
      // 'include-required' is always true for types, but keeping these here
      // just for completeness
      args
    } else {
      assert(false, message: "element 'sunk': unexpected positional arguments\n  hint: these can only be passed to the constructor")
    }

    default-parser(args, include-required: include-required)
  },
  prefix: ""
)

#assert.eq(
  e.fields(sunk(5pt, black, 5pt + black, inner: [Inner])),
  (values: (stroke(5pt), stroke(black), 5pt + black), inner: [Inner], color: red)
)

Reference

This chapter contains information about top-level constants and functions exported by each module in elembic.

Element functions

At the moment, all of the functions in this module are exported exclusively at the top-level of the package, other than declare which must be used as e.element.declare.

Declaration

e.element.declare

Creates a new element, returning its constructor. Read the "Creating custom elements" chapter for more information.

Signature:

#e.declare(
  name,
  prefix: str,
  display: function,
  fields: array,
  parse-args: auto | function(arguments, include-required: bool) -> dictionary = auto,
  typecheck: bool = true,
  allow-unknown-fields: bool = false,
  template: none | function(displayed element) -> content = none,
  prepare: none | function(elem, document) -> content = none,
  construct: none | function(constructor) -> function(..args) -> content = none,
  scope: none | dictionary | module = none,
  count: none | function(counter) -> content | function(counter) -> function(fields) -> content = counter.step,
  labelable: bool = true,
  reference: none | (supplement: none | content | function(fields) -> content, numbering: none | function(fields) -> str | function, custom: none | function(fields) -> content) = none,
  outline: none | auto | (caption: content) = none,
  synthesize: none | function(fields) -> synthesized fields,
  contextual: bool = false,
) -> function

Arguments:

  • name: The element's name.
  • prefix: The element's prefix, used to distinguish it from elements with the same name. This is usually your package's name alongside a (major) version.
  • display: Function fields => content to display the element.
  • fields: Array with this element's fields. They must be created with e.field(...).
  • parse-args: Optional override for the built-in argument parser (or auto to keep as is). Must be in the form function(args, include-required: bool) => dictionary, where include-required: true means required fields are enforced (constructor), while include-required: false means they are forbidden (set rules).
  • typecheck: Set to false to disable field typechecking.
  • allow-unknown-fields: Set to true to allow users to specify unknown fields to your element. They are not typechecked and are simply forwarded to the element's fields by the argument parser.
  • template: Optional function displayed element => content to define overridable default set rules for your elements, such as paragraph settings. Users can override these settings with show-set rules on elements.
  • prepare: Optional function (element, document) => content to define show and set rules that should be applied to the whole document for your element to properly function.
  • construct: Optional function that overrides the default element constructor, returning arbitrary content. This should be used over manually wrapping the returned constructor as it ensures set rules and data extraction from the constructor still work.
  • scope: Optional scope with associated data for your element. This could be a module with constructors for associated elements, for instance. This value can be accessed with e.scope(elem), e.g. #import e.scope(elem): sub-elem.
  • count: Optional function counter => (content | function fields => content) which inserts a counter step before the element. Ensures the element's display function has updated context to get the latest counter value (after the step / update) with e.counter(it).get(). Defaults to counter.step to step the counter once before each element placed.
  • labelable: Defaults to true and allows specifying #element(label: <abc>), which not only ensures show rules on that label work and have access to the element's final fields, but also allows referring to that element with @abc if the reference option is configured and #show: e.prepare() is applied. When false, the element may have a field named label instead, but it won't have these effects.
  • reference: When not none, allows referring to the new element with Typst's built-in @ref syntax. Requires the user to execute #show: e.prepare() at the top of their document (it is part of the default rules, so prepare needs no arguments there). Specify either a supplement and numbering for references looking like "Name 2", and/or custom to show some fully customized content for the reference instead.
  • outline: When not none, allows creating an outline for the element's appearances with #outline(target: e.selector(elem, outline: true)). When set to auto, the entries will display "Name 2" based on reference information. When a caption is specified via a dictionary, it will display as "Name 2: caption", unless supplement and numbering for reference are both none, in which case it will only display caption.
  • synthesize: Can be set to a function to override final values of fields, or create new fields based on final values of fields, before the first show rule. When computing new fields based on other fields, please specify those new fields in the fields array with synthesized: true. This forbids the user from specifying them manually, but allows them to filter based on that field.
  • contextual: When set to true, functions fields => something for other options, including display, will be able to access the current values of set rules with (e.ctx(fields).get)(other-elem). In addition, an additional context block is created, so that you may access the correct values for native-elem.field in the context. In practice, this is a bit expensive, and so this option shouldn't be enabled unless you need precisely bibliography.title, or you really need to get set rule information from other elements within functions such as synthesize or display.

Example:

#import "@local/elembic:0.0.1" as e: field

// For references to apply
#show: e.prepare()

#let elem = e.element.declare(
  "elem",
  prefix: "@preview/my-package,v1",
  display: it => {
    [== #it.title]
    block(fill: it.fill)[#it.inner]
  },
  fields: (
    field("fill", e.types.option(e.types.paint)),
    field("inner", content, default: [Hello!]),
    field("title", content, default: [Hello!]),
  ),
  reference: (
    supplement: [Elem],
    numbering: "1"
  ),
  outline: (caption: it => it.title),
)

#outline(target: e.selector(elem, outline: true))

#elem()
#elem(title: [abc], label: <abc>)
@abc

Rules and styles

e.apply

Apply multiple rules (set rules, etc.) at once.

These rules do not count towards the "set rule limit" observed in Limitations; apply itself will always count as a single rule regardless of the amount of rules inside it (be it 5, 50, or 500). Therefore, it is recommended to group rules together under apply whenever possible.

Note that Elembic will automatically wrap consecutive rules (only whitespace or native set/show rules inbetween) into a single apply, bringing the same benefit.

Signature:

#e.apply(
  ..rules: e.apply(...) | e.set_(...) | e.revoke(...) | e.reset(...),
  mode: auto | style-modes.normal | style-modes.leaky | style-modes.stateful = auto
) -> function

Example:

#show: e.apply(
  set_(superbox, fill: red),
  set_(superbox, width: 100)
)

e.get

Reads the current values of element fields after applying set rules.

The callback receives a 'get' function which can be used to read the values for a given element. The content returned by the function, which depends on those values, is then placed into the document.

Signature:

#e.get(
  receiver: function(function) -> content
) -> content

Example:

#show: e.set_(elem, fill: green)
// ...
#e.get(get => {
  // OK
  assert(get(elem).fill == green)
})

e.named

Name a certain rule. Use e.apply to name multiple rules at once. This is used to be able to revoke the rule later with e.revoke.

Please note that, at the moment, each rule can only have one name. This means that applying multiple named on the same set of rules will simply replace the previous names.

However, more than one rule can have the same name, allowing both to be revoked at once if needed.

Signature:

#e.named(
  name: str,
  rule: e.apply(...) | e.set_(...) | e.revoke(...) | e.reset(...),
) -> function

Example:

#show: e.named("cool rule", e.set_(elem, fields))

e.prepare

Applies necessary show rules to the entire document so that custom elements behave properly. This is usually only needed for elements which have custom references, since, in that case, the document-wide rule #show ref: e.ref is required. It is recommended to always use e.prepare when using Elembic.

However, some custom elements also have their own prepare functions. (Read their documentation to know if that's the case.) Then, you may specify their constructors as parameters to this function, and this function will run the prepare function of each element. Not specifying any elements will just run the default rules, which may still be important.

As an example, an element may use its own prepare function to apply some special behavior to its outline.

Signature:

#e.prepare(
  ..elems: function
) -> function

Example:

// Apply default rules + special rules for these elements (if they need it)
#show: e.prepare(elemA, elemB)

// Apply default rules only (enable custom references for all elements)
#show: e.prepare()

e.ref

This is meant to be used in a show rule of the form #show ref: e.ref to ensure references to custom elements work properly.

Please use e.prepare as it does that automatically, and more if necessary.

Signature:

#e.ref(
  ref: content
) -> content

Example:

#show ref: e.ref

e.reset

Temporarily revoke all active set rules for certain elements (or even all elements, if none are specified). Applies only to the current scope, like other rules.

Signature:

#e.reset(
  ..elems: function,
  mode: auto | style-modes.normal | style-modes.leaky | style-modes.stateful = auto
) -> function

Example:

#show: e.set_(element, fill: red)
#[
  // Revoke all previous set rules on 'element' for this scope
  #show: e.reset(element)
  #element[This is using the default fill (not red)]
]

// Rules not revoked outside the scope
#element[This is using red fill]

e.revoke

Revoke all rules with a certain name, temporarily disabling their effects within the current scope. This is supported for named set rules, reset rules and even revoke rules themselves (which prompts the originally revoked rules to temporarily apply again).

This is intended to be used temporarily, in a specific scope. This means you are supposed to only revoke the rule for a short portion of the document. If you wish to do the opposite, that is, only apply the rule for a short portion for the document (and have it never apply again afterwards), then please just scope the set rule itself instead.

You should use e.named to add names to rules.

Signature:

#e.revoke(
  name: str,
  mode: auto | style-modes.normal | style-modes.leaky | style-modes.stateful = auto
) -> function

Example:

#show: e.named("name", set_(element, fields))
...
#[
  #show: e.revoke("name")
  // rule 'name' doesn't apply here
  ...
]

// Applies here again
...

e.select

Prepare selectors which only match elements with a certain set of values for their fields. Receives filters in the format element.with(field: A, other-field: B). Note that the fields must be specified through their names, even if they are usually positional. These filters are similar in spirit to native elements' element.where(..fields) selectors.

For each filter specified, an additional selector argument is passed to the callback function. These selectors can be used for show rules and querying. Note that #show sel: set (...) will only apply to the element's body (which could be fine). In addition, rules applied as #show sel: e.set_(...) are applied in reverse due to how Typst works, so be careful when doing that, especially when using something like e.revoke.

You must wrap the remainder of the document that depends on those selectors as the value returned by the callback.

It is thus recommended to only use this function once, at the very top of the document, to get all the needed selectors. This is because this function can only match elements within the returned callback. Elements outside it are not matched by the selectors, even if their fields' values match.

Signature:

#e.select(
  ..filters: element.with(one-field: expected-value, another-field: expected-value),
  receiver: function(..selectors) -> content
) -> content

Example:

#e.select(superbox.with(fill: red), superbox.with(width: auto), (red-superbox, auto-superbox) => {
  // Hide superboxes with red fill or auto width
  show red-superbox: none
  show auto-superbox: none

  // This one is hidden
  #superbox(fill: red)

  // This one is hidden
  #superbox(width: auto)

  // This one is kept
  #superbox(fill: green, width: 5pt)
})

e.set_

Apply a set rule to a custom element. Check out the Styling guide for more information.

Note that this function only accepts non-required fields (that have a default). Any required fields must always be specified at call site and, as such, are always be prioritized, so it is pointless to have set rules for those.

Keep in mind the limitations when using set rules, as well as revoke, reset and apply rules.

As such, when applying many set rules at once, please use e.apply instead (or specify them consecutively so elembic does that automatically).

Signature:

#e.set_(
  elem: function,
  ..fields
)

Example:

#show: e.set_(superbox, fill: red)
#show: e.set_(superbox, optional-pos-arg1, optional-pos-arg2)

// This call will be equivalent to:
// #superbox(required-arg, optional-pos-arg1, optional-pos-arg2, fill: red)
#superbox(required-arg)

e.style-modes

Dictionary with an integer for each style mode:

  • normal (normal mode - default): limit of ~30 non-consecutive rules.
  • leaky (leaky mode): limit of ~60 non-consecutive rules.
  • stateful (stateful mode): no rule limit, but slower.

You will normally not use these values directly, but rather e.g. use e.stateful.set_(...) to use a stateful-only rule.

Read limitations for more information.

Data retrieval functions

Functions used to retrieve data from custom elements, custom types, and their instances.

Main functions

e.data

This is the main function used to retrieve data from custom elements and custom types and their instances. The other functions listed under "Helper functions" are convenient wrappers over this function to reduce typing. It receives any input and returns a dictionary with one of the following values for the data-kind key:

  1. "element": dictionary with an element's relevant parameters and data generated it was declared. This is the data kind returned by e.data(constructor). Importantly, it contains information such as eid for the element's unique ID (combining its prefix and name, used to distinguish it from elements with the same name), sel for the element's selector, outer-sel for the outer selector (used exclusively for show-set rules), counter for the element's counter, func for the element's constructor, fields for a parsed list of element fields, and more.

  2. "custom-type-data": conceptually similar to "element" but for custom types, including their data at declaration time. This is returned by e.data(custom-type-constructor). Contains tid for the type's unique ID (combining prefix and name), as well as typeinfo for the type's typeinfo, fields with field information and func for the constructor.

  3. "element-instance": returned from e.data(it) in a show rule on custom elements, or when using e.data(some-element(...)). Relevant keys here include eid for the element ID (prefix + name), func for the element's constructor, as well as fields, dictionary containing the values specified for each field for this instance.

    It also contains body, which, in show rules, contains the value returned by the element's display function (the element's effective body), but note that the body shouldn't be placed directly; you should return it from the show rule to preserve the element. You usually will never need to use body. In addition, outside of show rules, it is rather meaningless and only contains the element itself.

    Note that, in show rules, the returned data will have fields-known: true which indicates that the final values of all fields, after synthesizing and set rules are applied, are known and stored in fields. Outside of show rules, however (e.g. on recently-constructed elements), the dictionary will have fields-known: false indicating that the dictionary of fields is incomplete and only contains the arguments initially passed to the constructor, as set rules and default values for missing fields haven't been applied yet.

    Note also that this data kind is also returned when applying to elements matched in a show rule on filtered selectors returned by e.select.

  4. "type-instance": similar to "element-instance", except fields are always known and complete since there are no show or set rules for custom types, so e.data(my-type(...)) will always have a complete set of fields, as well as tid for the type's ID and func for its constructor.

  5. "incomplete-element-instance": this is only obtained when trying to e.data(it) on a show rule on an element's outer selector (obtained from e.selector(elem, outer: true) or e.data(elem).outer-sel). The only relevant information it contains is the eid of the matched element. The rest is all unknown.

  6. "content": returned when some arbitrary content with native elements is received. In this case, eid will be none, but func will be set to it.func(), fields will be set to it.fields() and body will be set to it (the given parameter) itself.

  7. "unknown": something that wasn't content, an element, a custom type, or their instances was given. For example, an integer (no data to extract).

Signature:

#e.data(
  any
) -> dictionary

Example:

// Element
// (Equivalent to e.data(elem).sel)
#let sel = e.selector(elem)

// Instance
#show sel: it => {
  // (Equivalent to e.data(it).fields)
  let fields = e.fields(it)
  [Given color: #fields.color]

  [Counter value is #e.counter(elem).display("1.")]

  // (Equivalent to e.data(it).eid, ...)
  assert.eq(e.eid(it), e.eid(elem))
  // (Equivalent to e.data(it).func)
  assert.eq(e.func(it), elem)

  it
}

// Custom type data
#let name = e.data(my-type).name
#let typeinfo = e.data(my-type).typeinfo

// Custom type instance data
#assert.eq(e.func(my-type(...)), my-type)

// Equivalent to e.data(my-type(a: 5, b: 6)).fields
#assert.eq(e.fields(my-type(a: 5, b: 6)), (a: 5, b: 6))

e.repr

This is used to obtain a debug representation of custom types.

In the future, this will support elements as well.

Also supports native types (just calls repr() for them).

Signature:

#e.repr(
  any
) -> str

Example:

// -> "person(name: \"John\", age: 40)"
#e.repr(person(name: "John", age: 40))

Helper functions

Functions which simply wrap and return some specific data.

e.counter

Helper function to obtain an element's associated counter.

This is a wrapper over e.data(arg).counter.

Signature:

#e.counter(
  element
) -> counter

Example:

#e.counter(elem).step()

#context e.counter(elem).display("1.")

e.ctx

Helper function to obtain context from element instances within show rules and functions used by e.element.declare.

This is only available if the element was declared with contextual: true, as it is otherwise expensive to store. When available, it is a dictionary containing (get: function), where get(elem) returns the current default values of fields for elem considering set rules.

This is a wrapper over e.data(arg).ctx.

Signature:

#e.ctx(
  any
) -> dictionary | none

Example:

// For references to work
#show: e.prepare()

#let elem = e.element.declare(
  "elem",
  prefix: "@preview/my-package,v1",
  contextual: true,
  reference: (
    custom: it => [#(e.ctx(it).get)(other-elem).field]
  )
)

#elem(label: <a>)

// Will display the value of the other element's field
@a

e.eid

Helper function to obtain an element's unique ID (modified combination of prefix + name).

Can also be used on element instances.

This is a wrapper over e.data(arg).eid.

Signature:

#e.eid(
  any
) -> str | none

Example:

e.fields

Helper function to obtain an element or custom type instance's fields as a dictionary.

For elements, this will be incomplete outside of show rules. For custom types, it is always complete.

This is a wrapper over e.data(arg).fields.

Signature:

#e.fields(
  any
) -> dictionary | none

Example:

#assert.eq(e.fields(my-elem(a: 5)), (a: 5))

e.func

Helper function to obtain the constructor used to create an element or custom type instance.

Can also be used on elements or custom types themselves.

This is a wrapper over e.data(arg).func.

Signature:

#e.func(
  any
) -> function | none

e.func-name

Get the name of a content's constructor function as a string.

Returns none on invalid input.

Signature:

#e.func-name(
  content | custom element function
) -> function | none

Example:

assert.eq(func-name(my-elem()), "my-elem")
assert.eq(func-name([= abc]), "heading")

e.scope

Helper function to obtain an element or custom type's associated scope.

This is a wrapper over e.data(arg).scope.

Signature:

#e.scope(
  any
) -> module | dictionary | none

Example:

#import e.scope(elem): sub-elem

#sub-elem(...)

e.selector

Returns one of the element's selectors:

  • By default, returns its main selector, used for show rules and querying. This is equivalent to e.data(arg).sel.
  • With outer: true, returns a selector that can be used for show-set rules. This is equivalent to e.data(arg).outer-sel.
  • With outline: true, returns a selector that can be used in outline(target: /* here */) for outlinable elements. This is equivalent to e.data(arg).outline-sel.

Signature:

#e.selector(
  element,
  outer: bool = false,
  outline: bool = false,
) -> label | selector | none

Example:

#show e.selector(elem): strong
#show e.selector(elem, outer: true): set par(justify: false)

#outline(target: e.selector(elem, outline: true))

e.tid

Helper function to obtain the type ID of a custom type, or of an instance's custom type.

This is a wrapper over e.data(arg).tid.

Signature:

#e.tid(
  any
) -> str | none