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:
- Grouping rules together with
apply
- Scoping temporary rules and not misusing
revoke
- (As a last resort) Switching to either of the other styling modes (
leaky
orstateful
)
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):
- 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.
- 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, anybibliography.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 replacee.set_(element, field: value)
withe.leaky.set_(element, field: value)
. (You can create an alias such asimport e: leaky as lk
and thenlk.set_(...)
for ease of use.)
- 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):
-
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.
- This is used to inform rules in normal mode that they should read and write to the
-
Replacing existing set rules with their stateful-only counterparts. That is, replace all occurrences of
e.set_
,e.apply
,e.revoke
etc. withe.stateful.set_
,e.stateful.apply
ande.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 writest.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.
- While a Ctrl+F fixes it, it's a bit of a mouthful, so you may want to consider aliasing
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:
-
name
(string, positional): the field name, by which it will be accessed in the dictionary returned bye.fields(element instance)
. -
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). -
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 accessinge.data(elem).all-fields
, and can be used to auto-generate documentation, for example. -
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. -
named
(boolean orauto
, 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 isauto
, which istrue
for required fields andfalse
for optional fields (but you can have required named fields and optional positional fields by changing both parameters accordingly). -
default
(any, named, optional): the default value for this field ifrequired
isfalse
. 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 specifynone
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 isint
, thendefault
will automatically be set to0
for such optional fields. If the type doesn't have its own default and the field is optional, you must specify your own default. -
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 throughe.select
. For example, if you have a synthesized field calledlevel
, a user can match onelem.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:
-
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 ase.select
. -
Create a synthesis step for your element with
synthesize: fields => updated fields
. Here, you can, for example, access Typst context, as well as usee.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]
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
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*]
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:
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 oftypes.union
,"wrapped"
as the output oftypes.wrap
,"collection"
fortypes.array
, and so on);name
: the name of a type;input
: which basic types may be cast to it (e.g.: integer or string). This uses the type ID format for custom types, obtained withtypes.typeid(value)
, of the form(tid: ..., name: ...)
. For native types, the output oftype(value)
is used;output
: which basic types the input may be cast to (e.g.: just string). This uses the same format asinput
;check
: an extra check (functioninput value => true / false
) that tells whether a basic input type may be cast to this type, ornone
for no such check;cast
: an optional function to cast an input value that passed the check to an output value (functioninput value => output value
). Must always succeed, or panic. Ifnone
, 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).error
: an optional function that is called when the check fails (input value => string
), indicating why the check may have failed.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.fold
: an optional function(outer output value, inner output value) => new output value
to indicate how to combine two consecutive values of this type, whereinner
is more prioritized overouter
. For example, for thestroke
type, one would have(5pt, black) => 5pt + black
, but(5pt + black, red) => 5pt + red
sincered
is the inner value in that case. If this isnone
, 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 hasfold
.- Note that
fold
may also beauto
to mean(a, b) => a + b
, for performance reasons.
- Note that
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 asint
, 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 ofint
andfloat
, the integer5
will remain the integer5
when casting totypes.union(int, float)
, but will be casted to the float5.0
when casted totypes.union(float, int)
. (Of course, a float such as4.0
will remain a float in both cases, since it isn't accepted byint
). 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 thenfloat
is different from specifying a cast fromfloat
followed byint
, 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 ofint
andfloat
, the integer5
will trigger the cast fromint
as you'd expect if theint
cast comes first, but will converted to5.0
before triggering the cast fromfloat
if thefloat
cast is specified first. (Of course, a float such as4.0
will trigger the cast fromfloat
in both cases, since it isn't accepted byint
). 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
: Functionfields => content
to display the element.fields
: Array with this element's fields. They must be created withe.field(...)
.parse-args
: Optional override for the built-in argument parser (orauto
to keep as is). Must be in the formfunction(args, include-required: bool) => dictionary
, whereinclude-required: true
means required fields are enforced (constructor), whileinclude-required: false
means they are forbidden (set rules).typecheck
: Set tofalse
to disable field typechecking.allow-unknown-fields
: Set totrue
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 functiondisplayed 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 withe.scope(elem)
, e.g.#import e.scope(elem): sub-elem
.count
: Optional functioncounter => (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) withe.counter(it).get()
. Defaults tocounter.step
to step the counter once before each element placed.labelable
: Defaults totrue
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 thereference
option is configured and#show: e.prepare()
is applied. Whenfalse
, the element may have a field namedlabel
instead, but it won't have these effects.reference
: When notnone
, 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, soprepare
needs no arguments there). Specify either asupplement
andnumbering
for references looking like "Name 2", and/orcustom
to show some fully customized content for the reference instead.outline
: When notnone
, allows creating an outline for the element's appearances with#outline(target: e.selector(elem, outline: true))
. When set toauto
, the entries will display "Name 2" based on reference information. When acaption
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 displaycaption
.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 withsynthesized: true
. This forbids the user from specifying them manually, but allows them to filter based on that field.contextual
: When set totrue
, functionsfields => something
for other options, includingdisplay
, 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 fornative-elem.field
in the context. In practice, this is a bit expensive, and so this option shouldn't be enabled unless you need preciselybibliography.title
, or you really need to get set rule information from other elements within functions such assynthesize
ordisplay
.
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:
-
"element"
: dictionary with an element's relevant parameters and data generated it was declared. This is the data kind returned bye.data(constructor)
. Importantly, it contains information such aseid
for the element's unique ID (combining itsprefix
andname
, 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. -
"custom-type-data"
: conceptually similar to"element"
but for custom types, including their data at declaration time. This is returned bye.data(custom-type-constructor)
. Containstid
for the type's unique ID (combiningprefix
andname
), as well astypeinfo
for the type's typeinfo,fields
with field information andfunc
for the constructor. -
"element-instance"
: returned frome.data(it)
in a show rule on custom elements, or when usinge.data(some-element(...))
. Relevant keys here includeeid
for the element ID (prefix + name),func
for the element's constructor, as well asfields
, 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'sdisplay
function (the element's effective body), but note that thebody
shouldn't be placed directly; you should returnit
from the show rule to preserve the element. You usually will never need to usebody
. 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 infields
. Outside of show rules, however (e.g. on recently-constructed elements), the dictionary will havefields-known: false
indicating that the dictionary offields
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
. -
"type-instance"
: similar to"element-instance"
, exceptfields
are always known and complete since there are no show or set rules for custom types, soe.data(my-type(...))
will always have a complete set of fields, as well astid
for the type's ID andfunc
for its constructor. -
"incomplete-element-instance"
: this is only obtained when trying toe.data(it)
on a show rule on an element's outer selector (obtained frome.selector(elem, outer: true)
ore.data(elem).outer-sel
). The only relevant information it contains is theeid
of the matched element. The rest is all unknown. -
"content"
: returned when some arbitrarycontent
with native elements is received. In this case,eid
will benone
, butfunc
will be set toit.func()
,fields
will be set toit.fields()
andbody
will be set toit
(the given parameter) itself. -
"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 toe.data(arg).outer-sel
. - With
outline: true
, returns a selector that can be used inoutline(target: /* here */)
for outlinable elements. This is equivalent toe.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