Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/lang/rules.rs

137 KiB, 288 runs

created by r1870400018:39861, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The styling rule engine: a `#show <selector>: <transform>` as data over the block tree.
2//!
3//! Typst styles individual elements with a show rule -- `#show heading.where(level: 1): set text(size:
4//! 30pt)`. Austenite does not *execute* one (it has no style-computation layer, by design), but it can
5//! *lower* a set-fields show rule the same way [`crate::lang::set`] lowers a top-level `#set`: read the
6//! selector and the transform, and, where the transform is a plain field write the theme carries, apply
7//! it to the matched element's subtree by wrapping the element in a [`Block::Scoped`] carrying the patch.
8//! The scope machinery ([`Block::Scoped`], made scope-aware in every document-order pass) then confines
9//! the styling to that one element and lifts it again after, so a rule on a level-1 heading styles only
10//! the level-1 headings and no further.
11//!
12//! What is lowered here is deliberately narrow: a **set-fields** transform (`set <target>(...)`), on a
13//! field the renderer actually reads. Two families are refused rather than run, so a rule never silently
14//! misleads:
15//!
16//! - A transform whose body *reads the page* -- `context`, `query`, `counter.at`, `state`, `measure`,
17//! `layout` -- has no lowering (Austenite settles numbers in a document-order pre-pass, not a layout
18//! round-trip), so it is recorded as a refusal and never run.
19//! - A transform patching a field the renderer does not read, or reads only cross-group -- `text.tracking`,
20//! `text.ligatures`, a body/heading `font` face, `heading` `smallcaps`, `figure.skip`, `code.background`,
21//! `equation.numbering`, any `page` dimension -- is refused too, so a rule that would quietly no-op is a
22//! visible "not yet supported" instead.
23//!
24//! A wrap-transform (a template with holes, `it => underline(it)`, `block.with(...)`) is a later part's
25//! work; here such a transform is refused, not guessed at.
26//!
27//! Consuming is selector-aware. The one `text` field a heading renders is its size, so a
28//! `#show heading.where(level: N): set text(size: ...)` is *not* refused: it is redirected into the matched
29//! level's own `heading` size -- the field the renderer reads for a heading (`Theme::heading_size`) -- so
30//! the rule resizes that level's headings and no others (an unpredicated `#show heading:` sizes every level
31//! alike). A `text` field a heading never renders -- tracking, ligatures, a font face, a body hyphenation
32//! switch -- is still refused under a heading selector, naming the field and the selector, so the invariant
33//! holds: every lowered field is consumed by the renderer or refused, never written-and-ignored.
34//!
35//! Block identity (design note). A set-fields transform *preserves* the source element's identity -- the
36//! [`Block::Scoped`] it produces is a derived wrap of the very block matched, not a new element -- so a
37//! later part can address the styled element by the source element's own identity and the [`RuleId`] that
38//! wrapped it. This part records those two hooks (the rule's index, and that the wrap is derived) and
39//! computes no address or hash from them yet.
40
41use crate::doc::Block;
42use crate::ir::{
43 FloatPlacement,
44 Length,
45 Sp,
46 Span,
47};
48use crate::theme::{
49 Theme,
50 ThemeHeadingLevelPatch,
51 ThemePatch,
52};
53
54use super::parse::Refusals;
55use super::set;
56
57use oxedyne_fe2o3_core::prelude::*;
58use oxedyne_fe2o3_graphics::colour::Rgba;
59
60// ┌───────────────────────────────────────────────────────────────────────────┐
61// │ THE RULE │
62// └───────────────────────────────────────────────────────────────────────────┘
63
64/// The element kind a selector's base names. A `figure.caption` refines the figure kind to its caption;
65/// the rest name a block the reader sets.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum ElementKind {
68 Heading,
69 Figure,
70 FigureCaption,
71 Raw, // a verbatim code block (`raw`)
72 Equation,
73 Link,
74 Paragraph,
75 List,
76 Table,
77}
78
79/// A field predicate a `.where(...)` narrows a selector by.
80#[derive(Clone, Debug, PartialEq)]
81pub enum FieldPredicate {
82 Level(u8), // heading.where(level: N)
83 Block(bool), // raw.where(block: true/false)
84}
85
86/// A `#show` selector: an element kind and the field predicates that narrow it. An empty predicate list
87/// matches every element of the kind.
88#[derive(Clone, Debug, PartialEq)]
89pub struct Selector {
90 pub kind: ElementKind,
91 pub predicates: Vec<FieldPredicate>,
92}
93
94/// What a rule does to a matched element: patch its subtree ([`Transform::SetFields`], Part 1), or place
95/// sibling blocks around it and overlay a theme on the group ([`Transform::Template`], this part). A
96/// page-reading or otherwise unsupported transform is a [`Transform::Refused`], recorded and never run.
97#[derive(Clone, Debug)]
98pub enum Transform {
99 SetFields(ThemePatch),
100 Template(Template),
101 Refused(String), // the reason, recorded as a refusal rather than applied
102}
103
104/// A template transform: the matched element is *moved* (not cloned) into a hole between sibling blocks the
105/// template placed around it, and a theme overlay wraps the whole group. `pre`/`post` are the blocks a
106/// `v(<len>)`/`line(...)` in the template lowers to, before and after the element. `hole` is the overlay a
107/// `#set` inside the wrap contributes (and, under a heading selector, the level spacing a `v(...)` folds
108/// into). `frame`, when set, seats the element in a washed [`Block::Box`] of that fill and, where the rule
109/// names them, that inset and radius too -- a `block.with(fill:, inset:, radius:)` callout. `rule_id` and
110/// the hole's index (always `pre.len()`) are recorded so a later pass can address the moved element by the
111/// rule that placed it -- the block-identity hook the design note calls for.
112#[derive(Clone, Debug)]
113pub struct Template {
114 pub pre: Vec<Block>,
115 pub hole: ThemePatch,
116 pub post: Vec<Block>,
117 pub frame: Option<TemplateFrame>,
118 pub rule_id: RuleId,
119}
120
121/// The wash a `block.with(fill:, inset:, radius:)` template names -- the fill always, `inset_x`/
122/// `inset_top`/`inset_bot`/`radius` only where the rule sets them. `None` on any of the four leaves the
123/// renderer's own default (the `#styled-box` template's one body em, 1.2 body em and 4pt) untouched, so a
124/// rule that names only `fill:` frames the element without moving its geometry at all.
125#[derive(Clone, Copy, Debug, PartialEq)]
126pub struct TemplateFrame {
127 pub fill: Rgba,
128 pub inset_x: Option<Sp>,
129 pub inset_top: Option<Sp>,
130 pub inset_bot: Option<Sp>,
131 pub radius: Option<Sp>,
132}
133
134/// A rule's index in the set that produced it -- the identity hook a set-fields wrap carries so a later
135/// part can address the styled element. No address or hash is computed from it here.
136pub type RuleId = usize;
137
138/// One styling rule: a selector, its transform, and the [`RuleId`] a derived wrap records. `source`
139/// is the rule's own source name (with its leading `#`), for a refusal diagnostic.
140#[derive(Clone, Debug)]
141pub struct Rule {
142 pub selector: Selector,
143 pub transform: Transform,
144 pub rule_id: RuleId,
145 pub source: String,
146 pub span: Span,
147}
148
149// ┌───────────────────────────────────────────────────────────────────────────┐
150// │ THE DEFAULT RULE SET │
151// └───────────────────────────────────────────────────────────────────────────┘
152
153/// The default rule set applied ahead of a document's own rules: the styling the block layer would set
154/// anyway, expressed as rules over the same block tree. This part migrates the heading styling that a
155/// set-fields transform can carry -- one rule per heading level re-asserting that level's own size from
156/// the theme -- so every heading flows through the rule engine and the corpus renders byte-identically
157/// (the re-asserted value equals the theme's, and the scope wrap is transparent).
158///
159/// Caption sizing and the inline-equation digit shrink are *not* migrated here: the caption reads
160/// `text.body_size` (not a `figure` field) and the inline digit shrink reads the running maths size, both
161/// cross-group reads a per-element `figure`/`equation` set-fields rule cannot target without the renderer
162/// change the readiness audit flagged. Their migration waits on that change (a wrap-transform part), so
163/// they are left in the block layer and named here rather than expressed as a rule that would silently
164/// no-op.
165pub fn default_rule_set(theme: &Theme) -> Vec<Rule> {
166 let mut rules = Vec::new();
167 for (i, level) in theme.heading.levels.iter().enumerate() {
168 let lvl = (i + 1) as u8; // level index 0 is level 1
169 // Re-assert this level's own size: a set-fields patch that names the size the theme already holds,
170 // so applying it (by wrapping the heading in a scope) leaves the rendered size exactly as it was.
171 let mut patch = ThemePatch::default();
172 let mut levels: Vec<ThemeHeadingLevelPatch> = Vec::with_capacity(i + 1);
173 levels.resize_with(i + 1, Default::default);
174 levels[i].size = Some(level.size);
175 patch.heading.levels = levels;
176 rules.push(Rule {
177 selector: Selector { kind: ElementKind::Heading, predicates: vec![FieldPredicate::Level(lvl)] },
178 transform: Transform::SetFields(patch),
179 rule_id: rules.len(),
180 source: fmt!("#show heading.where(level: {})", lvl),
181 span: Span::new(0, 0),
182 });
183 }
184 rules
185}
186
187// ┌───────────────────────────────────────────────────────────────────────────┐
188// │ COLLECTING A DOCUMENT'S OWN RULES FROM SOURCE │
189// └───────────────────────────────────────────────────────────────────────────┘
190
191/// Every `#show <selector>: <transform>` a source declares at top level, lowered to a [`Rule`]. A rule
192/// whose transform reads the page, or patches a field the renderer does not read, is captured as a
193/// [`Transform::Refused`] and its reason recorded in `refusals`, so it is a visible "not supported" rather
194/// than a silent drop. A `#show:` with no selector (the whole-document `doc.with` application) is not a
195/// per-element rule and is left for [`crate::lang::set`]. Rules are numbered from `base_id`, so a caller
196/// appending them after the default set keeps every [`RuleId`] distinct.
197pub fn collect_from_source(src: &str, base_id: RuleId, refusals: &mut Refusals) -> Vec<Rule> {
198 let mut rules = Vec::new();
199 let mut offset = 0usize; // running byte offset of the current line's start
200 for raw in src.split_inclusive('\n') {
201 let line_start = offset;
202 offset = offset.saturating_add(raw.len());
203 let trimmed = raw.trim_start();
204 // A per-element show rule opens `#show <selector>:` -- a selector between `#show ` and the colon.
205 // `#show:` (no selector) is the whole-document application, not ours.
206 let after = match trimmed.strip_prefix("#show ") {
207 Some(a) => a,
208 None => continue,
209 };
210 let (sel_text, tr_text) = match split_at_top_level_colon(after) {
211 Some(pair) => pair,
212 None => continue,
213 };
214 let selector = match parse_selector(sel_text.trim()) {
215 Some(s) => s,
216 None => continue, // an unrecognised selector is left to the reader's own refusal path
217 };
218 let span = Span::new(line_start as u32, offset as u32);
219 let source = fmt!("#show {}", sel_text.trim());
220 let mut transform = lower_transform(&selector, tr_text.trim());
221 if let Transform::Refused(reason) = &transform {
222 refusals.record(&fmt!("{} ({})", source, reason), span);
223 }
224 let rule_id = base_id + rules.len();
225 // A template records the rule that placed it, so the moved element keeps an addressable identity.
226 if let Transform::Template(t) = &mut transform {
227 t.rule_id = rule_id;
228 }
229 rules.push(Rule { selector, transform, rule_id, source, span });
230 }
231 rules
232}
233
234/// Does this already-left-trimmed line declare a per-element `#show <selector>: <transform>` rule the rule
235/// engine collects and applies (or refuses in its own diagnostic)? True only when a selector between
236/// `#show ` and a top-level colon parses to a known element kind -- the same recognition
237/// [`collect_from_source`] uses -- so the reader can stop tallying such a line as a skipped construct and
238/// leave it to the engine. A `#show:` doc application (no selector) and a `#show ...` whose selector no
239/// element answers to are not rule lines.
240pub fn is_rule_line(trimmed: &str) -> bool {
241 let after = match trimmed.strip_prefix("#show ") {
242 Some(a) => a,
243 None => return false,
244 };
245 match split_at_top_level_colon(after) {
246 Some((sel_text, _)) => parse_selector(sel_text.trim()).is_some(),
247 None => false,
248 }
249}
250
251/// The `(selector, transform)` split of a `#show <selector>: <transform>` body at the first colon that is
252/// not inside a `(...)`/`[...]`/`"..."` -- so the colon inside `where(level: 1)` is passed over and the
253/// real separator found. `None` when there is no top-level colon.
254fn split_at_top_level_colon(s: &str) -> Option<(&str, &str)> {
255 let bytes = s.as_bytes();
256 let mut depth = 0i32;
257 let mut in_str = false;
258 let mut esc = false;
259 let mut i = 0usize;
260 while i < bytes.len() {
261 let c = bytes[i];
262 if in_str {
263 if esc { esc = false; }
264 else if c == b'\\' { esc = true; }
265 else if c == b'"' { in_str = false; }
266 i += 1;
267 continue;
268 }
269 match c {
270 b'"' => in_str = true,
271 b'(' | b'[' | b'{' => depth += 1,
272 b')' | b']' | b'}' => depth -= 1,
273 b':' if depth == 0 => return Some((&s[..i], &s[i + 1..])),
274 _ => {},
275 }
276 i += 1;
277 }
278 None
279}
280
281/// Parses a selector -- `heading.where(level: 1)`, `figure.caption`, `raw.where(block: true)`, `link` --
282/// into an [`ElementKind`] and its field predicates. The base identifier before the first `.` names the
283/// kind; a `.caption` refines a figure to its caption; a `.where(...)` reads the predicates. `None` for a
284/// base identifier no element kind answers to.
285fn parse_selector(s: &str) -> Option<Selector> {
286 // The base runs up to the first `.` or `(`; the rest is a `.caption` refinement or a `.where(...)`.
287 let base_end = s.find(['.', '(']).unwrap_or(s.len());
288 let base = s[..base_end].trim();
289 let rest = s[base_end..].trim();
290
291 let mut kind = match base {
292 "heading" => ElementKind::Heading,
293 "figure" => ElementKind::Figure,
294 "raw" => ElementKind::Raw,
295 "math.equation" | "equation" => ElementKind::Equation,
296 "link" => ElementKind::Link,
297 "par" | "parbreak" => ElementKind::Paragraph,
298 "list" | "enum" => ElementKind::List,
299 "table" => ElementKind::Table,
300 _ => return None,
301 };
302
303 let mut predicates = Vec::new();
304 if let Some(after) = rest.strip_prefix(".caption") {
305 if kind == ElementKind::Figure {
306 kind = ElementKind::FigureCaption;
307 }
308 predicates.extend(parse_where(after.trim()));
309 } else {
310 predicates.extend(parse_where(rest));
311 }
312 Some(Selector { kind, predicates })
313}
314
315/// The predicates of a `.where(k: v, ...)` selector tail, or an empty list when there is none. Only the
316/// two forms this part reads -- `level: N` and `block: bool` -- are lifted; an argument it does not know
317/// is passed over rather than failing the whole selector.
318fn parse_where(rest: &str) -> Vec<FieldPredicate> {
319 let inner = match rest.strip_prefix(".where") {
320 Some(a) => a.trim(),
321 None => return Vec::new(),
322 };
323 let args = match (inner.strip_prefix('('), inner.strip_suffix(')')) {
324 (Some(a), _) => a.trim_end_matches(')').trim(),
325 _ => return Vec::new(),
326 };
327 let mut out = Vec::new();
328 for part in args.split(',') {
329 let mut kv = part.splitn(2, ':');
330 let key = kv.next().unwrap_or("").trim();
331 let val = kv.next().unwrap_or("").trim();
332 match key {
333 "level" => if let Ok(n) = val.parse::<u8>() { out.push(FieldPredicate::Level(n)); },
334 "block" => match val {
335 "true" => out.push(FieldPredicate::Block(true)),
336 "false" => out.push(FieldPredicate::Block(false)),
337 _ => {},
338 },
339 _ => {},
340 }
341 }
342 out
343}
344
345// ┌───────────────────────────────────────────────────────────────────────────┐
346// │ LOWERING A TRANSFORM │
347// └───────────────────────────────────────────────────────────────────────────┘
348
349/// The page-reading primitives whose presence in a transform body means it settles a number from the laid
350/// page, which Austenite has no layer to run -- so such a transform is refused, never lowered.
351const PAGE_READING_TOKENS: &[&str] = &["context", "query", "counter.at", "state", "measure", "layout"];
352
353/// Lowers a transform body to a [`Transform`]. A `set <target>(...)` on a field the renderer reads lowers
354/// to a [`Transform::SetFields`]; a body that reads the page, patches an unread or cross-group field, or is
355/// a wrap-transform this part does not build, is a [`Transform::Refused`] carrying its reason.
356fn lower_transform(selector: &Selector, body: &str) -> Transform {
357 // A closure head (`it =>`, `x =>`) is stripped; the transform is what it evaluates to.
358 let body = match body.split_once("=>") {
359 Some((_head, tail)) => tail.trim(),
360 None => body,
361 };
362
363 // A page-reading body has no lowering at all.
364 for tok in PAGE_READING_TOKENS {
365 if body.contains(tok) {
366 return Transform::Refused(fmt!("reads the page: {}", tok));
367 }
368 }
369
370 // A `set <target>(<args>)` lowers to a set-fields patch below; a body that is not a bare `set` -- a
371 // template with holes, a wrap -- is read by the template lowerer, which builds a [`Transform::Template`]
372 // or refuses the body it cannot place.
373 let after = match body.strip_prefix("set ") {
374 Some(a) => a.trim(),
375 None => return lower_template(selector, body),
376 };
377 let open = match after.find('(') {
378 Some(i) => i,
379 None => return Transform::Refused(fmt!("unsupported transform: {}", short(body))),
380 };
381 let target = after[..open].trim();
382 let args = match after[open..].strip_prefix('(').and_then(|a| a.strip_suffix(')')) {
383 Some(a) => a,
384 None => return Transform::Refused(fmt!("unbalanced set(...) in transform: {}", short(body))),
385 };
386
387 // A field this selector's element does not read (or reads only cross-group) is refused rather than
388 // applied, so a rule that would silently no-op is a visible "not yet supported". The judgement is
389 // selector-aware: a `text` field a heading does not render is refused under a heading selector even
390 // though the same field is read for body text.
391 if let Some(reason) = unread_field_reason(selector, target, args) {
392 return Transform::Refused(reason);
393 }
394
395 // A heading reads its glyph size from the `heading` group, not from `text.body_size` (which no heading
396 // renders), so a `set text(size: ...)` on a heading selector is redirected into the matched level's own
397 // size -- the field the renderer consumes -- affecting only that level (or every level, for an
398 // unpredicated rule). Every other case lowers straight through `set::lower_set`.
399 let patch = if selector.kind == ElementKind::Heading && target == "text" {
400 match heading_text_patch(selector, args) {
401 Some(p) => p,
402 None => return Transform::Refused(fmt!(
403 "set text on {} named no size or font the renderer can consume", selector_label(selector))),
404 }
405 } else {
406 set::lower_set(target, args)
407 };
408 if patch == ThemePatch::default() {
409 // The set named a target or argument the theme carries no read field for: refuse rather than wrap an
410 // element in an empty scope that changes nothing.
411 return Transform::Refused(fmt!("set {} lowered to nothing", target));
412 }
413 Transform::SetFields(patch)
414}
415
416/// The heading-group patch a `set text(size: ..., font: ...)` on a heading selector lowers to: the size the
417/// renderer
418/// reads for a heading is its own level size ([`Theme::heading_size`]), so the text size is redirected
419/// there rather than into `text.body_size`, which a heading never reads. A `level: N` predicate targets
420/// that one level; an unpredicated heading selector sizes every level alike (`size_all`). `None` when the
421/// set names no size, or a size that does not convert to points (an `em`, which needs a running size this
422/// lowering has not) -- the caller then refuses it rather than wrapping to no effect.
423fn heading_text_patch(selector: &Selector, args: &str) -> Option<ThemePatch> {
424 // Reuse the body readers: `set text(size: 30pt, font: "Felipa")` lowers its size into `text.body_size`
425 // and its family list into `text.faces.body`, and those are exactly what to redirect into the heading
426 // level. A heading draws in one named face, so the list's first family is its face; the fall-back
427 // families after it are not carried (a heading's uncovered glyphs fall to the body role instead).
428 let text = set::lower_set("text", args).text;
429 let face = text.faces.body.as_ref().and_then(|l| l.first().cloned());
430 if text.body_size.is_none() && face.is_none() {
431 return None;
432 }
433 let mut patch = ThemePatch::default();
434 match level_predicate(selector) {
435 Some(n) => {
436 let idx = (n.max(1) as usize) - 1; // level 0/1 both index 0, as the theme maps them
437 let mut levels: Vec<ThemeHeadingLevelPatch> = Vec::with_capacity(idx + 1);
438 levels.resize_with(idx + 1, Default::default);
439 levels[idx].size = text.body_size;
440 levels[idx].face = face.map(Some);
441 patch.heading.levels = levels;
442 },
443 None => {
444 patch.heading.size_all = text.body_size;
445 patch.heading.face_all = face.map(Some);
446 },
447 }
448 Some(patch)
449}
450
451/// The single `level: N` a selector narrows to, or `None` for an unpredicated selector (or one narrowed by
452/// some other predicate). Used to target a heading size rule at the one level it names.
453fn level_predicate(selector: &Selector) -> Option<u8> {
454 selector.predicates.iter().find_map(|p| match p {
455 FieldPredicate::Level(n) => Some(*n),
456 _ => None,
457 })
458}
459
460/// A selector rendered back to its source form -- `heading`, `heading.where(level: 1)` -- for a refusal
461/// diagnostic that names which selector left a field unconsumed.
462fn selector_label(selector: &Selector) -> String {
463 let kind = match selector.kind {
464 ElementKind::Heading => "heading",
465 ElementKind::Figure => "figure",
466 ElementKind::FigureCaption => "figure.caption",
467 ElementKind::Raw => "raw",
468 ElementKind::Equation => "equation",
469 ElementKind::Link => "link",
470 ElementKind::Paragraph => "par",
471 ElementKind::List => "list",
472 ElementKind::Table => "table",
473 };
474 if selector.predicates.is_empty() {
475 kind.to_string()
476 } else {
477 let preds: Vec<String> = selector.predicates.iter().map(|p| match p {
478 FieldPredicate::Level(n) => fmt!("level: {}", n),
479 FieldPredicate::Block(b) => fmt!("block: {}", b),
480 }).collect();
481 fmt!("{}.where({})", kind, preds.join(", "))
482 }
483}
484
485/// Why a `set <target>(<args>)` transform patches a field the selector's element does not read, or reads
486/// only across a group boundary the readiness audit named -- so the rule is refused rather than wrapped to
487/// no effect. `None` when every field it names is one the renderer reads for that element. Selector-aware:
488/// a heading renders one shaped line from the `heading` group, so a `text` field that only styles running
489/// body text is refused under a heading selector, whereas the heading's own size passes (it is redirected
490/// into the heading group by [`heading_text_patch`]).
491fn unread_field_reason(selector: &Selector, target: &str, args: &str) -> Option<String> {
492 let has = |key: &str| names_arg(args, key);
493 // A heading's only renderable `text` field is its size; the rest style running body text a heading
494 // never sets, so they are refused here, naming the field and the selector.
495 if selector.kind == ElementKind::Heading && target == "text" {
496 if has("tracking") { return Some(fmt!("text.tracking is not read for {}", selector_label(selector))); }
497 if has("ligatures") { return Some(fmt!("text.ligatures is not read for {}", selector_label(selector))); }
498 if has("hyphenate") { return Some(fmt!("text.hyphenate is not read for {}", selector_label(selector))); }
499 return None;
500 }
501 match target {
502 "text" => {
503 if has("tracking") { return Some("text.tracking is not read by the renderer".to_string()); }
504 if has("ligatures") { return Some("text.ligatures is not read by the renderer".to_string()); }
505 None
506 },
507 "heading" => {
508 if has("smallcaps") { return Some("heading smallcaps is not read per level by the renderer".to_string()); }
509 if has("font") { return Some("a heading font face is resolved elsewhere, not read from a rule".to_string()); }
510 None
511 },
512 "figure" => Some("figure.skip / figure sizing is not read from a rule".to_string()),
513 "raw" | "code" => Some("code.background / code sizing is not read from a rule".to_string()),
514 "math.equation" | "equation" => Some("equation.numbering is inert; the renderer always sets (N)".to_string()),
515 "page" => Some("page.* geometry is not consumed from a rule".to_string()),
516 _ => None,
517 }
518}
519
520/// Does `args` name the top-level argument `key` (an identifier immediately before a `:`, at depth zero)?
521/// Reuses the same word-boundary care as the `#set` reader so `font` is not found inside `heading-font`.
522fn names_arg(args: &str, key: &str) -> bool {
523 let bytes = args.as_bytes();
524 let mut from = 0usize;
525 while let Some(rel) = args[from..].find(key) {
526 let at = from + rel;
527 let before_ok = at == 0 || {
528 let p = bytes[at - 1];
529 !(p.is_ascii_alphanumeric() || p == b'-' || p == b'_')
530 };
531 let mut j = at + key.len();
532 while j < bytes.len() && bytes[j] == b' ' {
533 j += 1;
534 }
535 if before_ok && j < bytes.len() && bytes[j] == b':' {
536 return true;
537 }
538 from = at + key.len();
539 }
540 false
541}
542
543/// A short, single-line echo of a transform body for a refusal message.
544fn short(body: &str) -> String {
545 let one: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
546 if one.chars().count() > 40 {
547 let mut s: String = one.chars().take(40).collect();
548 s.push('…');
549 s
550 } else {
551 one
552 }
553}
554
555// ┌───────────────────────────────────────────────────────────────────────────┐
556// │ LOWERING A TEMPLATE (a #show whose body wraps the element) │
557// └───────────────────────────────────────────────────────────────────────────┘
558
559/// Lowers a `#show <selector>: <body>` whose body is not a bare `set` to a [`Transform::Template`], or
560/// refuses it. The recognised shapes are the corpus's shared template forms: a `block.with(fill:, inset:,
561/// radius:)` (or `block(fill: ...)[#it]`) wash around the element -- a callout frame; a `block(...)[ #set
562/// text(...) #it.body ]` whose inner `#set` overlays the element (the hole patch); and `v(<len>)` / `line(...)`
563/// statements set as siblings before or after the element (`pre` / `post`). An inline wrap (`underline`) and a
564/// text rewrite (`regex`) have no block lowering and are refused; a page-reading body is already refused
565/// upstream. Under a heading selector a `v(<len>)` folds into the matched level's `space_above` / `space_below`
566/// rather than a sibling -- a sibling after a heading would break its keep-with-next -- and any other sibling
567/// there is refused for the same reason.
568fn lower_template(selector: &Selector, body: &str) -> Transform {
569 let body = strip_code_block(body.trim());
570 // An inline dress or a text rewrite is not a block-level template.
571 if mentions_call(body, "underline") {
572 return Transform::Refused(fmt!("underline wraps inline content, not a block: {}", short(body)));
573 }
574 if mentions_call(body, "regex") {
575 return Transform::Refused(fmt!("a regex show rewrites matched text, not an element: {}", short(body)));
576 }
577
578 let heading = selector.kind == ElementKind::Heading;
579 let mut pre: Vec<Block> = Vec::new();
580 let mut post: Vec<Block> = Vec::new();
581 let mut hole = ThemePatch::default();
582 let mut frame: Option<TemplateFrame> = None;
583 let mut seen_hole = false;
584
585 for stmt in split_statements(body) {
586 let s = stmt.trim().trim_start_matches('#').trim();
587 if s.is_empty() {
588 continue;
589 }
590 if let Some(kind) = spacer_kind(s) {
591 match make_spacer(selector, kind, s, seen_hole, heading, &mut hole) {
592 Ok(None) => {}, // folded into the level's spacing (a heading v)
593 Ok(Some(b)) => if seen_hole { post.push(b); } else { pre.push(b); },
594 Err(e) => return Transform::Refused(fmt!("{}", e)),
595 }
596 } else if is_element_stmt(s) {
597 if seen_hole {
598 return Transform::Refused(fmt!("a template names the element `it` more than once: {}", short(body)));
599 }
600 if let Err(e) = read_element(s, &mut hole, &mut frame) {
601 return Transform::Refused(fmt!("{}", e));
602 }
603 seen_hole = true;
604 } else {
605 return Transform::Refused(fmt!("unsupported template statement: {}", short(s)));
606 }
607 }
608
609 if !seen_hole {
610 return Transform::Refused(fmt!("a template body names no element `it`: {}", short(body)));
611 }
612 // The rule_id is stamped by `collect_from_source` once the rule's index is known.
613 Transform::Template(Template { pre, hole, post, frame, rule_id: 0 })
614}
615
616/// A code-block wrapper `{ ... }` stripped to its contents, so the statements inside can be split; a body
617/// that is a single expression (a `block.with(...)`) is returned unchanged.
618fn strip_code_block(body: &str) -> &str {
619 let b = body.trim();
620 match (b.strip_prefix('{'), b.strip_suffix('}')) {
621 (Some(inner), _) if b.ends_with('}') => inner.trim(),
622 _ => b,
623 }
624}
625
626/// Does `body` call `name` -- the identifier `name` immediately before a `(`, at a word boundary -- so a
627/// template mentioning `underline(` or `regex(` is caught without matching it inside a longer word?
628fn mentions_call(body: &str, name: &str) -> bool {
629 let bytes = body.as_bytes();
630 let mut from = 0usize;
631 while let Some(rel) = body[from..].find(name) {
632 let at = from + rel;
633 let before_ok = at == 0 || {
634 let p = bytes[at - 1];
635 !(p.is_ascii_alphanumeric() || p == b'-' || p == b'_')
636 };
637 let after = at + name.len();
638 if before_ok && after < bytes.len() && bytes[after] == b'(' {
639 return true;
640 }
641 from = at + name.len();
642 }
643 false
644}
645
646/// Splits a template body into its top-level statements, at a newline or `;` outside any `(...)`, `[...]`,
647/// `{...}` or `"..."` -- the statement separators of a Typst code block.
648fn split_statements(body: &str) -> Vec<String> {
649 let mut out = Vec::new();
650 let mut depth = 0i32;
651 let mut in_str = false;
652 let mut esc = false;
653 let mut cur = String::new();
654 for c in body.chars() {
655 if in_str {
656 cur.push(c);
657 if esc { esc = false; }
658 else if c == '\\' { esc = true; }
659 else if c == '"' { in_str = false; }
660 continue;
661 }
662 match c {
663 '"' => { in_str = true; cur.push(c); },
664 '(' | '[' | '{' => { depth += 1; cur.push(c); },
665 ')' | ']' | '}' => { depth -= 1; cur.push(c); },
666 '\n' | ';' if depth == 0 => { out.push(std::mem::take(&mut cur)); },
667 _ => cur.push(c),
668 }
669 }
670 if !cur.trim().is_empty() {
671 out.push(cur);
672 }
673 out
674}
675
676/// A `v(...)` or `line(...)` spacer statement, or `None` for a statement that is neither.
677#[derive(Clone, Copy, PartialEq)]
678enum SpacerKind {
679 Vertical, // v(<len>)
680 Line, // line(...) -- a horizontal divider
681}
682
683impl SpacerKind {
684 fn label(self) -> &'static str {
685 match self {
686 SpacerKind::Vertical => "v()",
687 SpacerKind::Line => "line()",
688 }
689 }
690}
691
692/// Which spacer, if any, this already-`#`-stripped statement opens with.
693fn spacer_kind(s: &str) -> Option<SpacerKind> {
694 if s.starts_with("v(") { Some(SpacerKind::Vertical) }
695 else if s.starts_with("line(") { Some(SpacerKind::Line) }
696 else { None }
697}
698
699/// Does this statement carry the element `it` -- a wrap (`block`/`box`) or a bare `it` reference? Used to
700/// tell the hole statement from the spacers around it.
701fn is_element_stmt(s: &str) -> bool {
702 s.starts_with("block") || s.starts_with("box") || mentions_word(s, "it")
703}
704
705/// Does `s` contain the bare identifier `word` at a word boundary (so `it` is not found inside `with`)?
706fn mentions_word(s: &str, word: &str) -> bool {
707 let bytes = s.as_bytes();
708 let mut from = 0usize;
709 while let Some(rel) = s[from..].find(word) {
710 let at = from + rel;
711 let before = at == 0 || !is_ident_byte(bytes[at - 1]);
712 let after_i = at + word.len();
713 let after = after_i >= bytes.len() || !is_ident_byte(bytes[after_i]);
714 if before && after {
715 return true;
716 }
717 from = at + word.len();
718 }
719 false
720}
721
722fn is_ident_byte(b: u8) -> bool {
723 b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
724}
725
726/// Builds the sibling block a spacer lowers to, or folds a heading's `v(...)` into the matched level's
727/// spacing (returning `Ok(None)`). Under a heading a non-`v` sibling is refused -- a divider after a heading
728/// strands it from the content it must keep with.
729fn make_spacer(
730 selector: &Selector,
731 kind: SpacerKind,
732 s: &str,
733 seen_hole: bool,
734 heading: bool,
735 hole: &mut ThemePatch,
736)
737 -> Outcome<Option<Block>>
738{
739 if heading {
740 if kind != SpacerKind::Vertical {
741 // The keep-with-next guard: a heading must stay adjacent to the block it keeps with.
742 if seen_hole {
743 return Err(err!("post-content after a heading breaks keep-with-next"; Invalid, Input));
744 }
745 return Err(err!(
746 "a heading template supports only v() spacing, not a leading {}", kind.label(); Invalid, Input));
747 }
748 let sp = res!(spacer_length(s));
749 let n = res!(level_predicate(selector).ok_or_else(||
750 err!("a heading spacing template needs a level: predicate"; Invalid, Input)));
751 let idx = (n.max(1) as usize) - 1;
752 while hole.heading.levels.len() <= idx {
753 hole.heading.levels.push(ThemeHeadingLevelPatch::default());
754 }
755 // A v() before the element lifts the level's space above; one after sets its space below.
756 if seen_hole {
757 hole.heading.levels[idx].space_below = Some(sp);
758 } else {
759 hole.heading.levels[idx].space_above = Some(sp);
760 }
761 return Ok(None);
762 }
763 match kind {
764 SpacerKind::Vertical => Ok(Some(Block::Space(res!(spacer_length(s))))),
765 SpacerKind::Line => Ok(Some(res!(read_line(s)))),
766 }
767}
768
769/// The scaled-point length of a `v(<len>)` statement's argument. An `em` (or `%`) value has no running
770/// size at lowering time, so it is refused rather than set wrongly.
771fn spacer_length(s: &str) -> Outcome<Sp> {
772 let inside = res!(call_args(s, "v").ok_or_else(|| err!("malformed v() spacer: {}", short(s); Invalid, Input)));
773 match length_pt(inside.trim()) {
774 Some(pt) => Ok(Sp::from_pt(pt)),
775 None => Err(err!("a v() length needs an absolute unit (pt/mm/cm), not {}", short(&inside); Invalid, Input)),
776 }
777}
778
779/// A `line(length: <len>, stroke: <n>pt)` lowered to a horizontal rule. A `length` given as a percentage is
780/// a fraction of the placement measure ([`Length::Rel`]); an absolute length is [`Length::Abs`]. The stroke
781/// thickness and grey take template-like defaults when the source names none.
782fn read_line(s: &str) -> Outcome<Block> {
783 let inside = res!(call_args(s, "line").ok_or_else(|| err!("malformed line() divider: {}", short(s); Invalid, Input)));
784 let width = match named_value(&inside, "length") {
785 Some(v) if v.trim_end().ends_with('%') => {
786 let f = res!(v.trim_end().trim_end_matches('%').trim().parse::<f64>()
787 .map_err(|_| err!("line length percentage not a number: {}", short(&v); Invalid, Input)));
788 Length::Rel(f / 100.0)
789 },
790 Some(v) => match length_pt(v.trim()) {
791 Some(pt) => Length::Abs(pt),
792 None => return Err(err!("a line length needs pt/mm/cm or %, not {}", short(&v); Invalid, Input)),
793 },
794 None => Length::Rel(1.0), // a bare divider runs the full measure
795 };
796 // The stroke, if any, is `<n>pt` optionally `+ luma(<g>)`; default a thin black rule.
797 let (thickness, grey) = match named_value(&inside, "stroke") {
798 Some(v) => (length_pt(&v).unwrap_or(0.6), stroke_grey(&v).unwrap_or(0)),
799 None => (0.6, 0),
800 };
801 Ok(Block::Rule { width, thickness, grey })
802}
803
804/// The grey level a `stroke: ... + luma(<g>)` names, or `None` when the stroke carries no `luma`.
805fn stroke_grey(v: &str) -> Option<u8> {
806 let at = v.find("luma(")? + "luma(".len();
807 let end = v[at..].find(')')?;
808 v[at..at + end].trim().parse::<f64>().ok().map(|n| n.round().clamp(0.0, 255.0) as u8)
809}
810
811/// Reads the element (hole) statement of a template into the hole patch and frame. A `block.with(fill: ...)`
812/// or `block(fill: ...)[#it]` sets the frame's wash, plus its `inset:`/`radius:` when the same call names
813/// them; a `#set text(...)` inside the wrap's content overlays the element; a bare `it` leaves both
814/// untouched. A wrap that is neither a `.with` partial nor a content wrap of `it` is refused, so a body
815/// this reader cannot place is a visible refusal, not a silent no-op -- and so is an `inset`/`radius` this
816/// reader cannot resolve to a length (an `em` or `%` value, or a dict form naming something other than
817/// `x`/`y`/`bottom`), rather than the fill being framed while its geometry is quietly dropped.
818fn read_element(s: &str, hole: &mut ThemePatch, frame: &mut Option<TemplateFrame>) -> Outcome<()> {
819 let is_wrap = s.starts_with("block") || s.starts_with("box");
820 if !is_wrap {
821 // A bare `it` / `it.body` -- the element passes through untouched.
822 return Ok(());
823 }
824 // The wrap's argument list -- `block.with(<args>)` or `block(<args>)[...]`.
825 let head = s.strip_prefix("block").or_else(|| s.strip_prefix("box")).unwrap_or(s);
826 let head = head.trim_start_matches(".with").trim_start();
827 let args = call_group(head).unwrap_or_default();
828 // A `fill:` washes the element in a box; `inset:`/`radius:` in the same call ride along on it, since
829 // neither means anything without a box to draw them on.
830 if let Some(fv) = named_value(&args, "fill") {
831 match parse_colour(&fv) {
832 Some(rgba) => {
833 let mut tf = TemplateFrame { fill: rgba, inset_x: None, inset_top: None, inset_bot: None, radius: None };
834 if let Some(iv) = named_value(&args, "inset") {
835 res!(read_inset(&iv, &mut tf));
836 }
837 if let Some(rv) = named_value(&args, "radius") {
838 tf.radius = Some(Sp::from_pt(res!(length_pt_or_refuse("radius", &rv))));
839 }
840 *frame = Some(tf);
841 },
842 None => return Err(err!(
843 "a template fill colour could not be resolved: {}", short(&fv); Invalid, Input)),
844 }
845 }
846 // A content block `[ ... ]` may carry `#set` overlays and must reference `it` when the wrap is not a
847 // `.with` partial application.
848 let has_partial = s.contains(".with");
849 if let Some(content) = bracket_content(s) {
850 for (target, cargs) in inner_sets(&content) {
851 merge_patch(hole, &set::lower_set(&target, &cargs));
852 }
853 if !mentions_word(&content, "it") {
854 return Err(err!("a template wrap's content does not place the element `it`: {}", short(s); Invalid, Input));
855 }
856 } else if !has_partial {
857 return Err(err!("a template wrap places no element `it`: {}", short(s); Invalid, Input));
858 }
859 Ok(())
860}
861
862/// Reads a `block.with(inset: ...)` argument into `tf`: a scalar length (`inset: 8pt`) pads every side
863/// alike, and the dict form (`inset: (x: 8pt, y: 6pt, bottom: 8pt)`) reuses the corpus's own shape -- `x`
864/// the horizontal pad, `y` the top pad (and the foot pad too, unless `bottom` overrides it), `bottom` the
865/// foot pad alone. A dict key this reader does not recognise, or a length it cannot resolve to points (an
866/// `em` or `%` value), is refused rather than silently left at the renderer's default.
867fn read_inset(raw: &str, tf: &mut TemplateFrame) -> Outcome<()> {
868 let raw = raw.trim();
869 if raw.starts_with('(') {
870 let inner = res!(call_group(raw).ok_or_else(||
871 err!("a template inset dict is not a closed (...) group: {}", short(raw); Invalid, Input)));
872 let mut named = false;
873 if let Some(xv) = named_value(&inner, "x") {
874 tf.inset_x = Some(Sp::from_pt(res!(length_pt_or_refuse("inset x", &xv))));
875 named = true;
876 }
877 if let Some(yv) = named_value(&inner, "y") {
878 let pt = Sp::from_pt(res!(length_pt_or_refuse("inset y", &yv)));
879 tf.inset_top = Some(pt);
880 tf.inset_bot = Some(pt); // `y` sets top and bottom alike, unless `bottom` overrides it below
881 named = true;
882 }
883 if let Some(bv) = named_value(&inner, "bottom") {
884 tf.inset_bot = Some(Sp::from_pt(res!(length_pt_or_refuse("inset bottom", &bv))));
885 named = true;
886 }
887 if !named {
888 return Err(err!("a template inset dict names none of x/y/bottom: {}", short(raw); Invalid, Input));
889 }
890 Ok(())
891 } else {
892 let pt = Sp::from_pt(res!(length_pt_or_refuse("inset", raw)));
893 tf.inset_x = Some(pt);
894 tf.inset_top = Some(pt);
895 tf.inset_bot = Some(pt);
896 Ok(())
897 }
898}
899
900/// A length token to points, refusing rather than silently dropping a value [`length_pt`] cannot resolve
901/// (an `em` or `%`, which has no absolute size at lowering time) -- named by `field` for the diagnostic.
902fn length_pt_or_refuse(field: &str, v: &str) -> Outcome<f64> {
903 match length_pt(v) {
904 Some(pt) => Ok(pt),
905 None => Err(err!(
906 "a template {} length could not be resolved to points (em/% are not supported here): {}",
907 field, short(v); Invalid, Input)),
908 }
909}
910
911/// Folds the non-default leaves of `src` onto `dst` -- the overlay a wrap's inner `#set` contributes to the
912/// hole. Each group `lower_set` writes is folded, the heading group per level so a heading `v(...)` spacing
913/// already folded in stands beside a `#set heading(...)` the same wrap might carry.
914fn merge_patch(dst: &mut ThemePatch, src: &ThemePatch) {
915 let d = ThemePatch::default();
916 if src.text != d.text { dst.text = src.text.clone(); }
917 if src.par != d.par { dst.par = src.par.clone(); }
918 if src.list != d.list { dst.list = src.list.clone(); }
919 if src.enumeration != d.enumeration { dst.enumeration = src.enumeration.clone(); }
920 if src.equation != d.equation { dst.equation = src.equation.clone(); }
921 if src.page != d.page { dst.page = src.page.clone(); }
922 if src.code != d.code { dst.code = src.code.clone(); }
923 // The heading group, folded leaf by leaf so a level's spacing set elsewhere survives.
924 if src.heading.numbering_all.is_some() { dst.heading.numbering_all = src.heading.numbering_all.clone(); }
925 if src.heading.size_all.is_some() { dst.heading.size_all = src.heading.size_all; }
926 if src.heading.face_all.is_some() { dst.heading.face_all = src.heading.face_all.clone(); }
927 if src.heading.face.is_some() { dst.heading.face = src.heading.face.clone(); }
928 if src.heading.kind.is_some() { dst.heading.kind = src.heading.kind; }
929 for (i, lvl) in src.heading.levels.iter().enumerate() {
930 while dst.heading.levels.len() <= i {
931 dst.heading.levels.push(ThemeHeadingLevelPatch::default());
932 }
933 let into = &mut dst.heading.levels[i];
934 if lvl.size.is_some() { into.size = lvl.size; }
935 if lvl.space_above.is_some() { into.space_above = lvl.space_above; }
936 if lvl.space_below.is_some() { into.space_below = lvl.space_below; }
937 if lvl.face.is_some() { into.face = lvl.face.clone(); }
938 if lvl.weight.is_some() { into.weight = lvl.weight; }
939 if lvl.italic.is_some() { into.italic = lvl.italic; }
940 if lvl.smallcaps.is_some() { into.smallcaps = lvl.smallcaps; }
941 if lvl.numbering.is_some() { into.numbering = lvl.numbering.clone(); }
942 }
943}
944
945/// The top-level `#set <target>(<args>)` declarations inside a wrap's content block, as `(target, args)`
946/// pairs -- the overlay a `block(...)[ #set text(size: 9pt) #it ]` contributes to the hole.
947fn inner_sets(content: &str) -> Vec<(String, String)> {
948 let mut out = Vec::new();
949 for stmt in split_statements(content) {
950 let s = stmt.trim().trim_start_matches('#').trim();
951 let after = match s.strip_prefix("set ") {
952 Some(a) => a.trim(),
953 None => continue,
954 };
955 let open = match after.find('(') {
956 Some(i) => i,
957 None => continue,
958 };
959 let target = after[..open].trim().to_string();
960 if let Some(args) = call_group(&after[open..]) {
961 out.push((target, args));
962 }
963 }
964 out
965}
966
967/// The text inside the first balanced `(...)` of `call(...)` when `s` opens with `name`, or `None`.
968fn call_args(s: &str, name: &str) -> Option<String> {
969 let rest = s.strip_prefix(name)?.trim_start();
970 call_group(rest)
971}
972
973/// The text inside a balanced `(...)` at the start of `s` (which must open with `(`), spanning nested
974/// brackets and strings. `None` when the parentheses never close.
975fn call_group(s: &str) -> Option<String> {
976 let s = s.trim_start();
977 let bytes = s.as_bytes();
978 if bytes.first() != Some(&b'(') {
979 return None;
980 }
981 let mut depth = 0i32;
982 let mut in_str = false;
983 let mut esc = false;
984 for (i, c) in s.char_indices() {
985 if in_str {
986 if esc { esc = false; }
987 else if c == '\\' { esc = true; }
988 else if c == '"' { in_str = false; }
989 continue;
990 }
991 match c {
992 '"' => in_str = true,
993 '(' | '[' | '{' => depth += 1,
994 ')' | ']' | '}' => {
995 depth -= 1;
996 if depth == 0 {
997 return Some(s[1..i].to_string());
998 }
999 },
1000 _ => {},
1001 }
1002 }
1003 None
1004}
1005
1006/// The text inside the first balanced `[...]` content block of `s`, or `None` when there is none.
1007fn bracket_content(s: &str) -> Option<String> {
1008 let start = s.find('[')?;
1009 let bytes = s.as_bytes();
1010 let mut depth = 0i32;
1011 for (i, c) in s[start..].char_indices() {
1012 match c {
1013 '[' => depth += 1,
1014 ']' => {
1015 depth -= 1;
1016 if depth == 0 {
1017 let _ = bytes;
1018 return Some(s[start + 1..start + i].to_string());
1019 }
1020 },
1021 _ => {},
1022 }
1023 }
1024 None
1025}
1026
1027/// The raw value text a `key:` names inside an argument list, up to the next top-level comma. `None` when
1028/// the key is absent.
1029fn named_value(args: &str, key: &str) -> Option<String> {
1030 let bytes = args.as_bytes();
1031 let mut from = 0usize;
1032 let start = loop {
1033 let rel = args[from..].find(key)?;
1034 let at = from + rel;
1035 let before_ok = at == 0 || !is_ident_byte(bytes[at - 1]);
1036 let mut j = at + key.len();
1037 while j < bytes.len() && bytes[j] == b' ' {
1038 j += 1;
1039 }
1040 if before_ok && j < bytes.len() && bytes[j] == b':' {
1041 break j + 1;
1042 }
1043 from = at + key.len();
1044 };
1045 // Read to the next comma outside any nested group.
1046 let tail = &args[start..];
1047 let mut depth = 0i32;
1048 let mut end = tail.len();
1049 for (i, c) in tail.char_indices() {
1050 match c {
1051 '(' | '[' | '{' => depth += 1,
1052 ')' | ']' | '}' => depth -= 1,
1053 ',' if depth == 0 => { end = i; break; },
1054 _ => {},
1055 }
1056 }
1057 Some(tail[..end].trim().to_string())
1058}
1059
1060/// A template's named colour palette (`#let colours = (yellow: rgb("#f0f600"), ...)`), by name. Empty
1061/// unless a book's palette is collected from its template chain; a `colours.<name>` reference resolves
1062/// against it.
1063pub type Palette = std::collections::HashMap<String, Rgba>;
1064
1065/// A colour expression lowered to an [`Rgba`]. The forms a template fill takes that resolve without a
1066/// palette: `luma(<n>)`, `rgb("#rrggbb")`, `rgb(<r>, <g>, <b>)` and a small set of named colours, each
1067/// optionally lightened or darkened (`.lighten(<p>%)` / `.darken(<p>%)`). A palette reference (`colours.blue`)
1068/// resolves only through [`parse_colour_pal`], which is given the book's palette.
1069///
1070/// Shared with the `#set text(fill:)` lowering ([`crate::lang::set`]), which reads a body-text colour
1071/// with the same grammar, so the two readers cannot drift.
1072pub(crate) fn parse_colour(expr: &str) -> Option<Rgba> {
1073 parse_colour_pal(expr, &Palette::new())
1074}
1075
1076/// As [`parse_colour`], resolving a `colours.<name>` reference against `palette` (and applying any trailing
1077/// `.lighten`/`.darken` to the looked-up colour). With an empty palette this is exactly [`parse_colour`].
1078pub(crate) fn parse_colour_pal(expr: &str, palette: &Palette) -> Option<Rgba> {
1079 let e = expr.trim();
1080 // The base runs up to the first `.lighten`/`.darken` modifier (a `luma(...)`/`rgb(...)` call keeps its
1081 // own parentheses); the rest is the modifier chain.
1082 let split_at = [".lighten", ".darken"].iter().filter_map(|m| e.find(m)).min();
1083 let (head, mods) = match split_at {
1084 Some(i) => (e[..i].trim(), &e[i..]),
1085 None => (e, ""),
1086 };
1087 let base = if let Some(rest) = head.strip_prefix("luma(") {
1088 let n = rest.trim_end_matches(')').trim().parse::<f64>().ok()?;
1089 let v = n.round().clamp(0.0, 255.0) as u8;
1090 Rgba::opaque(v, v, v)
1091 } else if let Some(rest) = head.strip_prefix("rgb(") {
1092 res_rgb(rest.trim_end_matches(')').trim())?
1093 } else if let Some(name) = head.strip_prefix("colours.").or_else(|| head.strip_prefix("colors.")) {
1094 *palette.get(name.trim())?
1095 } else {
1096 named_colour(head)?
1097 };
1098 Some(apply_colour_mods(base, mods))
1099}
1100
1101/// Collects a template's `#let colours = ( name: <colour>, ... )` palette from `src` into `palette`, so a
1102/// `colours.<name>` reference in a furniture fill or stroke resolves. Each entry's value is read with the
1103/// same colour grammar as a fill (`rgb("#...")`, `luma(...)`, a named colour). An entry this reader cannot
1104/// resolve is passed over; a source with no such binding adds nothing.
1105pub fn collect_palette(src: &str, palette: &mut Palette) {
1106 let chars: Vec<char> = src.chars().collect();
1107 let mut i = 0usize;
1108 while i < chars.len() {
1109 // The literal must name `colours` exactly, not merely start with it -- `#let colours_x = (...)`
1110 // is a different binding and must not be read as the palette.
1111 if at_line_start(&chars, i) && starts_with_at(&chars, i, "#let colours")
1112 && !chars.get(i + "#let colours".chars().count()).is_some_and(|&c| is_ident_char(c))
1113 {
1114 // The dict opens at the first `(` after the `=`.
1115 let mut j = i;
1116 while j < chars.len() && chars[j] != '(' && chars[j] != '\n' {
1117 j += 1;
1118 }
1119 if chars.get(j) == Some(&'(') {
1120 if let Some((inner, next)) = read_delim_group(&chars, j) {
1121 for entry in split_top_commas_str(&inner) {
1122 if let Some((name_part, val_part)) = entry.split_once(':') {
1123 // The name is the last line of the key part, so a `//` comment line preceding the
1124 // entry is dropped; the value is taken up to any trailing `//` line comment.
1125 let name = name_part.rsplit('\n').next().unwrap_or(name_part).trim();
1126 let val = val_part.split("//").next().unwrap_or(val_part).trim();
1127 if !name.is_empty() && name.chars().all(is_ident_char) {
1128 if let Some(rgba) = parse_colour(val) {
1129 palette.insert(name.to_string(), rgba);
1130 }
1131 }
1132 }
1133 }
1134 i = next;
1135 continue;
1136 }
1137 }
1138 }
1139 i += 1;
1140 }
1141}
1142
1143/// `rgb("#rrggbb")` or `rgb(<r>, <g>, <b>)` to an [`Rgba`].
1144fn res_rgb(inner: &str) -> Option<Rgba> {
1145 let inner = inner.trim();
1146 if let Some(hex) = inner.strip_prefix('"').and_then(|h| h.strip_suffix('"')) {
1147 let hex = hex.trim_start_matches('#');
1148 if hex.len() == 6 {
1149 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1150 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1151 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1152 return Some(Rgba::opaque(r, g, b));
1153 }
1154 return None;
1155 }
1156 let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
1157 if parts.len() == 3 {
1158 let r = parts[0].parse::<f64>().ok()?.round().clamp(0.0, 255.0) as u8;
1159 let g = parts[1].parse::<f64>().ok()?.round().clamp(0.0, 255.0) as u8;
1160 let b = parts[2].parse::<f64>().ok()?.round().clamp(0.0, 255.0) as u8;
1161 return Some(Rgba::opaque(r, g, b));
1162 }
1163 None
1164}
1165
1166/// A named colour to its [`Rgba`], for the handful Typst's own defaults carry.
1167fn named_colour(name: &str) -> Option<Rgba> {
1168 Some(match name {
1169 "black" => Rgba::opaque(0, 0, 0),
1170 "white" => Rgba::opaque(255, 255, 255),
1171 "gray" | "grey" => Rgba::opaque(170, 170, 170),
1172 "silver" => Rgba::opaque(221, 221, 221),
1173 "red" => Rgba::opaque(255, 65, 54),
1174 "green" => Rgba::opaque(46, 204, 64),
1175 "blue" => Rgba::opaque(0, 116, 217),
1176 "yellow" => Rgba::opaque(255, 220, 0),
1177 "orange" => Rgba::opaque(255, 133, 27),
1178 "purple" => Rgba::opaque(177, 13, 201),
1179 _ => return None,
1180 })
1181}
1182
1183/// Applies the trailing `.lighten(<p>%)` / `.darken(<p>%)` modifiers of a colour expression, each mixing the
1184/// colour that fraction toward white or black the way Typst's own `.lighten`/`.darken` do.
1185fn apply_colour_mods(base: Rgba, mods: &str) -> Rgba {
1186 let mut c = base;
1187 let mut rest = mods;
1188 loop {
1189 let dot = match rest.find('.') {
1190 Some(i) => i,
1191 None => break,
1192 };
1193 let after = &rest[dot + 1..];
1194 let open = match after.find('(') {
1195 Some(i) => i,
1196 None => break,
1197 };
1198 let name = after[..open].trim();
1199 let inner = match call_group(&after[open..]) {
1200 Some(g) => g,
1201 None => break,
1202 };
1203 let pct = inner.trim().trim_end_matches('%').trim().parse::<f64>().unwrap_or(0.0) / 100.0;
1204 c = match name {
1205 "lighten" => mix(c, 255, pct),
1206 "darken" => mix(c, 0, pct),
1207 _ => c,
1208 };
1209 // Advance past this `.name(inner)` modifier: the dot, the name, and the balanced `(inner)`.
1210 let consumed = dot + 1 + open + 1 + inner.len() + 1;
1211 if consumed >= rest.len() {
1212 break;
1213 }
1214 rest = &rest[consumed..];
1215 }
1216 c
1217}
1218
1219/// Mixes each channel of `c` a fraction `t` toward `target` (0 or 255) -- the arithmetic behind lighten/darken.
1220fn mix(c: Rgba, target: i32, t: f64) -> Rgba {
1221 let f = |v: u8| -> u8 {
1222 let nv = v as f64 + (target as f64 - v as f64) * t;
1223 nv.round().clamp(0.0, 255.0) as u8
1224 };
1225 Rgba::new(f(c.r), f(c.g), f(c.b), c.a)
1226}
1227
1228/// A length token to points, accepting `pt`, `mm`, `cm`, `in` or a bare number. An `em` or `%` value has no
1229/// absolute size at lowering time, so it returns `None` and the caller refuses it.
1230fn length_pt(s: &str) -> Option<f64> {
1231 let s = s.trim();
1232 let mut end = 0usize;
1233 let mut seen_dot = false;
1234 for (i, c) in s.char_indices() {
1235 if c.is_ascii_digit() || (c == '-' && i == 0) {
1236 end = i + c.len_utf8();
1237 } else if c == '.' && !seen_dot {
1238 seen_dot = true;
1239 end = i + c.len_utf8();
1240 } else {
1241 break;
1242 }
1243 }
1244 if end == 0 {
1245 return None;
1246 }
1247 let num: f64 = s[..end].parse().ok()?;
1248 let unit = s[end..].trim();
1249 match unit {
1250 "" | "pt" => Some(num),
1251 "mm" => Some(num * 72.0 / 25.4),
1252 "cm" => Some(num * 72.0 / 2.54),
1253 "in" => Some(num * 72.0),
1254 _ => None,
1255 }
1256}
1257
1258// ┌───────────────────────────────────────────────────────────────────────────┐
1259// │ #let TEMPLATE FUNCTIONS (a `#let name(params) = block/box(...)` furniture) │
1260// └───────────────────────────────────────────────────────────────────────────┘
1261
1262/// A `#let name(params) = <expr>` furniture function the reader expands at each call site. The two the
1263/// Lucronics corpus defines -- `#pr-note(body)` (a plain indented, tightened block) and
1264/// `#aside-box(title: ..., body)` (a washed, left-stroked callout) -- both wrap their body parameter in a
1265/// `block(...)`/`box(...)`, so a call `#name[ ... ]` sets that body inside the furniture's frame rather
1266/// than being tallied as a skipped construct.
1267///
1268/// The definition is lowered once (at book-assembly time, against the document's body size, so every
1269/// `em` length resolves to an absolute at that size) into a serialisable [`ThemePatch`] carrying the
1270/// frame's geometry (`callout.*`) and the inner `#set text`/`#set par` overlay (`text.*`/`par.*`). A call
1271/// then re-parses its `[ ... ]` body and wraps it in a `Block::Box` under that patch -- the same shape a
1272/// `#styled-box[...]` produces, so the callout renderer sets it with no new path.
1273#[derive(Clone, Debug, PartialEq)]
1274pub struct TemplateFn {
1275 pub body_param: String, // the content parameter the call's `[ ... ]` body fills
1276 pub has_title: bool, // a `title:` parameter -> a leading bold title paragraph in the body
1277 pub title_size: Option<Sp>, // the title run's text size, em-resolved (a bold paragraph is set at it)
1278 pub patch: ThemePatch, // the frame geometry and the inner-set overlay, merged
1279 pub float: Option<FloatPlacement>, // Some when the body is re-wrapped in `figure(placement: ...)` -- a float
1280}
1281
1282/// The template functions in scope for a source, by name. Empty until a book's definitions are collected;
1283/// a source with none reads exactly as before.
1284pub type TemplateFns = std::collections::HashMap<String, TemplateFn>;
1285
1286/// A `#let name = [ ... ]` (value) or `#let name(p, ...) = [ ... ]` (function) content binding: markup
1287/// captured verbatim and, at each reference, expanded by substituting the call's positional arguments for
1288/// each `#param` in the body and re-reading the result as document markup. Unlike a [`TemplateFn`], whose
1289/// body is a furniture wrap the reader lowers to a padded box, a content binding's body is arbitrary block
1290/// markup -- headings, paragraphs, nested calls -- so its blocks are spliced into the stream, not boxed.
1291#[derive(Clone, Debug, PartialEq)]
1292pub struct ContentFn {
1293 pub params: Vec<String>, // the positional parameter names, empty for a value binding
1294 pub body: String, // the bracketed markup, its `[` `]` delimiters stripped
1295 pub wrapper: Option<String>, // a `box`/`rect`/`block` styling wrap the body was lifted out of
1296}
1297
1298/// The content bindings in scope for a source, by name. Empty until a document's definitions are collected;
1299/// a source with none reads exactly as before.
1300pub type ContentFns = std::collections::HashMap<String, ContentFn>;
1301
1302/// A `#let name = <literal>` scalar value binding: a bare string, integer, float or length literal, held as
1303/// its own display text so a later `#name` reference substitutes it verbatim. Unlike a [`ContentFn`], whose
1304/// body is markup re-read through the reader, a scalar's value needs no re-parse -- Typst renders a bare
1305/// literal exactly as it was written (`3`, `1.5`, `12pt`), so the source text a scalar was declared with IS
1306/// its display text, with a string's quotes stripped.
1307#[derive(Clone, Debug, PartialEq)]
1308pub enum ScalarValue {
1309 Str(String), // a `"..."` string, its quotes stripped
1310 Number(String), // an int, float or length literal, kept as its own source text (unit included)
1311}
1312
1313impl ScalarValue {
1314 /// The text a `#name` reference substitutes: the string's contents, or the number/length literal's own
1315 /// source text.
1316 pub fn display_text(&self) -> &str {
1317 match self {
1318 Self::Str(s) => s,
1319 Self::Number(s) => s,
1320 }
1321 }
1322}
1323
1324/// The scalar value bindings in scope for a source, by name. Empty until a document's definitions are
1325/// collected; a source with none reads exactly as before.
1326pub type ScalarFns = std::collections::HashMap<String, ScalarValue>;
1327
1328/// The `#let` bindings a parse resolves a call against: the furniture functions ([`TemplateFns`], expanded
1329/// into a padded box) and the content bindings ([`ContentFns`], spliced as markup). Threaded as one through
1330/// the reader so a caller passes both together and a nested body carries the same scope. Borrowed, so it is
1331/// [`Copy`] and travels without a clone.
1332///
1333/// `active` is the stack of content-binding names currently being expanded, innermost last. A reference to a
1334/// name already on it is a cycle (`#let a = [#a]`, or the mutual `#let a = [#b]`/`#let b = [#a]`) and is
1335/// refused at once -- so a cycle recurses only to its own length, never until the native stack or the wasm
1336/// shadow stack overflows. Its length also caps a pathological non-cyclic chain (see the reader's own cap).
1337#[derive(Clone, Copy)]
1338pub struct Bindings<'a, 'b> {
1339 pub tfns: &'a TemplateFns,
1340 pub cfns: &'a ContentFns,
1341 pub sfns: &'a ScalarFns,
1342 pub active: &'b [String],
1343}
1344
1345impl<'a> Bindings<'a, 'static> {
1346 /// No scalar scope to hand: borrows the empty [`ScalarFns`] map, so a caller with only furniture and
1347 /// content bindings in scope reads exactly as before.
1348 pub fn new(tfns: &'a TemplateFns, cfns: &'a ContentFns) -> Self {
1349 Self { tfns, cfns, sfns: empty_scalar_fns(), active: &[] }
1350 }
1351
1352 /// As [`Self::new`], with the scalar `#let` value bindings a full `#let` scope also carries -- see
1353 /// [`crate::book::Scope::bindings`], which is how a book or lone-file compile builds one.
1354 pub fn with_scalars(tfns: &'a TemplateFns, cfns: &'a ContentFns, sfns: &'a ScalarFns) -> Self {
1355 Self { tfns, cfns, sfns, active: &[] }
1356 }
1357}
1358
1359impl<'a, 'b> Bindings<'a, 'b> {
1360 /// Is `name` already being expanded -- a content-binding cycle?
1361 pub fn expanding(&self, name: &str) -> bool {
1362 self.active.iter().any(|n| n == name)
1363 }
1364
1365 /// The number of content-binding expansions currently open.
1366 pub fn depth(&self) -> usize {
1367 self.active.len()
1368 }
1369
1370 /// The same bindings with `active` as the stack of names in expansion, for re-reading an expanded body.
1371 pub fn with_active<'c>(self, active: &'c [String]) -> Bindings<'a, 'c> {
1372 Bindings { tfns: self.tfns, cfns: self.cfns, sfns: self.sfns, active }
1373 }
1374}
1375
1376/// The empty [`ScalarFns`] map [`Bindings::new`] borrows when a caller has no scalar scope to hand -- a
1377/// `'static` empty map costs nothing to share and needs no per-call allocation.
1378fn empty_scalar_fns() -> &'static ScalarFns {
1379 static EMPTY: std::sync::OnceLock<ScalarFns> = std::sync::OnceLock::new();
1380 EMPTY.get_or_init(ScalarFns::new)
1381}
1382
1383/// Collects every `#let name(params) = block/box(...)` furniture definition in `src` into `tfns`, lowering
1384/// each against `body_size` so its `em` lengths resolve to absolutes. A definition whose body this reader
1385/// cannot lower (not a `block`/`box` wrap, or naming a length it cannot resolve) is passed over silently --
1386/// the call then stays a tallied skip, exactly as before, rather than expanding wrongly. A byte-identical
1387/// definition seen twice (the corpus repeats `#let pr-note` verbatim atop three chapters) re-inserts the
1388/// same value, so the map is definition-order-independent.
1389pub fn collect_template_fns(src: &str, body_size: Sp, palette: &Palette, tfns: &mut TemplateFns) {
1390 let chars: Vec<char> = src.chars().collect();
1391 let mut i = 0usize;
1392 while i < chars.len() {
1393 // A definition opens at a line-leading `#let <ident>(` -- a function `#let`, whose name is followed by
1394 // a parameter list. (`#let name = (...)` -- a value binding -- has no `(` right after the name and is
1395 // left to the data-array reader.)
1396 if at_line_start(&chars, i) && starts_with_at(&chars, i, "#let ") {
1397 if let Some((name, params, expr, next)) = read_let_fn(&chars, i) {
1398 // A name the reader already handles as a built-in construct (`styled-box`, `padded-image`,
1399 // `part-page`, ...) is NOT overridden by a collected definition, so the built-in path stays
1400 // authoritative and a corpus that defines its own `styled-box` renders exactly as before.
1401 if !is_reserved_construct(&name) {
1402 if let Some(tf) = lower_template_fn(&params, &expr, body_size, palette) {
1403 tfns.insert(name, tf);
1404 }
1405 }
1406 i = next;
1407 continue;
1408 }
1409 }
1410 i += 1;
1411 }
1412}
1413
1414/// Collects every `#let name = [ ... ]` and `#let name(params) = [ ... ]` content binding in `src` into
1415/// `cfns`. The body is a bracket-balanced `[ ... ]`, captured verbatim with its parameter names, so a
1416/// reference expands into re-read markup. A `#let` whose body is a data array (`= (...)`) or a scalar is
1417/// passed over here -- the array and scalar readers keep those -- and a name the reader already handles as
1418/// a built-in construct is not overridden. A binding seen twice re-inserts the same value, so the map is
1419/// definition-order-independent.
1420///
1421/// A body that is a `box(...)[ ... ]`, `rect(...)[ ... ]` or `block(...)[ ... ]` styling wrap -- a content
1422/// function whose text is set inside a styled box, `#let stamp(s) = box(fill: ..)[*v: #s*]` -- is captured
1423/// as a content binding of its INNER `[ ... ]` content, with the wrapper name held so the styling this
1424/// reader cannot draw is recorded as a visible skip when the binding expands. The inner text is kept and
1425/// set, never silently dropped. This is distinct from a furniture wrap (`#pr-note`, whose content block
1426/// sits INSIDE the call's parens and the [`collect_template_fns`] reader draws as a styled block): a
1427/// furniture definition carries no `[ ... ]` group TRAILING the wrap's closing `)`, so the two shapes do
1428/// not collide, and where a name were somehow read by both, the furniture map wins at every call site.
1429pub fn collect_content_fns(src: &str, cfns: &mut ContentFns) {
1430 let chars: Vec<char> = src.chars().collect();
1431 let mut i = 0usize;
1432 while i < chars.len() {
1433 if at_line_start(&chars, i) && starts_with_at(&chars, i, "#let ") {
1434 if let Some((name, params, body, wrapper, next)) = read_let_content(&chars, i) {
1435 if !is_reserved_construct(&name) {
1436 cfns.insert(name, ContentFn { params, body, wrapper });
1437 }
1438 i = next;
1439 continue;
1440 }
1441 }
1442 i += 1;
1443 }
1444}
1445
1446/// Reads a `#let name = [ ... ]` or `#let name(params) = [ ... ]` content binding beginning at `at` (the
1447/// `#`), returning the name, its positional parameter names (empty for a value binding), the bracketed body
1448/// with its delimiters stripped, the styling wrapper the body was lifted out of (`Some("box")` and kin, or
1449/// `None` for a plain bracket body), and the index just past it. `None` when the line is not a
1450/// content-binding `#let`: a data array `= (...)` and a scalar fail the body check below, so this reader
1451/// leaves them to the array and scalar readers.
1452///
1453/// The recognised body is either a bare `[ ... ]`, or a `box(...)[ ... ]`, `rect(...)[ ... ]` or
1454/// `block(...)[ ... ]` styling wrap whose content group TRAILS the wrap's closing `)` -- a content function
1455/// styled by a box. The trailing group tells this shape apart from a furniture definition, whose content
1456/// block sits inside the wrap's parens; a furniture `= block(...)` with no trailing `[ ... ]` fails the
1457/// check here and is left to [`collect_template_fns`].
1458fn read_let_content(chars: &[char], at: usize) -> Option<(String, Vec<String>, String, Option<String>, usize)> {
1459 let mut j = at + "#let ".chars().count();
1460 let name_start = j;
1461 while j < chars.len() && is_ident_char(chars[j]) {
1462 j += 1;
1463 }
1464 let name: String = chars[name_start..j].iter().collect();
1465 if name.is_empty() {
1466 return None;
1467 }
1468 // An optional parameter list `( ... )` for a function binding. Only a bare positional identifier is a
1469 // substitutable parameter; a keyword default (`title: ...`) or a spread is not, so it is passed over.
1470 let mut params = Vec::new();
1471 if chars.get(j) == Some(&'(') {
1472 let (plist, after) = read_paren_group(chars, j)?;
1473 params = split_top_commas_str(&plist).into_iter().filter_map(|p| {
1474 let p = p.trim();
1475 if !p.is_empty() && p.chars().all(is_ident_char) { Some(p.to_string()) } else { None }
1476 }).collect();
1477 j = after;
1478 }
1479 // The `=` separating the signature from the body.
1480 while j < chars.len() && chars[j].is_whitespace() {
1481 j += 1;
1482 }
1483 if chars.get(j) != Some(&'=') {
1484 return None;
1485 }
1486 j += 1;
1487 while j < chars.len() && chars[j].is_whitespace() {
1488 j += 1;
1489 }
1490 // A bare bracket body `[ ... ]` -- the plain content binding.
1491 if chars.get(j) == Some(&'[') {
1492 let (body, next) = read_delim_group(chars, j)?;
1493 return Some((name, params, body, None, next));
1494 }
1495 // A styling wrap `box(...)[ ... ]` / `rect(...)[ ... ]` / `block(...)[ ... ]`: a content function whose
1496 // text is set inside a styled box. The inner `[ ... ]` content is the binding's body; the wrapper name is
1497 // carried so the styling this reader cannot draw records a visible skip when the binding expands. The
1498 // content group must TRAIL the wrap's closing `)` -- a furniture definition (`#pr-note`) carries its
1499 // content block inside the parens and has no trailing group, so it fails here and stays with the
1500 // furniture reader.
1501 for wrap in ["box", "rect", "block"] {
1502 if starts_with_at(chars, j, wrap) {
1503 let after_name = j + wrap.chars().count();
1504 // A genuine wrap call: the name is followed immediately by `(`, not part of a longer identifier.
1505 if chars.get(after_name) != Some(&'(') {
1506 continue;
1507 }
1508 if let Some((_, after_args)) = read_delim_group(chars, after_name) {
1509 if chars.get(after_args) == Some(&'[') {
1510 let (body, next) = read_delim_group(chars, after_args)?;
1511 return Some((name, params, body, Some(wrap.to_string()), next));
1512 }
1513 }
1514 }
1515 }
1516 None
1517}
1518
1519/// Collects every `#let name = <literal>` scalar value binding in `src` into `sfns`: a bare `"..."` string,
1520/// or an integer, float or length literal, with nothing else on the right of the `=`. A `#let` whose body
1521/// is furniture (`= block/box(...)`), content (`= [ ... ]`), a data array (`= (...)`), a function signature
1522/// (`name(params) = ...`) or any other expression this reader does not evaluate (a call, a concatenation, an
1523/// identifier) is passed over here -- it is left as a visible `#let` skip, exactly as before -- and a name
1524/// the reader already handles as a built-in construct is not overridden. A binding seen twice re-inserts the
1525/// same value, so the map is definition-order-independent.
1526pub fn collect_scalar_fns(src: &str, sfns: &mut ScalarFns) {
1527 let chars: Vec<char> = src.chars().collect();
1528 let mut i = 0usize;
1529 while i < chars.len() {
1530 if at_line_start(&chars, i) && starts_with_at(&chars, i, "#let ") {
1531 if let Some((name, value, next)) = read_let_scalar(&chars, i) {
1532 if !is_reserved_construct(&name) {
1533 sfns.insert(name, value);
1534 }
1535 i = next;
1536 continue;
1537 }
1538 }
1539 i += 1;
1540 }
1541}
1542
1543/// Reads a `#let name = <literal>` scalar binding beginning at `at` (the `#`), returning the name, its
1544/// value, and the index just past the line it stands on. `None` when the line is not a scalar `#let`: a
1545/// name immediately followed by `(` is a function signature, left to [`read_let_content`]'s params check and
1546/// [`crate::lang::rules::collect_template_fns`]; a value that does not read as a bare string, integer, float
1547/// or length literal -- a `[...]` content body, a `(...)` array, a call, an `if`, an identifier or any other
1548/// expression -- is left as it stands, for the same visible `#let` skip a scalar binding got before this
1549/// reader existed.
1550fn read_let_scalar(chars: &[char], at: usize) -> Option<(String, ScalarValue, usize)> {
1551 let mut j = at + "#let ".chars().count();
1552 let name_start = j;
1553 while j < chars.len() && is_ident_char(chars[j]) {
1554 j += 1;
1555 }
1556 let name: String = chars[name_start..j].iter().collect();
1557 if name.is_empty() {
1558 return None;
1559 }
1560 // A scalar binding takes no parameter list; a `(` here (with no space, as a signature is written) is a
1561 // function, not a value.
1562 while j < chars.len() && chars[j].is_whitespace() {
1563 j += 1;
1564 }
1565 if chars.get(j) != Some(&'=') {
1566 return None;
1567 }
1568 j += 1;
1569 while j < chars.len() && chars[j].is_whitespace() {
1570 j += 1;
1571 }
1572 let line_end = chars[j..].iter().position(|&c| c == '\n').map_or(chars.len(), |p| j + p);
1573 let rest: String = chars[j..line_end].iter().collect();
1574 let value_text = strip_trailing_line_comment(rest.trim());
1575 let value = res_scalar_literal(value_text)?;
1576 Some((name, value, line_end))
1577}
1578
1579/// Reads `text` (the right-hand side of a `#let`, comment-stripped and trimmed) as a scalar literal: a
1580/// `"..."` string, its quotes stripped, or an integer, float or length (`pt`/`mm`/`cm`/`in`) literal kept as
1581/// its own source text -- Typst renders a bare number or length exactly as written, so no reformatting is
1582/// needed. `None` for anything else, so an expression this reader cannot evaluate is left for the ordinary
1583/// `#let` skip rather than misread.
1584fn res_scalar_literal(text: &str) -> Option<ScalarValue> {
1585 if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
1586 return Some(ScalarValue::Str(text[1..text.len() - 1].to_string()));
1587 }
1588 for unit in ["pt", "mm", "cm", "in"] {
1589 if let Some(num) = text.strip_suffix(unit) {
1590 if !num.is_empty() && num.trim().parse::<f64>().is_ok() {
1591 return Some(ScalarValue::Number(text.to_string()));
1592 }
1593 }
1594 }
1595 if text.parse::<f64>().is_ok() {
1596 return Some(ScalarValue::Number(text.to_string()));
1597 }
1598 None
1599}
1600
1601/// Strips a trailing `//` line comment from a scalar `#let`'s right-hand side (`#let n = 3 // words/min`),
1602/// so the literal reads correctly. A `//` inside the value's own `"..."` quotes is not a comment and is kept.
1603fn strip_trailing_line_comment(s: &str) -> &str {
1604 let mut in_str = false;
1605 let mut chars = s.char_indices().peekable();
1606 while let Some((idx, c)) = chars.next() {
1607 match c {
1608 '"' => in_str = !in_str,
1609 '/' if !in_str => if let Some(&(_, '/')) = chars.peek() {
1610 return s[..idx].trim_end();
1611 },
1612 _ => {},
1613 }
1614 }
1615 s
1616}
1617
1618/// Is `name` a construct the reader already captures specially, so a `#let` of that name must not shadow
1619/// the built-in path? These are exactly the names [`crate::lang::parse::capture_opener`] and the document
1620/// loop match on before the furniture arm.
1621fn is_reserved_construct(name: &str) -> bool {
1622 matches!(name,
1623 "styled-box" | "figure" | "table" | "columns" | "image" | "padded-image"
1624 | "section-banner" | "print-glossary" | "line" | "part-page"
1625 // Common Typst built-ins a corpus must not be able to redefine into a wrap the reader would expand.
1626 | "v" | "h" | "pagebreak" | "colbreak" | "place" | "lorem" | "outline" | "box" | "block" | "text" | "align"
1627 | "grid" | "stack")
1628}
1629
1630/// Is `at` the start of a line (position 0, or just after a newline)?
1631fn at_line_start(chars: &[char], at: usize) -> bool {
1632 at == 0 || chars.get(at - 1) == Some(&'\n')
1633}
1634
1635/// Does `chars` hold the literal `pat` starting at `at`?
1636fn starts_with_at(chars: &[char], at: usize, pat: &str) -> bool {
1637 let p: Vec<char> = pat.chars().collect();
1638 if at + p.len() > chars.len() {
1639 return false;
1640 }
1641 chars[at..at + p.len()] == p[..]
1642}
1643
1644/// Reads a `#let name(params) = <expr>` beginning at `at` (the `#`), returning the name, the parameter
1645/// list text, the definition expression, and the index just past the expression. The expression runs to
1646/// the end of the top-level balanced group it opens (`block( ... )`, `box( ... )`, or a `{ ... }` body),
1647/// so a multi-line definition is read whole. `None` when the line is not a function `#let`.
1648fn read_let_fn(chars: &[char], at: usize) -> Option<(String, String, String, usize)> {
1649 let mut j = at + "#let ".chars().count();
1650 // The name: identifier characters up to the `(`.
1651 let name_start = j;
1652 while j < chars.len() && is_ident_char(chars[j]) {
1653 j += 1;
1654 }
1655 let name: String = chars[name_start..j].iter().collect();
1656 if name.is_empty() || chars.get(j) != Some(&'(') {
1657 return None;
1658 }
1659 // The parameter list `( ... )`.
1660 let (params, after_params) = read_paren_group(chars, j)?;
1661 // The `=` separating the signature from the body.
1662 let mut k = after_params;
1663 while k < chars.len() && chars[k].is_whitespace() {
1664 k += 1;
1665 }
1666 if chars.get(k) != Some(&'=') {
1667 return None;
1668 }
1669 k += 1;
1670 while k < chars.len() && chars[k].is_whitespace() {
1671 k += 1;
1672 }
1673 // The expression is the balanced group the body opens: a `block(`/`box(` call, or a `{ ... }` block.
1674 let (expr, next) = read_balanced_from(chars, k)?;
1675 Some((name, params, expr, next))
1676}
1677
1678/// Reads the balanced `( ... )` group whose `(` sits at `open`, returning the inner text (without the
1679/// parentheses) and the index just past the `)`.
1680fn read_paren_group(chars: &[char], open: usize) -> Option<(String, usize)> {
1681 if chars.get(open) != Some(&'(') {
1682 return None;
1683 }
1684 read_delim_group(chars, open)
1685}
1686
1687/// Reads the balanced group whose opener (`(`, `[` or `{`) sits at `open`, returning the inner text
1688/// (without the delimiters) and the index just past its close. Nesting and string literals are honoured.
1689fn read_delim_group(chars: &[char], open: usize) -> Option<(String, usize)> {
1690 if !matches!(chars.get(open), Some('(') | Some('[') | Some('{')) {
1691 return None;
1692 }
1693 let mut depth = 0i32;
1694 let mut in_str = false;
1695 let mut esc = false;
1696 for i in open..chars.len() {
1697 let c = chars[i];
1698 if in_str {
1699 if esc { esc = false; }
1700 else if c == '\\' { esc = true; }
1701 else if c == '"' { in_str = false; }
1702 continue;
1703 }
1704 match c {
1705 '"' => in_str = true,
1706 '(' | '[' | '{' => depth += 1,
1707 ')' | ']' | '}' => {
1708 depth -= 1;
1709 if depth == 0 {
1710 let inner: String = chars[open + 1..i].iter().collect();
1711 return Some((inner, i + 1));
1712 }
1713 },
1714 _ => {},
1715 }
1716 }
1717 None
1718}
1719
1720/// Reads the balanced group beginning at `from` -- a `name( ... )` call, a `( ... )`, a `[ ... ]` or a
1721/// `{ ... }` -- returning the whole group's text (delimiters included) and the index just past its close.
1722/// The group starts at the first `(`/`[`/`{` at or after `from` on the definition; leading identifier
1723/// characters (a call name like `block`) are kept in the returned text.
1724fn read_balanced_from(chars: &[char], from: usize) -> Option<(String, usize)> {
1725 // Skip a leading call name to its opening bracket, keeping the name in the span.
1726 let mut open = from;
1727 while open < chars.len() && (is_ident_char(chars[open]) || chars[open] == '.') {
1728 open += 1;
1729 }
1730 let opener = *chars.get(open)?;
1731 if !matches!(opener, '(' | '[' | '{') {
1732 return None;
1733 }
1734 let mut depth = 0i32;
1735 let mut in_str = false;
1736 let mut esc = false;
1737 for i in open..chars.len() {
1738 let c = chars[i];
1739 if in_str {
1740 if esc { esc = false; }
1741 else if c == '\\' { esc = true; }
1742 else if c == '"' { in_str = false; }
1743 continue;
1744 }
1745 match c {
1746 '"' => in_str = true,
1747 '(' | '[' | '{' => depth += 1,
1748 ')' | ']' | '}' => {
1749 depth -= 1;
1750 if depth == 0 {
1751 let span: String = chars[from..=i].iter().collect();
1752 return Some((span, i + 1));
1753 }
1754 },
1755 _ => {},
1756 }
1757 }
1758 None
1759}
1760
1761fn is_ident_char(c: char) -> bool {
1762 c.is_alphanumeric() || c == '-' || c == '_'
1763}
1764
1765/// Lowers a furniture definition's parameter list and body expression to a [`TemplateFn`], resolving every
1766/// `em` length against `body_size`. The recognised body is a single `block(...)`/`box(...)` wrap (`pr-note`)
1767/// or a `{ ... }` block whose first `box(...)`/`block(...)` is that wrap (`aside-box`, which then re-wraps
1768/// it in a `figure(placement: auto)` this reader lowers in-flow). The named arguments read are `inset`
1769/// (scalar or a `(left:, right:, x:, y:, top:, bottom:)` dict), `above`/`below` (block margins folded into
1770/// the top/bottom pads), `fill`, `radius` and `stroke: (left: <w> + <colour>)`; the positional content
1771/// block's inner `set text(size:)` / `set par(spacing:, first-line-indent:)` become the body overlay.
1772/// `None` when the body is neither wrap, or a length will not resolve -- the call then stays a tallied skip.
1773fn lower_template_fn(params: &str, expr: &str, body_size: Sp, palette: &Palette) -> Option<TemplateFn> {
1774 let body_param = body_param_name(params)?;
1775 let has_title = param_names(params).iter().any(|p| p == "title");
1776
1777 // The float wrapper: a body re-wrapped in `figure(placement: ...)` (the `#aside-box(float: true)` idiom,
1778 // `if float { figure(placement: auto, inner) } else { inner }`) is a float the driver defers, not a keep
1779 // box set in the flow. The placement is read from the `figure(...)` call; a body that never wraps in a
1780 // figure is not a float.
1781 let float = figure_placement_in(expr);
1782
1783 // The wrap call: the definition's own `block(...)`/`box(...)`, taken directly when the body is that call,
1784 // or found as the first such call inside a `{ ... }` body (the `let inner = box(...)` idiom). A `box` and
1785 // a `block` lower alike -- both wrap the body in a padded frame.
1786 let wrap = wrap_call(expr)?;
1787 let args = wrap_args(&wrap)?;
1788
1789 let mut patch = ThemePatch::default();
1790
1791 // The fill: a resolved colour washes the frame; an absent (or unresolved) fill leaves it transparent, so
1792 // a plain indented block draws no panel. `box` and `block` alike carry a fill only when the source names one.
1793 let fill = match named_value(&args, "fill") {
1794 Some(fv) => parse_colour_pal(&fv, palette).unwrap_or(Rgba::TRANSPARENT),
1795 None => Rgba::TRANSPARENT,
1796 };
1797 patch.callout.fill = Some(fill);
1798
1799 // A `stroke: (left: <w> + <colour>)` -- the aside-box left rule. The width and colour are read from the
1800 // dict's `left:` entry; a stroke this reader cannot resolve leaves both unset (no rule drawn).
1801 if let Some(sv) = named_value(&args, "stroke") {
1802 if let Some((w, col)) = read_left_stroke(&sv, body_size, palette) {
1803 patch.callout.stroke_left_w = Some(w);
1804 patch.callout.stroke_left_col = Some(col);
1805 }
1806 }
1807
1808 // The inset: a scalar pads every side, a dict names `left`/`right`/`x`/`y`/`top`/`bottom`. `x` sets both
1809 // horizontal pads, `y` both vertical; a side-specific key overrides.
1810 if let Some(iv) = named_value(&args, "inset") {
1811 let pads = read_inset_pads(&iv, body_size)?;
1812 patch.callout.inset_left = pads.left;
1813 patch.callout.inset_right = pads.right;
1814 patch.callout.inset_top = pads.top;
1815 patch.callout.inset_bot = pads.bottom;
1816 }
1817 // The block margins `above`/`below` fold into the top/bottom pads: with a transparent wash they read as
1818 // the block's own leading/trailing space, an honest first cut of Typst's block spacing model.
1819 if let Some(av) = named_value(&args, "above") {
1820 patch.callout.inset_top = Some(resolve_len(&av, body_size)?);
1821 }
1822 if let Some(bv) = named_value(&args, "below") {
1823 patch.callout.inset_bot = Some(resolve_len(&bv, body_size)?);
1824 }
1825 if let Some(rv) = named_value(&args, "radius") {
1826 patch.callout.radius = Some(resolve_len(&rv, body_size)?);
1827 }
1828
1829 // The positional content block: its inner `set text`/`set par` overlay the body, and it must reference
1830 // the body parameter (the hole). A definition whose content never names the body is not a furniture wrap.
1831 let (_delim, content) = positional_content(&args)?;
1832 if !mentions_word(&content, &body_param) {
1833 return None;
1834 }
1835 // The body's own text size may be set two ways: an inner `set text(size:)` (pr-note's `{ ... }` block) or
1836 // a `text(size: <x>)[#body]` wrapper around the body (aside-box). Both are read, resolving `em` in the
1837 // inner-set chain against the size a preceding `set text` already fixed (so a `par(spacing: 0.55em)` after
1838 // `text(size: 0.88em)` tracks Typst, which resolves the em against the reduced size, not the outer one).
1839 read_inner_sets_em(&content, body_size, &mut patch);
1840 if patch.text.body_size.is_none() {
1841 if let Some(sz) = body_wrapper_size(&content, &body_param, body_size) {
1842 patch.text.body_size = Some(sz);
1843 }
1844 }
1845
1846 // The title run's size (`text(size: 0.85em)[#title]`), read from the content so a title paragraph is set
1847 // at the same size the body is.
1848 let title_size = if has_title {
1849 title_text_size(&content, body_size)
1850 } else {
1851 None
1852 };
1853
1854 Some(TemplateFn { body_param, has_title, title_size, patch, float })
1855}
1856
1857/// Reads the placement of a `figure(placement: <p>, ...)` wrapper in a furniture definition's body, or
1858/// `None` when the body wraps its content in no figure. `auto`/`top` float to the top, `bottom` to the
1859/// foot -- the same mapping the `#figure` reader uses.
1860fn figure_placement_in(expr: &str) -> Option<FloatPlacement> {
1861 let at = expr.find("figure(")?;
1862 let rest = &expr[at + "figure(".len()..];
1863 let key = rest.find("placement:")?;
1864 let after = rest[key + "placement:".len()..].trim_start();
1865 // The value runs to the next comma or the close of the call.
1866 let end = after.find(|c| c == ',' || c == ')').unwrap_or(after.len());
1867 match after[..end].trim() {
1868 "auto" => Some(FloatPlacement::Auto),
1869 "top" => Some(FloatPlacement::Top),
1870 "bottom" => Some(FloatPlacement::Bottom),
1871 _ => None,
1872 }
1873}
1874
1875/// Reads a `stroke: (left: <w> + <colour>)` dict into a width and colour, resolving `em` against `body_size`
1876/// and a `colours.<name>` against `palette`. Typst's `2pt + colours.yellow.darken(20%)` is a stroke whose
1877/// thickness is the length term and whose paint is the colour term. `None` when there is no `left:` entry or
1878/// its width/colour will not resolve, so no rule is drawn rather than a wrong one.
1879fn read_left_stroke(raw: &str, body_size: Sp, palette: &Palette) -> Option<(Sp, Rgba)> {
1880 let raw = raw.trim();
1881 // A dict `(left: ...)`, or a bare stroke applied to every side -- take the `left:` entry, else the whole.
1882 let spec = if raw.starts_with('(') {
1883 let inner = call_group(raw)?;
1884 named_value(&inner, "left")?
1885 } else {
1886 raw.to_string()
1887 };
1888 // The spec is `<length> + <colour>` (either order): the term that resolves to a length is the width, the
1889 // term that resolves to a colour is the paint.
1890 let mut width: Option<Sp> = None;
1891 let mut colour: Option<Rgba> = None;
1892 for term in spec.split('+') {
1893 let t = term.trim();
1894 if let Some(sp) = resolve_len(t, body_size) {
1895 width = Some(sp);
1896 } else if let Some(c) = parse_colour_pal(t, palette) {
1897 colour = Some(c);
1898 }
1899 }
1900 match (width, colour) {
1901 (Some(w), Some(c)) => Some((w, c)),
1902 _ => None,
1903 }
1904}
1905
1906/// The size of a `text(size: <len>)[ ... body ... ]` wrapper around the body parameter -- the body text size
1907/// when it is set by wrapping rather than by an inner `#set text` (aside-box's `text(size: 0.85em)[#body]`).
1908/// `None` when no `text(...)` call whose content names the body carries a size.
1909fn body_wrapper_size(content: &str, body_param: &str, body_size: Sp) -> Option<Sp> {
1910 let chars: Vec<char> = content.chars().collect();
1911 let mut from = 0usize;
1912 while let Some(at) = find_call(&chars[from..], "text").map(|i| from + i) {
1913 let (call, next) = match read_balanced_from(&chars, at) {
1914 Some(r) => r,
1915 None => break,
1916 };
1917 // The `[ ... ]` content block follows the `( ... )` args (Typst's `text(...)[...]`): read it and test
1918 // whether it places the body parameter.
1919 let mut k = next;
1920 while k < chars.len() && chars[k].is_whitespace() {
1921 k += 1;
1922 }
1923 let places_body = chars.get(k) == Some(&'[')
1924 && read_delim_group(&chars, k)
1925 .map(|(inner, _)| mentions_word(&inner, body_param))
1926 .unwrap_or(false);
1927 if places_body {
1928 if let Some(a) = wrap_args(&call) {
1929 if let Some(v) = named_value(&a, "size") {
1930 if let Some(sp) = resolve_len(&v, body_size) {
1931 return Some(sp);
1932 }
1933 }
1934 }
1935 }
1936 from = next;
1937 }
1938 None
1939}
1940
1941/// The body parameter's name: the last positional (unnamed) parameter in the list, which is the content
1942/// the call supplies. A named parameter (`title: none`, `float: true`) is a keyword the call may set, not
1943/// the content hole. `None` when the list names no positional parameter.
1944fn body_param_name(params: &str) -> Option<String> {
1945 split_top_commas_str(params).into_iter().rev().find_map(|p| {
1946 let p = p.trim();
1947 if p.is_empty() || p.contains(':') {
1948 None
1949 } else if p.chars().all(is_ident_char) {
1950 Some(p.to_string())
1951 } else {
1952 None
1953 }
1954 })
1955}
1956
1957/// Every parameter's name (the identifier before any `:` default), for detecting a `title:` keyword.
1958fn param_names(params: &str) -> Vec<String> {
1959 split_top_commas_str(params).into_iter().filter_map(|p| {
1960 let name = p.split(':').next().unwrap_or("").trim();
1961 if !name.is_empty() && name.chars().all(is_ident_char) {
1962 Some(name.to_string())
1963 } else {
1964 None
1965 }
1966 }).collect()
1967}
1968
1969/// The `block(...)`/`box(...)` wrap call of a furniture body: the whole expression when it is that call, or
1970/// the first such call inside a `{ ... }` body. `None` when neither is present.
1971fn wrap_call(expr: &str) -> Option<String> {
1972 let e = expr.trim();
1973 if e.starts_with("block") || e.starts_with("box") {
1974 return Some(e.to_string());
1975 }
1976 // A `{ ... }` body (the `let inner = box(...)` idiom): find the first `block(`/`box(` call within.
1977 let chars: Vec<char> = e.chars().collect();
1978 for name in ["box", "block"] {
1979 if let Some(at) = find_call(&chars, name) {
1980 if let Some((span, _)) = read_balanced_from(&chars, at) {
1981 return Some(span);
1982 }
1983 }
1984 }
1985 None
1986}
1987
1988/// The argument text of a `name( ... )` wrap call, without the enclosing parentheses.
1989fn wrap_args(wrap: &str) -> Option<String> {
1990 let chars: Vec<char> = wrap.chars().collect();
1991 let open = chars.iter().position(|&c| c == '(')?;
1992 read_paren_group(&chars, open).map(|(inner, _)| inner)
1993}
1994
1995/// The index of a `name(` call in `chars`, at a word boundary so `box` is not found inside a longer word.
1996fn find_call(chars: &[char], name: &str) -> Option<usize> {
1997 let pat: Vec<char> = name.chars().collect();
1998 let n = pat.len();
1999 let mut i = 0usize;
2000 while i + n < chars.len() {
2001 if chars[i..i + n] == pat[..]
2002 && (i == 0 || !is_ident_char(chars[i - 1]))
2003 && chars.get(i + n) == Some(&'(')
2004 {
2005 return Some(i);
2006 }
2007 i += 1;
2008 }
2009 None
2010}
2011
2012/// The four inset pads a furniture block names.
2013struct InsetPads {
2014 left: Option<Sp>,
2015 right: Option<Sp>,
2016 top: Option<Sp>,
2017 bottom: Option<Sp>,
2018}
2019
2020/// Reads a furniture `inset:` value into its four pads, resolving `em` against `body_size`. A scalar
2021/// (`inset: 8pt`) pads every side; a dict (`inset: (x: 1em, y: 1em, bottom: 1.2em)` or
2022/// `(left: 1.2em, right: 0.6em)`) names sides -- `x` both horizontal, `y` both vertical, then a
2023/// side-specific key overrides. `None` when a named length will not resolve, so the call stays a skip.
2024fn read_inset_pads(raw: &str, body_size: Sp) -> Option<InsetPads> {
2025 let raw = raw.trim();
2026 let mut pads = InsetPads { left: None, right: None, top: None, bottom: None };
2027 if raw.starts_with('(') {
2028 let inner = call_group(raw)?;
2029 if let Some(v) = named_value(&inner, "x") {
2030 let sp = resolve_len(&v, body_size)?;
2031 pads.left = Some(sp);
2032 pads.right = Some(sp);
2033 }
2034 if let Some(v) = named_value(&inner, "y") {
2035 let sp = resolve_len(&v, body_size)?;
2036 pads.top = Some(sp);
2037 pads.bottom = Some(sp);
2038 }
2039 if let Some(v) = named_value(&inner, "left") {
2040 pads.left = Some(resolve_len(&v, body_size)?);
2041 }
2042 if let Some(v) = named_value(&inner, "right") {
2043 pads.right = Some(resolve_len(&v, body_size)?);
2044 }
2045 if let Some(v) = named_value(&inner, "top") {
2046 pads.top = Some(resolve_len(&v, body_size)?);
2047 }
2048 if let Some(v) = named_value(&inner, "bottom") {
2049 pads.bottom = Some(resolve_len(&v, body_size)?);
2050 }
2051 Some(pads)
2052 } else {
2053 let sp = resolve_len(raw, body_size)?;
2054 Some(InsetPads { left: Some(sp), right: Some(sp), top: Some(sp), bottom: Some(sp) })
2055 }
2056}
2057
2058/// The first top-level `{ ... }` or `[ ... ]` content group in a wrap's argument list, as its delimiter and
2059/// inner text -- the block the body parameter sits in. `None` when the call carries no positional content.
2060fn positional_content(args: &str) -> Option<(char, String)> {
2061 let chars: Vec<char> = args.chars().collect();
2062 let mut depth = 0i32;
2063 let mut in_str = false;
2064 let mut esc = false;
2065 let mut i = 0usize;
2066 while i < chars.len() {
2067 let c = chars[i];
2068 if in_str {
2069 if esc { esc = false; }
2070 else if c == '\\' { esc = true; }
2071 else if c == '"' { in_str = false; }
2072 i += 1;
2073 continue;
2074 }
2075 match c {
2076 '"' => in_str = true,
2077 '(' => depth += 1,
2078 ')' => depth -= 1,
2079 '{' | '[' if depth == 0 => {
2080 if let Some((span, _)) = read_delim_group(&chars, i) {
2081 return Some((c, span));
2082 }
2083 return None;
2084 },
2085 '{' | '[' => depth += 1,
2086 '}' | ']' => depth -= 1,
2087 _ => {},
2088 }
2089 i += 1;
2090 }
2091 None
2092}
2093
2094/// Reads a content block's inner `set text(size:)` and `set par(spacing:, first-line-indent:)` into the
2095/// hole overlay, resolving `em` against `body_size`. A `#`-prefixed `#set` (an `[ ... ]` content block) and
2096/// a bare `set` (a `{ ... }` code block) are both read.
2097fn read_inner_sets_em(content: &str, body_size: Sp, patch: &mut ThemePatch) {
2098 // The text size in force as the statements are read in order. Typst resolves an `em` against the running
2099 // font size, so a `set par(spacing: 0.55em)` AFTER a `set text(size: 0.88em)` resolves its em against the
2100 // reduced 0.88em size, not the outer body -- tracking that is what keeps the inter-paragraph spacing tight.
2101 let mut cur_size = body_size;
2102 for stmt in split_statements(content) {
2103 let s = stmt.trim().trim_start_matches('#').trim();
2104 let after = match s.strip_prefix("set ") {
2105 Some(a) => a.trim(),
2106 None => continue,
2107 };
2108 let open = match after.find('(') {
2109 Some(i) => i,
2110 None => continue,
2111 };
2112 let target = after[..open].trim();
2113 let cargs = match call_group(&after[open..]) {
2114 Some(a) => a,
2115 None => continue,
2116 };
2117 match target {
2118 "text" => {
2119 // `set text(size: 0.88em)`: the em resolves against the size before this set (the running
2120 // `cur_size`), and the result becomes the running size for every em that follows.
2121 if let Some(v) = named_value(&cargs, "size") {
2122 if let Some(sp) = resolve_len(&v, cur_size) {
2123 patch.text.body_size = Some(sp);
2124 cur_size = sp;
2125 }
2126 }
2127 },
2128 "par" => {
2129 if let Some(v) = named_value(&cargs, "spacing") {
2130 if let Some(sp) = resolve_len(&v, cur_size) {
2131 patch.par.skip = Some(sp);
2132 }
2133 }
2134 if let Some(v) = named_value(&cargs, "first-line-indent") {
2135 if let Some(sp) = resolve_len(&v, cur_size) {
2136 patch.par.indent = Some(sp);
2137 }
2138 }
2139 if let Some(v) = named_value(&cargs, "leading") {
2140 if let Some(sp) = resolve_len(&v, cur_size) {
2141 patch.text.leading = Some(sp);
2142 }
2143 }
2144 },
2145 _ => {},
2146 }
2147 }
2148}
2149
2150/// The size of a `text(size: <len>)[#title]` run inside a furniture's content -- the size a leading title
2151/// paragraph is set at. `None` when no such run names a size.
2152fn title_text_size(content: &str, body_size: Sp) -> Option<Sp> {
2153 // The title run carries `weight: "bold"`, so match the first `text(...)` naming a bold weight and a size.
2154 let chars: Vec<char> = content.chars().collect();
2155 let mut from = 0usize;
2156 while let Some(at) = find_call(&chars[from..], "text").map(|i| from + i) {
2157 if let Some((_, next)) = read_balanced_from(&chars, at) {
2158 let call: String = chars[at..next].iter().collect();
2159 if let Some(a) = wrap_args(&call) {
2160 if a.contains("bold") {
2161 if let Some(v) = named_value(&a, "size") {
2162 if let Some(sp) = resolve_len(&v, body_size) {
2163 return Some(sp);
2164 }
2165 }
2166 }
2167 }
2168 from = next;
2169 } else {
2170 break;
2171 }
2172 }
2173 None
2174}
2175
2176/// A length token to scaled points, resolving `em` against `body_size` (an em is that fraction of the
2177/// running text size). Accepts `em`, and every absolute unit [`length_pt`] reads (`pt`/`mm`/`cm`/`in`/bare).
2178/// `None` for a `%` or an unrecognised unit, so a caller refuses rather than sizing wrongly.
2179fn resolve_len(v: &str, body_size: Sp) -> Option<Sp> {
2180 let v = v.trim();
2181 if let Some(num) = v.strip_suffix("em") {
2182 let n: f64 = num.trim().parse().ok()?;
2183 return Some(Sp::from_pt(n * body_size.to_pt()));
2184 }
2185 length_pt(v).map(Sp::from_pt)
2186}
2187
2188/// Splits a parameter or dict text on top-level commas (outside any `(...)`/`[...]`/`{...}`/`"..."`).
2189fn split_top_commas_str(s: &str) -> Vec<String> {
2190 let mut out = Vec::new();
2191 let mut depth = 0i32;
2192 let mut in_str = false;
2193 let mut esc = false;
2194 let mut cur = String::new();
2195 for c in s.chars() {
2196 if in_str {
2197 cur.push(c);
2198 if esc { esc = false; }
2199 else if c == '\\' { esc = true; }
2200 else if c == '"' { in_str = false; }
2201 continue;
2202 }
2203 match c {
2204 '"' => { in_str = true; cur.push(c); },
2205 '(' | '[' | '{' => { depth += 1; cur.push(c); },
2206 ')' | ']' | '}' => { depth -= 1; cur.push(c); },
2207 ',' if depth == 0 => out.push(std::mem::take(&mut cur)),
2208 _ => cur.push(c),
2209 }
2210 }
2211 if !cur.trim().is_empty() {
2212 out.push(cur);
2213 }
2214 out
2215}
2216
2217// ┌───────────────────────────────────────────────────────────────────────────┐
2218// │ MATCHING │
2219// └───────────────────────────────────────────────────────────────────────────┘
2220
2221/// Does `selector` match `block`? The kind must answer to the block's variant and every predicate must
2222/// hold. A predicate a block has no field for (a `block:` on a heading) never matches, so a mis-targeted
2223/// rule styles nothing rather than everything.
2224pub fn matches(selector: &Selector, block: &Block) -> bool {
2225 let kind_ok = match (selector.kind, block) {
2226 (ElementKind::Heading, Block::Heading { .. }) => true,
2227 (ElementKind::Figure, Block::Figure { .. })
2228 | (ElementKind::Figure, Block::TableFigure { .. })
2229 | (ElementKind::Figure, Block::ImageFigure { .. })
2230 | (ElementKind::Figure, Block::CodeFigure { .. }) => true,
2231 (ElementKind::FigureCaption, Block::Figure { .. })
2232 | (ElementKind::FigureCaption, Block::TableFigure { .. })
2233 | (ElementKind::FigureCaption, Block::ImageFigure { .. })
2234 | (ElementKind::FigureCaption, Block::CodeFigure { .. }) => true,
2235 (ElementKind::Raw, Block::Code { .. }) => true,
2236 (ElementKind::Equation, Block::Equation { .. }) => true,
2237 (ElementKind::Paragraph, Block::Paragraph { .. })
2238 | (ElementKind::Paragraph, Block::RichParagraph { .. }) => true,
2239 (ElementKind::List, Block::List { .. }) => true,
2240 (ElementKind::Table, Block::Table(_)) => true,
2241 // A `link` selector is an inline element, carried in no block of its own, so it matches no block
2242 // here and its rule styles nothing this part -- an inline-rule part's work.
2243 _ => false,
2244 };
2245 if !kind_ok {
2246 return false;
2247 }
2248 selector.predicates.iter().all(|p| predicate_holds(p, block))
2249}
2250
2251/// Does one field predicate hold for `block`?
2252fn predicate_holds(p: &FieldPredicate, block: &Block) -> bool {
2253 match (p, block) {
2254 (FieldPredicate::Level(n), Block::Heading { level, .. }) => level == n,
2255 // A verbatim code block is always block-level in Austenite (no inline `raw` reaches the block layer),
2256 // so `raw.where(block: true)` matches it and `block: false` never does.
2257 (FieldPredicate::Block(b), Block::Code { .. }) => *b,
2258 _ => false,
2259 }
2260}
2261
2262// ┌───────────────────────────────────────────────────────────────────────────┐
2263// │ APPLYING RULES │
2264// └───────────────────────────────────────────────────────────────────────────┘
2265
2266/// Applies `rules` over the block tree, wrapping each block a rule matches in a [`Block::Scoped`] carrying
2267/// the rule's set-fields patch -- the general mechanism a scoped `#set` already uses, so the styling is
2268/// confined to the matched element and every document-order pass scopes it for free. Rules are tried in
2269/// order and a block matched by several is wrapped in nested scopes, the last rule innermost so it wins;
2270/// a [`Transform::Refused`] never wraps (it was recorded at collection). An empty patch never wraps, so a
2271/// rule that changes nothing leaves the block untouched and the render byte-identical.
2272///
2273/// Recurses into existing [`Block::Scoped`] and [`Block::Box`] subtrees first, so a rule reaches an
2274/// element already inside a scope (an included chapter's own) as well as one at top level.
2275///
2276/// `avail` is the content width in force at placement (`geom.content_width()`), threaded so a template's
2277/// relative divider (a `line(length: 100%)`) resolves to an absolute width against the measure it will set
2278/// at, rather than being left to guess one.
2279pub fn apply_rules(blocks: &mut Vec<Block>, rules: &[Rule], avail: Sp) {
2280 // Descend into existing nesting subtrees first, so their own elements are matched too.
2281 for b in blocks.iter_mut() {
2282 if let Block::Scoped { blocks: inner, .. } | Block::Box { blocks: inner, .. } | Block::Place { blocks: inner, .. } = b {
2283 apply_rules(inner, rules, avail);
2284 }
2285 }
2286 // Then wrap each block this slice holds that a rule matches.
2287 let taken = std::mem::take(blocks);
2288 let mut out = Vec::with_capacity(taken.len());
2289 for block in taken {
2290 out.push(wrap_matching(block, rules, avail));
2291 }
2292 *blocks = out;
2293}
2294
2295/// Wraps `block` in one [`Block::Scoped`] per set-fields rule that matches it, the last matching rule
2296/// innermost so it overrides the earlier ones; a rule whose patch is empty adds no scope. A matching
2297/// template then restructures the (possibly already-scoped) block into its `Scoped{hole, [pre.., it, post..]}`
2298/// shape, the last matching template winning. A block no rule matches is returned unchanged.
2299fn wrap_matching(block: Block, rules: &[Rule], avail: Sp) -> Block {
2300 // The matching set-fields patches in order, so the fold wraps them last-innermost below.
2301 let mut patches: Vec<&ThemePatch> = Vec::new();
2302 // The last matching template, applied outermost of the set-fields scopes since it restructures the block.
2303 let mut template: Option<&Template> = None;
2304 for rule in rules {
2305 match &rule.transform {
2306 Transform::SetFields(p) => {
2307 if *p != ThemePatch::default() && matches(&rule.selector, &block) {
2308 patches.push(p);
2309 }
2310 },
2311 Transform::Template(t) => {
2312 if matches(&rule.selector, &block) {
2313 template = Some(t);
2314 }
2315 },
2316 Transform::Refused(_) => {},
2317 }
2318 }
2319 let mut wrapped = block;
2320 for p in patches.into_iter().rev() {
2321 wrapped = Block::Scoped { patch: p.clone(), blocks: vec![wrapped] };
2322 }
2323 if let Some(t) = template {
2324 wrapped = materialise_template(t, wrapped, avail);
2325 }
2326 wrapped
2327}
2328
2329/// Materialises a template around the matched element: the element is *moved* into the hole between the
2330/// template's `pre` and `post` siblings (wrapped in a washed [`Block::Box`] when the template frames it), and
2331/// the whole sequence is overlaid with the hole patch through a [`Block::Scoped`]. A relative divider width
2332/// in a sibling resolves to an absolute against `avail` here, at the placement measure.
2333fn materialise_template(t: &Template, it: Block, avail: Sp) -> Block {
2334 let hole_block = match t.frame {
2335 Some(tf) => {
2336 // The element seated in a box washed the template's fill, plus whichever of inset/radius the
2337 // rule named -- the renderer reads all five from `callout.*` on the box's own scoped theme,
2338 // falling back to its own constants for whatever the rule left `None`.
2339 let mut patch = ThemePatch::default();
2340 patch.callout.fill = Some(tf.fill);
2341 patch.callout.inset_x = tf.inset_x;
2342 patch.callout.inset_top = tf.inset_top;
2343 patch.callout.inset_bot = tf.inset_bot;
2344 patch.callout.radius = tf.radius;
2345 Block::Box { blocks: vec![it], patch, placement: None }
2346 },
2347 None => it,
2348 };
2349 let mut seq = Vec::with_capacity(t.pre.len() + 1 + t.post.len());
2350 for b in &t.pre {
2351 seq.push(resolve_avail(b.clone(), avail));
2352 }
2353 seq.push(hole_block);
2354 for b in &t.post {
2355 seq.push(resolve_avail(b.clone(), avail));
2356 }
2357 Block::Scoped { patch: t.hole.clone(), blocks: seq }
2358}
2359
2360/// Resolves a template sibling's relative width against the placement `avail`: a `Block::Rule` whose width is
2361/// a fraction ([`Length::Rel`], from a `line(length: 100%)`) becomes an absolute ([`Length::Abs`]) at that
2362/// measure, so its extent is fixed where it will set rather than left relative. Every other block is unchanged.
2363fn resolve_avail(block: Block, avail: Sp) -> Block {
2364 match block {
2365 Block::Rule { width: Length::Rel(f), thickness, grey } =>
2366 Block::Rule { width: Length::Abs(avail.to_pt() * f), thickness, grey },
2367 other => other,
2368 }
2369}
2370
2371/// The default rule set followed by a source's own rules, ready to apply. The default set uses `theme`
2372/// (the document theme in force) to re-assert each heading level's own size; the source's rules are
2373/// appended after, so an authored rule overrides the default for the elements it matches.
2374pub fn rule_set_for(theme: &Theme, src: &str, refusals: &mut Refusals) -> Vec<Rule> {
2375 let mut rules = default_rule_set(theme);
2376 let own = collect_from_source(src, rules.len(), refusals);
2377 rules.extend(own);
2378 rules
2379}
2380
2381#[cfg(test)]
2382mod tests {
2383 use super::*;
2384 use crate::doc::Segment;
2385
2386 fn heading(level: u8) -> Block {
2387 Block::Heading { level, segments: vec![Segment::text("H")], label: None }
2388 }
2389
2390 /// A `heading.where(level: 1)` selector parses to the heading kind with a single level predicate, and
2391 /// matches a level-1 heading but not a level-2 one.
2392 #[test]
2393 fn heading_where_level_parses_and_matches() {
2394 let sel = parse_selector("heading.where(level: 1)").expect("selector parses");
2395 assert_eq!(sel.kind, ElementKind::Heading);
2396 assert_eq!(sel.predicates, vec![FieldPredicate::Level(1)]);
2397 assert!(matches(&sel, &heading(1)));
2398 assert!(!matches(&sel, &heading(2)));
2399 }
2400
2401 /// The other selector forms the grammar reads parse to their kinds: a figure caption, a block raw, a
2402 /// bare link, and a math equation.
2403 #[test]
2404 fn selector_forms_parse() {
2405 assert_eq!(parse_selector("figure.caption").unwrap().kind, ElementKind::FigureCaption);
2406 assert_eq!(parse_selector("link").unwrap().kind, ElementKind::Link);
2407 let raw = parse_selector("raw.where(block: true)").unwrap();
2408 assert_eq!(raw.kind, ElementKind::Raw);
2409 assert_eq!(raw.predicates, vec![FieldPredicate::Block(true)]);
2410 assert!(parse_selector("nonesuch").is_none());
2411 }
2412
2413 /// A `set text(size: 30pt)` transform lowers to a set-fields patch. Under a heading selector the size is
2414 /// redirected into the matched level's own heading size -- the field the renderer reads for a heading --
2415 /// and does not touch `text.body_size`; under a body selector it stays `text.body_size`. A page-reading
2416 /// transform, a `text` field a heading never renders, and a wrap transform are all refused.
2417 #[test]
2418 fn transform_lowers_or_refuses() {
2419 let h1 = Selector { kind: ElementKind::Heading, predicates: vec![FieldPredicate::Level(1)] };
2420 let par = Selector { kind: ElementKind::Paragraph, predicates: vec![] };
2421 // Under a heading selector, the text size redirects into the matched level's heading size.
2422 match lower_transform(&h1, "set text(size: 30pt)") {
2423 Transform::SetFields(p) => {
2424 assert_eq!(p.heading.levels.first().and_then(|l| l.size), Some(crate::ir::Sp::from_pt(30.0)));
2425 assert!(p.text.body_size.is_none(), "a heading size rule must not write text.body_size");
2426 },
2427 other => panic!("a heading size rule should lower to set-fields, got {:?}", other),
2428 }
2429 // Under a body selector, the same set stays a body-size patch.
2430 match lower_transform(&par, "set text(size: 30pt)") {
2431 Transform::SetFields(p) => assert_eq!(p.text.body_size, Some(crate::ir::Sp::from_pt(30.0))),
2432 other => panic!("a body size rule should lower to set-fields, got {:?}", other),
2433 }
2434 assert!(matches!(lower_transform(&h1, "it => context measure(it)"), Transform::Refused(_)),
2435 "a page-reading transform must be refused");
2436 // A `text` field a heading never renders is refused under a heading selector.
2437 assert!(matches!(lower_transform(&h1, "set text(tracking: 0.1em)"), Transform::Refused(_)),
2438 "an unread field must be refused");
2439 assert!(matches!(lower_transform(&h1, "it => underline(it)"), Transform::Refused(_)),
2440 "a wrap transform is a later part, refused here");
2441 }
2442
2443 /// `collect_from_source` reads a per-element `#show` rule and leaves the whole-document `#show:`
2444 /// application alone.
2445 #[test]
2446 fn collect_reads_show_rules_only() {
2447 let src = "#show: doc.with(title: [X])\n#show heading.where(level: 1): set text(size: 30pt)\n";
2448 let mut refusals = Refusals::default();
2449 let rules = collect_from_source(src, 0, &mut refusals);
2450 assert_eq!(rules.len(), 1, "only the per-element rule is a rule; #show: is the doc application");
2451 assert_eq!(rules[0].selector.kind, ElementKind::Heading);
2452 assert!(matches!(rules[0].transform, Transform::SetFields(_)));
2453 }
2454
2455 /// A page-reading rule is collected as a refusal, not applied, and its reason recorded.
2456 #[test]
2457 fn page_reading_rule_is_refused() {
2458 let src = "#show heading: it => context counter.at(here())\n";
2459 let mut refusals = Refusals::default();
2460 let rules = collect_from_source(src, 0, &mut refusals);
2461 assert_eq!(rules.len(), 1);
2462 assert!(matches!(rules[0].transform, Transform::Refused(_)));
2463 assert!(!refusals.is_empty(), "the refusal is recorded for the report");
2464 }
2465
2466 /// A `#show heading.where(level: 1): set text(size: 30pt)` rule actually resizes the level-1 heading's
2467 /// rendered glyphs and nothing else: the rendered level-1 heading grows to exactly the size a theme that
2468 /// set level-1 to 30pt directly produces (positive), while the level-2 and level-3 headings and the body
2469 /// text set beside the resized heading keep their sizes (negative). Rendered through `author`, so the
2470 /// assertion is on the shaped output, not on the patch -- without the heading-size redirect this fails,
2471 /// since a `text.body_size` patch never reaches a heading's `heading_size(level)` glyph size.
2472 #[test]
2473 fn heading_size_rule_resizes_only_the_matched_level() -> Outcome<()> {
2474 use std::sync::Arc;
2475 use crate::doc::{author, HeadingStyle};
2476 use crate::ir::{Node, Sp};
2477
2478 let fonts = Arc::new(res!(crate::fonts::libertinus()));
2479 let geom = crate::page::PageGeometry::a4();
2480 let faces = crate::fonts::FaceResolver::default();
2481
2482 // DocInline sets every level as an inline sub-heading (no chapter-opener page), so each heading's
2483 // glyph size reads `heading_size(level)` through `subheading_hbox` -- the arm the rule must reach.
2484 let mut theme = Theme::default();
2485 theme.heading.kind = HeadingStyle::DocInline;
2486
2487 let blocks = || vec![
2488 heading(1),
2489 Block::Paragraph { text: "Body after one.".to_string() },
2490 heading(2),
2491 Block::Paragraph { text: "Body after two.".to_string() },
2492 heading(3),
2493 Block::Paragraph { text: "Body after three.".to_string() },
2494 ];
2495
2496 // One render's heading keep boxes, as `(heading-line height, joined body-line height)` pairs in
2497 // document order: the first HBox inside each heading VBox is the shaped heading line (its height
2498 // scaling with the glyph size), the second is the following paragraph's first line, pulled into the
2499 // heading's keep box -- so a body line set right beside the resized heading is measured too.
2500 let render = |base: &Theme, rules_src: &str| -> Outcome<Vec<(i32, Option<i32>)>> {
2501 let mut refusals = Refusals::default();
2502 let rules = rule_set_for(base, rules_src, &mut refusals);
2503 let mut bs = blocks();
2504 apply_rules(&mut bs, &rules, geom.content_width());
2505 let (doc, _) = res!(author(fonts.clone(), geom, base, &faces, &bs, None, None));
2506 let mut out = Vec::new();
2507 for n in &doc.nodes {
2508 if let Node::VBox(b) = n {
2509 let hs: Vec<i32> = b.list.iter().filter_map(|c| match c {
2510 Node::HBox(h) => Some(h.dims.height.raw()),
2511 _ => None,
2512 }).collect();
2513 if let Some(&first) = hs.first() {
2514 out.push((first, hs.get(1).copied()));
2515 }
2516 }
2517 }
2518 Ok(out)
2519 };
2520
2521 let base = res!(render(&theme, ""));
2522 let ruled = res!(render(&theme, "#show heading.where(level: 1): set text(size: 30pt)\n"));
2523 // The independent oracle: a theme that sets level-1's size to 30pt directly, no authored rule.
2524 let mut theme30 = theme.clone();
2525 theme30.heading.levels[0].size = Sp::from_pt(30.0);
2526 let direct = res!(render(&theme30, ""));
2527
2528 assert_eq!(base.len(), 3, "three headings render");
2529 assert_eq!(ruled.len(), 3);
2530 assert_eq!(direct.len(), 3);
2531
2532 // Positive: the rule enlarges the level-1 heading, to exactly the size a direct 30pt theme sets.
2533 assert!(ruled[0].0 > base[0].0, "the level-1 heading must grow under the 30pt rule");
2534 assert_eq!(ruled[0].0, direct[0].0,
2535 "the rule must resize the level-1 heading to the same glyphs as a direct 30pt theme");
2536
2537 // Negative: levels 2 and 3 keep their sizes; a level-1 rule touches no other level.
2538 assert_eq!(ruled[1].0, base[1].0, "the level-2 heading must be unchanged");
2539 assert_eq!(ruled[2].0, base[2].0, "the level-3 heading must be unchanged");
2540
2541 // Negative: the body lines -- including the one pulled into the resized level-1 heading's keep box --
2542 // keep the body size; the heading rule must not bleed into running text.
2543 assert_eq!(ruled.iter().map(|p| p.1).collect::<Vec<_>>(),
2544 base.iter().map(|p| p.1).collect::<Vec<_>>(),
2545 "the body text beside every heading must keep its size");
2546 Ok(())
2547 }
2548
2549 /// An authored rule changes only the element it targets, at render time: a level-1 heading numbering
2550 /// rule renumbers the level-1 heading while a level-2 heading, outside the rule's scope, keeps the
2551 /// default dotted number. (A heading *size* rule likewise reaches only its target's glyphs, tested
2552 /// through the rendered output in `heading_size_rule_resizes_only_the_matched_level`.)
2553 #[test]
2554 fn authored_rule_renumbers_only_its_target() -> Outcome<()> {
2555 use std::sync::Arc;
2556 let fonts = Arc::new(res!(crate::fonts::libertinus()));
2557 let geom = crate::page::PageGeometry::a4();
2558 let style = Theme::default(); // BookOpener: a heading carries a rendered number
2559 let mut refusals = Refusals::default();
2560 let rules = collect_from_source(
2561 "#show heading.where(level: 1): set heading(numbering: \"A\")\n", 0, &mut refusals);
2562 let mut blocks = vec![heading(1), heading(2)];
2563 apply_rules(&mut blocks, &rules, geom.content_width());
2564 let (_, heads) = res!(crate::doc::author(
2565 fonts, geom, &style, &crate::fonts::FaceResolver::default(), &blocks, None, None));
2566 assert_eq!(heads[0].number, "A", "the level-1 rule renumbers only the level-1 heading");
2567 assert_eq!(heads[1].number, "1.1", "the level-2 heading, outside the rule, keeps the default number");
2568 Ok(())
2569 }
2570
2571 /// A `#show par: set text(size: 9pt)` rule wraps the paragraph the heading keeps with in a single-block
2572 /// `Scoped`, and `walk`'s lookahead descends that scope: the heading still keeps its following line (the
2573 /// keep box holds two HBoxes, not a stranded one), and the kept line is set at the scoped 9pt -- exactly
2574 /// the height a theme whose body size is 9pt directly produces (positive). Without the rule the keep box
2575 /// is unchanged (negative). Guards the scope-transparent keep-with-next fix (L1-c1); before it, the
2576 /// `Scoped`-wrapped paragraph was invisible to the lookahead and the heading stranded.
2577 #[test]
2578 fn par_rule_keeps_with_heading_through_its_scope() -> Outcome<()> {
2579 use std::sync::Arc;
2580 use crate::doc::{author, HeadingStyle};
2581 use crate::ir::{Node, Sp};
2582
2583 let fonts = Arc::new(res!(crate::fonts::libertinus()));
2584 let geom = crate::page::PageGeometry::a4();
2585 let faces = crate::fonts::FaceResolver::default();
2586
2587 // DocInline sets the heading as an inline sub-heading that keeps with its following paragraph.
2588 let mut theme = Theme::default();
2589 theme.heading.kind = HeadingStyle::DocInline;
2590
2591 let blocks = || vec![
2592 heading(2),
2593 Block::Paragraph { text: "Body after the heading, long enough to keep on its own line.".to_string() },
2594 ];
2595
2596 // The heading's keep VBox as its list of HBox heights: HBox[0] is the shaped heading line, HBox[1] is
2597 // the following paragraph's first line, pulled into the keep box.
2598 let keep_hboxes = |base: &Theme, rules_src: &str| -> Outcome<Vec<i32>> {
2599 let mut refusals = Refusals::default();
2600 let rules = rule_set_for(base, rules_src, &mut refusals);
2601 let mut bs = blocks();
2602 apply_rules(&mut bs, &rules, geom.content_width());
2603 let (doc, _) = res!(author(fonts.clone(), geom, base, &faces, &bs, None, None));
2604 for n in &doc.nodes {
2605 if let Node::VBox(b) = n {
2606 return Ok(b.list.iter().filter_map(|c| match c {
2607 Node::HBox(h) => Some(h.dims.height.raw()),
2608 _ => None,
2609 }).collect());
2610 }
2611 }
2612 Ok(Vec::new())
2613 };
2614
2615 let base = res!(keep_hboxes(&theme, ""));
2616 let ruled = res!(keep_hboxes(&theme, "#show par: set text(size: 9pt)\n"));
2617 // The independent oracle: a theme whose body size is 9pt directly, no authored rule.
2618 let mut theme9 = theme.clone();
2619 theme9.text.body_size = Sp::from_pt(9.0);
2620 let direct = res!(keep_hboxes(&theme9, ""));
2621
2622 // The keep box holds two HBoxes in every case: the heading line and the body line it kept with.
2623 assert_eq!(base.len(), 2, "the heading must keep its following body line: {:?}", base);
2624 assert_eq!(ruled.len(), 2, "the par rule must not strand the heading -- the scope is seen through: {:?}", ruled);
2625 assert_eq!(direct.len(), 2, "the direct 9pt oracle keeps likewise: {:?}", direct);
2626
2627 // Positive: the kept body line takes the rule's 9pt, matching a direct 9pt body theme exactly.
2628 assert_eq!(ruled[1], direct[1],
2629 "the kept body line must take the scoped 9pt, the same height a direct 9pt theme sets");
2630 assert!(ruled[1] < base[1], "the 9pt rule must shrink the kept body line below the default");
2631 // Negative: the heading line itself is untouched by a par rule.
2632 assert_eq!(ruled[0], base[0], "a par rule must not change the heading line");
2633 Ok(())
2634 }
2635
2636 fn raw_selector() -> Selector { Selector { kind: ElementKind::Raw, predicates: vec![] } }
2637
2638 /// A `#show raw: block.with(fill: ..., inset: ..., radius: ...)` lowers to a template that frames the
2639 /// element, and applying it seats the code block -- moved, not cloned -- inside a `Block::Box` washed the
2640 /// named fill, under a transparent hole scope. The fill, inset and radius are each the value the
2641 /// template named -- the consume half of the invariant this rule form used to fail (fill was kept,
2642 /// inset/radius silently dropped).
2643 #[test]
2644 fn template_on_raw_frames_the_code() {
2645 let sel = raw_selector();
2646 let tr = lower_transform(&sel, "it => block.with(fill: luma(240), inset: 8pt, radius: 10pt)");
2647 let t = match tr {
2648 Transform::Template(t) => t,
2649 other => panic!("expected a template, got {:?}", other),
2650 };
2651 assert_eq!(t.frame, Some(TemplateFrame {
2652 fill: Rgba::opaque(240, 240, 240),
2653 inset_x: Some(Sp::from_pt(8.0)),
2654 inset_top: Some(Sp::from_pt(8.0)),
2655 inset_bot: Some(Sp::from_pt(8.0)),
2656 radius: Some(Sp::from_pt(10.0)),
2657 }), "the block.with fill, inset and radius all become the frame");
2658 assert!(t.pre.is_empty() && t.post.is_empty(), "a bare frame has no siblings");
2659 assert_eq!(t.hole, ThemePatch::default(), "no #set inside, so the hole is transparent");
2660
2661 let rule = Rule {
2662 selector: sel,
2663 transform: Transform::Template(t),
2664 rule_id: 7,
2665 source: "#show raw".to_string(),
2666 span: Span::new(0, 0),
2667 };
2668 let mut blocks = vec![Block::Code { lines: vec!["let x = 1;".to_string()] }];
2669 apply_rules(&mut blocks, std::slice::from_ref(&rule), Sp::from_pt(400.0));
2670 match &blocks[0] {
2671 Block::Scoped { patch, blocks: inner } => {
2672 assert_eq!(patch, &ThemePatch::default(), "the hole scope is transparent");
2673 match &inner[0] {
2674 Block::Box { blocks: bb, patch, .. } => {
2675 assert_eq!(bb.len(), 1);
2676 assert!(matches!(bb[0], Block::Code { .. }), "the code is moved into the box");
2677 assert_eq!(patch.callout.fill, Some(Rgba::opaque(240, 240, 240)),
2678 "the box wash is the template's fill");
2679 assert_eq!(patch.callout.inset_x, Some(Sp::from_pt(8.0)), "the box carries the named inset x");
2680 assert_eq!(patch.callout.inset_top, Some(Sp::from_pt(8.0)), "the box carries the named inset y");
2681 assert_eq!(patch.callout.inset_bot, Some(Sp::from_pt(8.0)), "the box carries the named inset bottom");
2682 assert_eq!(patch.callout.radius, Some(Sp::from_pt(10.0)), "the box carries the named radius");
2683 },
2684 other => panic!("expected the code framed in a Box, got {:?}", other),
2685 }
2686 },
2687 other => panic!("expected a Scoped group, got {:?}", other),
2688 }
2689 }
2690
2691 /// A `block.with(fill: ...)` with no `inset`/`radius` frames the element but names no geometry override
2692 /// -- the byte-identical path a bare `#styled-box[...]` (and this same rule form before it named any
2693 /// geometry) must keep, with the renderer's own constants left to apply downstream.
2694 #[test]
2695 fn template_frame_with_no_geometry_overrides_nothing() {
2696 let sel = raw_selector();
2697 let t = match lower_transform(&sel, "it => block.with(fill: luma(240))") {
2698 Transform::Template(t) => t,
2699 other => panic!("expected a template, got {:?}", other),
2700 };
2701 assert_eq!(t.frame, Some(TemplateFrame {
2702 fill: Rgba::opaque(240, 240, 240),
2703 inset_x: None,
2704 inset_top: None,
2705 inset_bot: None,
2706 radius: None,
2707 }), "no inset/radius named, so the frame carries no geometry override");
2708
2709 let rule = Rule {
2710 selector: sel,
2711 transform: Transform::Template(t),
2712 rule_id: 0,
2713 source: "#show raw".to_string(),
2714 span: Span::new(0, 0),
2715 };
2716 let mut blocks = vec![Block::Code { lines: vec!["let x = 1;".to_string()] }];
2717 apply_rules(&mut blocks, std::slice::from_ref(&rule), Sp::from_pt(400.0));
2718 match &blocks[0] {
2719 Block::Scoped { blocks: inner, .. } => match &inner[0] {
2720 Block::Box { patch, .. } => {
2721 assert_eq!(patch.callout.inset_x, None, "no inset override -- the renderer's own default applies");
2722 assert_eq!(patch.callout.inset_top, None);
2723 assert_eq!(patch.callout.inset_bot, None);
2724 assert_eq!(patch.callout.radius, None, "no radius override -- the renderer's own default applies");
2725 },
2726 other => panic!("expected the code framed in a Box, got {:?}", other),
2727 },
2728 other => panic!("expected a Scoped group, got {:?}", other),
2729 }
2730 }
2731
2732 /// The `inset: (x:, y:, bottom:)` dict form the corpus uses: `x` and `y` set the horizontal and top pad,
2733 /// `y` also sets the bottom pad by default, and a following `bottom` overrides just that one side.
2734 #[test]
2735 fn template_inset_dict_form() {
2736 let sel = raw_selector();
2737 let t = match lower_transform(&sel, "it => block.with(fill: luma(240), inset: (x: 8pt, y: 6pt, bottom: 12pt))") {
2738 Transform::Template(t) => t,
2739 other => panic!("expected a template, got {:?}", other),
2740 };
2741 let tf = t.frame.expect("a fill names a frame");
2742 assert_eq!(tf.inset_x, Some(Sp::from_pt(8.0)), "the dict's x becomes inset_x");
2743 assert_eq!(tf.inset_top, Some(Sp::from_pt(6.0)), "the dict's y becomes inset_top");
2744 assert_eq!(tf.inset_bot, Some(Sp::from_pt(12.0)), "a following bottom overrides y for the foot pad");
2745 }
2746
2747 /// A rule's `inset`/`radius` that this reader cannot resolve to points -- an `em` value, which has no
2748 /// absolute size at lowering time -- is refused, naming the field, rather than silently framing the
2749 /// element with its geometry dropped.
2750 #[test]
2751 fn template_frame_geometry_refuses_unresolvable_lengths() {
2752 let sel = raw_selector();
2753 match lower_transform(&sel, "it => block.with(fill: luma(240), inset: 1em)") {
2754 Transform::Refused(reason) => assert!(reason.contains("inset"),
2755 "the refusal must name the field it could not resolve: {}", reason),
2756 other => panic!("expected a refusal, got {:?}", other),
2757 }
2758 match lower_transform(&sel, "it => block.with(fill: luma(240), radius: 50%)") {
2759 Transform::Refused(reason) => assert!(reason.contains("radius"),
2760 "the refusal must name the field it could not resolve: {}", reason),
2761 other => panic!("expected a refusal, got {:?}", other),
2762 }
2763 }
2764
2765 /// A `#show heading.where(level: N): it => {{ v(a); it; v(b) }}` redirects the `v(...)` spacers into the
2766 /// matched level's own `space_above`/`space_below` rather than sibling blocks -- a sibling after a heading
2767 /// would break its keep-with-next -- so the template carries no `pre`/`post` and the hole patch names the
2768 /// level's spacing.
2769 #[test]
2770 fn heading_v_template_redirects_into_level_spacing() {
2771 let sel = Selector { kind: ElementKind::Heading, predicates: vec![FieldPredicate::Level(2)] };
2772 let t = match lower_transform(&sel, "it => { v(12pt); it; v(6pt) }") {
2773 Transform::Template(t) => t,
2774 other => panic!("expected a template, got {:?}", other),
2775 };
2776 assert!(t.pre.is_empty(), "a heading v() must not become a leading sibling block");
2777 assert!(t.post.is_empty(), "a heading v() must not become a trailing sibling block");
2778 assert!(t.frame.is_none());
2779 let lvl = t.hole.heading.levels.get(1).expect("level 2 -> index 1 is present");
2780 assert_eq!(lvl.space_above, Some(Sp::from_pt(12.0)), "the leading v() lifts space above the level");
2781 assert_eq!(lvl.space_below, Some(Sp::from_pt(6.0)), "the trailing v() sets space below the level");
2782 }
2783
2784 /// The template reader refuses the bodies it cannot place as blocks: an inline `underline` wrap, a `regex`
2785 /// text rewrite, a page-reading `context` body, and -- under a heading selector -- any post sibling other
2786 /// than a `v()`, which would strand the heading from the content it keeps with.
2787 #[test]
2788 fn template_refusals() {
2789 let raw = raw_selector();
2790 let h1 = Selector { kind: ElementKind::Heading, predicates: vec![FieldPredicate::Level(1)] };
2791 assert!(matches!(lower_transform(&raw, "it => underline(it)"), Transform::Refused(_)),
2792 "underline is an inline wrap, not a block template");
2793 assert!(matches!(lower_transform(&raw, "it => it.text.replace(regex(\"x\"), \"y\")"), Transform::Refused(_)),
2794 "a regex show rewrites text, not an element");
2795 assert!(matches!(lower_transform(&raw, "it => context { it }"), Transform::Refused(_)),
2796 "a page-reading context body has no lowering");
2797 match lower_transform(&h1, "it => { it; line(length: 100%) }") {
2798 Transform::Refused(reason) => assert!(reason.contains("keep-with-next"),
2799 "a post block after a heading must be refused for keep-with-next, got: {}", reason),
2800 other => panic!("expected a refusal, got {:?}", other),
2801 }
2802 }
2803
2804 /// A `#show raw: it => {{ v(6pt); block.with(fill: luma(240)); v(6pt) }}` places the `v(...)` spacers as
2805 /// sibling `Block::Space` blocks around the framed element (a non-heading selector has no keep-with-next
2806 /// guard), and the divider width of a `line(length: 100%)` resolves to an absolute against the placement
2807 /// avail when the template is applied.
2808 #[test]
2809 fn non_heading_template_places_spacer_siblings() {
2810 let sel = raw_selector();
2811 let t = match lower_transform(&sel, "it => { v(6pt); block.with(fill: luma(240)); line(length: 50%) }") {
2812 Transform::Template(t) => t,
2813 other => panic!("expected a template, got {:?}", other),
2814 };
2815 assert_eq!(t.pre.len(), 1, "the leading v() is a sibling before the element");
2816 assert!(matches!(t.pre[0], Block::Space(sp) if sp == Sp::from_pt(6.0)));
2817 assert_eq!(t.post.len(), 1, "the trailing line() is a sibling after the element");
2818 assert!(matches!(t.post[0], Block::Rule { width: Length::Rel(f), .. } if (f - 0.5).abs() < 1e-9),
2819 "the divider keeps its relative width until placement");
2820 assert!(t.frame.is_some());
2821
2822 let rule = Rule {
2823 selector: sel,
2824 transform: Transform::Template(t),
2825 rule_id: 0,
2826 source: "#show raw".to_string(),
2827 span: Span::new(0, 0),
2828 };
2829 let mut blocks = vec![Block::Code { lines: vec!["code".to_string()] }];
2830 apply_rules(&mut blocks, std::slice::from_ref(&rule), Sp::from_pt(400.0));
2831 // The produced sequence is [Space, Box{code}, Rule]; the rule's width resolved against avail (400 pt).
2832 match &blocks[0] {
2833 Block::Scoped { blocks: seq, .. } => {
2834 assert_eq!(seq.len(), 3, "pre, hole, post: {:?}", seq);
2835 assert!(matches!(seq[0], Block::Space(_)));
2836 assert!(matches!(seq[1], Block::Box { .. }));
2837 match &seq[2] {
2838 Block::Rule { width: Length::Abs(pt), .. } => assert!((pt - 200.0).abs() < 1e-6,
2839 "50% of a 400pt avail resolves to 200pt, got {}", pt),
2840 other => panic!("expected an absolute divider width, got {:?}", other),
2841 }
2842 },
2843 other => panic!("expected a Scoped group, got {:?}", other),
2844 }
2845 }
2846
2847 /// A `block(...)[ #set text(size: 9pt) #it ]` wrap with no fill lowers to a template whose hole patch
2848 /// overlays the inner `#set` on the element, with no frame.
2849 #[test]
2850 fn template_inner_set_becomes_the_hole() {
2851 let sel = raw_selector();
2852 let t = match lower_transform(&sel, "it => block(inset: 6pt)[#set text(size: 9pt)\n#it]") {
2853 Transform::Template(t) => t,
2854 other => panic!("expected a template, got {:?}", other),
2855 };
2856 assert!(t.frame.is_none(), "a wrap with no fill does not frame");
2857 assert_eq!(t.hole.text.body_size, Some(Sp::from_pt(9.0)), "the inner #set text overlays the element");
2858 }
2859
2860 // -- #let furniture template functions ---------------------------------------------------------
2861
2862 /// `resolve_len` reads an `em` as that fraction of the running body size, and every absolute unit
2863 /// straight through, so a furniture length lowers to a fixed point value at the document's own size.
2864 #[test]
2865 fn resolve_len_reads_em_against_body_size() {
2866 let body = Sp::from_pt(10.0);
2867 assert_eq!(resolve_len("0.88em", body), Some(Sp::from_pt(8.8)));
2868 assert_eq!(resolve_len("1.2em", body), Some(Sp::from_pt(12.0)));
2869 assert_eq!(resolve_len("0em", body), Some(Sp::from_pt(0.0)));
2870 assert_eq!(resolve_len("6pt", body), Some(Sp::from_pt(6.0)));
2871 assert_eq!(resolve_len("50%", body), None, "a percentage has no absolute size here");
2872 }
2873
2874 /// The corpus `#pr-note(body)` definition lowers to a transparent-wash box with the asymmetric left/right
2875 /// inset it names, the `above`/`below` margins folded into the top/bottom pads, and its inner `#set
2876 /// text`/`#set par` as the body overlay -- every `em` resolved against the document body size.
2877 #[test]
2878 fn collect_lowers_pr_note() {
2879 let src = "\
2880#let pr-note(body) = block(
2881 inset: (left: 1.2em, right: 0.6em),
2882 above: 0.9em,
2883 below: 1.1em,
2884 {
2885 set text(size: 0.88em)
2886 set par(spacing: 0.55em, first-line-indent: 0em)
2887 body
2888 },
2889)
2890";
2891 let body = Sp::from_pt(10.0);
2892 let mut tfns = TemplateFns::new();
2893 collect_template_fns(src, body, &Palette::new(), &mut tfns);
2894 let tf = tfns.get("pr-note").expect("pr-note is collected");
2895 assert_eq!(tf.body_param, "body");
2896 assert!(!tf.has_title, "pr-note takes no title");
2897 assert_eq!(tf.patch.callout.fill, Some(Rgba::TRANSPARENT), "no fill -- a plain indented block, no wash");
2898 // The block parameters (inset/above/below) resolve their em against the outer body size, since they are
2899 // evaluated in the outer context before the inner `set text` takes effect.
2900 assert_eq!(tf.patch.callout.inset_left, Some(Sp::from_pt(12.0)), "left: 1.2em at a 10pt body");
2901 assert_eq!(tf.patch.callout.inset_right, Some(Sp::from_pt(6.0)), "right: 0.6em");
2902 assert_eq!(tf.patch.callout.inset_top, Some(Sp::from_pt(9.0)), "above: 0.9em folds into the top pad");
2903 assert_eq!(tf.patch.callout.inset_bot, Some(Sp::from_pt(11.0)), "below: 1.1em folds into the bottom pad");
2904 assert_eq!(tf.patch.text.body_size, Some(Sp::from_pt(8.8)), "set text(size: 0.88em) against the 10pt body");
2905 // The inner `set par(spacing: 0.55em)` follows `set text(size: 0.88em)`, so its em resolves against the
2906 // reduced 8.8pt size (0.55 * 8.8 = 4.84pt), tracking Typst -- not against the outer body (which gave 5.5).
2907 assert_eq!(tf.patch.par.skip, Some(Sp::from_pt(4.84)), "0.55em against the reduced 8.8pt size");
2908 assert_eq!(tf.patch.par.indent, Some(Sp::from_pt(0.0)), "set par(first-line-indent: 0em)");
2909 }
2910
2911 /// A `#let name(s) = box(fill: ..)[content]` styled-box content function collects as a CONTENT binding of
2912 /// its inner `[content]`, with the wrapper name carried, so the text is set and the box styling records a
2913 /// visible skip -- rather than being lost to neither the furniture nor the content reader (the silent
2914 /// content-loss bug). `rect` and `block` trailing-bracket forms collect the same way; the furniture
2915 /// `#pr-note` (content INSIDE the parens, no trailing bracket) is NOT stolen into the content map.
2916 #[test]
2917 fn collect_captures_a_styled_box_content_fn_but_not_furniture() {
2918 let src = "\
2919#let stamp(s) = box(fill: luma(240), outset: 2pt, radius: 3pt)[*v: #s*]
2920#let tag(s) = rect(stroke: 1pt)[tag #s]
2921#let panel(s) = block(inset: 6pt)[panel #s]
2922#let pr-note(body) = block(inset: (left: 1.2em), { set text(size: 0.9em); body })
2923";
2924 let mut cfns = ContentFns::new();
2925 collect_content_fns(src, &mut cfns);
2926
2927 let stamp = cfns.get("stamp").expect("a box-wrapped content fn collects as a content binding");
2928 assert_eq!(stamp.params, vec!["s".to_string()]);
2929 assert_eq!(stamp.body, "*v: #s*", "the INNER content is the body, not the box call");
2930 assert_eq!(stamp.wrapper.as_deref(), Some("box"), "the wrapper name is carried for the styling skip");
2931
2932 assert_eq!(cfns.get("tag").and_then(|c| c.wrapper.as_deref()), Some("rect"), "rect wraps collect too");
2933 assert_eq!(cfns.get("tag").map(|c| c.body.as_str()), Some("tag #s"));
2934 assert_eq!(cfns.get("panel").and_then(|c| c.wrapper.as_deref()), Some("block"), "block wraps collect too");
2935 assert_eq!(cfns.get("panel").map(|c| c.body.as_str()), Some("panel #s"));
2936
2937 // The furniture pr-note (content block inside the parens, no trailing bracket) is NOT a content binding.
2938 assert!(cfns.get("pr-note").is_none(),
2939 "a furniture definition must stay with the template reader, not be stolen into the content map");
2940
2941 // And it DOES still lower as furniture, so the styled block is drawn as before -- byte-identity held.
2942 let mut tfns = TemplateFns::new();
2943 collect_template_fns(src, Sp::from_pt(10.0), &Palette::new(), &mut tfns);
2944 assert!(tfns.get("pr-note").is_some(), "pr-note still lowers as furniture");
2945 assert!(tfns.get("stamp").is_none(), "the styled-box content fn is not a furniture wrap");
2946 }
2947
2948 /// A furniture body that never names its content parameter is not a wrap -- it lowers to nothing, so a
2949 /// call to it stays a tallied skip rather than expanding wrongly.
2950 #[test]
2951 fn a_definition_that_ignores_its_body_is_not_lowered() {
2952 let src = "#let bogus(body) = block(inset: 6pt, { set text(size: 0.9em) })\n";
2953 let mut tfns = TemplateFns::new();
2954 collect_template_fns(src, Sp::from_pt(10.0), &Palette::new(), &mut tfns);
2955 assert!(tfns.get("bogus").is_none(), "a body that never places `body` is not a furniture wrap");
2956 }
2957
2958 /// A bare `#let name = <literal>` collects a scalar for a string, an integer and a length alike, keeping
2959 /// a string's contents unquoted and a number or length as its own written text (Typst's own display form
2960 /// for a plain literal).
2961 #[test]
2962 fn collect_scalar_fns_reads_string_int_and_length_literals() {
2963 let src = "\
2964#let title = \"Field Guide\"
2965#let edition = 3
2966#let gap = 12pt
2967";
2968 let mut sfns = ScalarFns::new();
2969 collect_scalar_fns(src, &mut sfns);
2970 assert_eq!(sfns.get("title"), Some(&ScalarValue::Str("Field Guide".to_string())));
2971 assert_eq!(sfns.get("edition"), Some(&ScalarValue::Number("3".to_string())));
2972 assert_eq!(sfns.get("gap"), Some(&ScalarValue::Number("12pt".to_string())));
2973 }
2974
2975 /// A `#let` whose right-hand side is not a bare literal -- a furniture wrap, a content binding, a data
2976 /// array, a function signature, or an expression this reader does not evaluate -- collects no scalar, so
2977 /// it is left exactly as before (a visible `#let` skip, or the furniture/content/array reader's own).
2978 #[test]
2979 fn collect_scalar_fns_passes_over_non_literal_lets() {
2980 let src = "\
2981#let pr-note(body) = block(inset: 6pt, body)
2982#let greeting = [Hello]
2983#let data = (1, 2, 3)
2984#let doubled(n) = n * 2
2985#let total = count + 1
2986";
2987 let mut sfns = ScalarFns::new();
2988 collect_scalar_fns(src, &mut sfns);
2989 assert!(sfns.is_empty(), "no line here is a bare literal binding: {:?}", sfns);
2990 }
2991
2992 /// A trailing `//` comment on a scalar `#let`'s line does not leak into its value -- `#let n = 3 //
2993 /// words/min` reads the plain integer, and a string's own `//`-shaped contents (inside its quotes) are
2994 /// kept rather than truncated.
2995 #[test]
2996 fn collect_scalar_fns_strips_a_trailing_comment_but_keeps_a_quoted_one() {
2997 let src = "\
2998#let speed = 230 // words/min
2999#let url-ish = \"see https://example.com\" // not a real link here
3000";
3001 let mut sfns = ScalarFns::new();
3002 collect_scalar_fns(src, &mut sfns);
3003 assert_eq!(sfns.get("speed"), Some(&ScalarValue::Number("230".to_string())));
3004 assert_eq!(sfns.get("url-ish"), Some(&ScalarValue::Str("see https://example.com".to_string())));
3005 }
3006
3007 /// An `#aside-box(title: none, float: true, body)` definition (the `let inner = box(...)` idiom, re-wrapped
3008 /// in a floating figure) lowers: the body parameter is found past the two keyword parameters, the title
3009 /// keyword is recognised, and the `box`'s fill/inset/radius resolve. A `luma(...)` fill stands in for the
3010 /// corpus's `colours.yellow.lighten(92%)` here -- palette-name resolution is the aside-box milestone's
3011 /// own gap. The `figure(placement: auto)` float wrapper is recognised: `float` is `Some(Auto)`, so the
3012 /// driver sets the callout at the top or foot of a page by the midpoint rule rather than in the flow.
3013 #[test]
3014 fn collect_lowers_aside_box_shape() {
3015 let src = "\
3016#let aside-box(title: none, float: true, body) = {
3017 let inner = box(
3018 width: 100%,
3019 inset: (x: 1em, y: 1em, bottom: 1.2em),
3020 fill: luma(240),
3021 radius: 4pt,
3022 stroke: (left: 2pt + luma(50)),
3023 [
3024 #if title != none [
3025 #text(weight: \"bold\", size: 0.85em)[#title] #v(0.4em)
3026 ]
3027 #text(size: 0.85em)[#body]
3028 ]
3029 )
3030 if float { figure(placement: auto, inner) } else { inner }
3031}
3032";
3033 let body = Sp::from_pt(10.0);
3034 let mut tfns = TemplateFns::new();
3035 collect_template_fns(src, body, &Palette::new(), &mut tfns);
3036 let tf = tfns.get("aside-box").expect("aside-box is collected");
3037 assert_eq!(tf.body_param, "body", "the body is the last positional parameter, past title: and float:");
3038 assert!(tf.has_title, "a title: keyword is recognised");
3039 assert_eq!(tf.patch.callout.fill, Some(Rgba::opaque(240, 240, 240)), "the box fill resolves");
3040 assert_eq!(tf.patch.callout.inset_left, Some(Sp::from_pt(10.0)), "inset.x -> left, 1em at 10pt");
3041 assert_eq!(tf.patch.callout.inset_right, Some(Sp::from_pt(10.0)), "inset.x -> right");
3042 assert_eq!(tf.patch.callout.inset_top, Some(Sp::from_pt(10.0)), "inset.y -> top");
3043 assert_eq!(tf.patch.callout.inset_bot, Some(Sp::from_pt(12.0)), "bottom overrides y for the foot pad");
3044 assert_eq!(tf.patch.callout.radius, Some(Sp::from_pt(4.0)), "radius: 4pt");
3045 // The left stroke `2pt + luma(50)` -- the length term is the width, the colour term the paint.
3046 assert_eq!(tf.patch.callout.stroke_left_w, Some(Sp::from_pt(2.0)), "the left rule is 2pt wide");
3047 assert_eq!(tf.patch.callout.stroke_left_col, Some(Rgba::opaque(50, 50, 50)), "the left rule's colour");
3048 // The body size comes from the `text(size: 0.85em)[#body]` wrapper, not a `#set`.
3049 assert_eq!(tf.patch.text.body_size, Some(Sp::from_pt(8.5)), "body wrapped in text(size: 0.85em)");
3050 assert_eq!(tf.title_size, Some(Sp::from_pt(8.5)), "the bold title run is set at 0.85em");
3051 assert_eq!(tf.float, Some(FloatPlacement::Auto), "the figure(placement: auto) wrapper makes it an auto float");
3052 }
3053
3054 /// A `#let colours = (...)` palette is collected, and a furniture fill/stroke naming `colours.<name>`
3055 /// resolves through it (with any `.lighten`/`.darken` applied to the looked-up colour). Without the
3056 /// palette the same reference resolves to nothing and the fill falls back to transparent.
3057 #[test]
3058 fn palette_resolves_a_named_colour_reference() {
3059 let src = "#let colours = (\n yellow: rgb(\"#f0f600\"),\n purple: rgb(\"#4c1a57\"),\n)\n";
3060 let mut palette = Palette::new();
3061 collect_palette(src, &mut palette);
3062 assert_eq!(palette.get("yellow"), Some(&Rgba::opaque(0xf0, 0xf6, 0x00)));
3063 // A reference resolves, and a modifier lightens the looked-up colour toward white.
3064 assert_eq!(parse_colour_pal("colours.yellow", &palette), Some(Rgba::opaque(0xf0, 0xf6, 0x00)));
3065 assert!(parse_colour_pal("colours.yellow.lighten(92%)", &palette).is_some());
3066 // Without the palette, the reference cannot resolve.
3067 assert_eq!(parse_colour_pal("colours.yellow", &Palette::new()), None);
3068 }
3069
3070 /// `#let colours` must match exactly -- a differently named dict such as `#let colours_x` is a
3071 /// separate binding, not the palette, and must not be prefix-matched into it.
3072 #[test]
3073 fn collect_palette_does_not_prefix_match_a_longer_name() {
3074 let src = "#let colours_x = (\n yellow: rgb(\"#f0f600\"),\n)\n";
3075 let mut palette = Palette::new();
3076 collect_palette(src, &mut palette);
3077 assert!(palette.get("yellow").is_none(), "colours_x must not be read as the colours palette");
3078 }
3079}