Oregami
Repositories/oxedyne/fe2o3

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

41.0 KiB, 264 runs

created by r1870400018:39264, 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//! Lowering a document's declarative styling onto a [`Theme`].
2//!
3//! Typst styles a document with `#set` and `#show` -- `#set text(size: 11pt)`, `#show: doc.with(...)`.
4//! Austenite does not *execute* these (it has no style-computation layer, by design), but it can *lower*
5//! the declarative ones onto the theme: read the arguments and write the fields they name. The reader
6//! ([`crate::lang::parse`]) captures these constructs rather than refusing them; this module turns a
7//! captured construct's argument text into theme-field writes, and the book assembler
8//! ([`crate::book`]), which holds the theme, drives it over a root's own declarations.
9//!
10//! What is lowered here is deliberately narrow -- the whole-document `#show: <template>.with(...)`
11//! application, and a top-level `#set` on an element the theme carries (text, par, heading, list, enum,
12//! math.equation, page). A `#set` on a target the theme has no field for (a `#set rect(...)`, say) is
13//! **not** lowered: [`lower_set`] returns `false` and the reader's refusal path keeps it a visible
14//! refusal rather than a silent no-op. The introspective tail -- a `#show` whose body is a closure that
15//! reads `context`, `query`, `counter.at`, `measure`, `layout` or `state` -- is never captured here at
16//! all; it stays a refusal, since lowering it would be a lie about what the engine can do.
17//!
18//! Scope note. This unit lowers a root's *own* top-level declarations. Per-part page geometry, per-level
19//! heading faces and the config file's `#let` type scale are a later unit's work; the reserved theme
20//! fields those write are populated here only where a `#show: doc.with(...)` names them directly.
21
22use crate::ir::Sp;
23use crate::theme::{
24 Theme,
25 ThemeHeadingLevelPatch,
26 ThemePatch,
27};
28
29use oxedyne_fe2o3_core::prelude::*;
30
31/// The `#set` targets the theme carries a field for, so a `#set` on one of these lowers rather than
32/// staying a refusal. The single source of truth: [`lower_set`] matches these as its handled arms, and
33/// the reader ([`crate::lang::parse::is_lowerable_set`]) tests a `#set` line against this same list to
34/// decide whether to capture it for lowering or leave it a visible refusal.
35pub const LOWERABLE_SET_TARGETS: &[&str] = &[
36 "text",
37 "par",
38 "page",
39 "heading",
40 "list",
41 "enum",
42 "math.equation",
43 "columns",
44];
45
46/// Lowers a source's own top-level declarations onto `theme`: its `#show: <template>.with(...)`
47/// application, then each top-level `#set <target>(...)` the theme carries a field for. Values a
48/// construct does not name are left as the theme already holds them, so a document that sets little
49/// changes little. This reads only the root's own declarations, not those of its includes, and applies
50/// them at the document scope -- the whole theme the driver renders with.
51pub fn lower_root_declarations(src: &str, theme: &mut Theme) {
52 // The theme's own body size seeds the `em` base: a root that sets no `text(size:)` of its own resolves a
53 // `#set par(spacing: <em>)` against the size the theme already carries, not the raw house default.
54 theme.apply(&lower_declarations_seeded(src, theme.text.body_size.to_pt()));
55}
56
57/// The [`ThemePatch`] a source's own top-level declarations lower to, without applying it: a `#show:
58/// <template>.with(...)` application, then each top-level `#set <target>(...)` the theme carries a field
59/// for, folded into one patch in source order so a later `#set` overrides an earlier one. Returned rather
60/// than applied so a caller can fold it at the scope it governs -- the document, an included chapter's
61/// subtree, or a `#styled-box` body. This reads only the source's own declarations, not those of any
62/// file it includes.
63pub fn lower_declarations(src: &str) -> ThemePatch {
64 // A scoped or reader-side caller (an included chapter, a `#columns`/`#styled-box` body) has no size in
65 // hand: the reader is size-agnostic, so an `em` par-field that names no `text(size:)` of its own falls
66 // back to the house default. One that DOES set its own size resolves against it, found by the pre-pass.
67 lower_declarations_seeded(src, DEFAULT_BODY_PT)
68}
69
70/// The [`ThemePatch`] a source's own top-level declarations lower to, resolving each `em` paragraph field
71/// against the text size in force AT LAYOUT the way Typst does: the batch's FINAL `#set text(size: <pt>)`
72/// wins whether it precedes or follows the `#set par`, and a batch that sets none inherits `scope_body_pt`
73/// (the theme or enclosing-scope size). Because the size is resolved lazily over the whole batch, source
74/// order between the `text` and `par` sets does not change the result.
75fn lower_declarations_seeded(src: &str, scope_body_pt: f64) -> ThemePatch {
76 let mut patch = ThemePatch::default();
77 if let Some(args) = show_doc_with_args(src) {
78 lower_doc_with_into(&args, &mut patch);
79 }
80 let sets = top_level_sets(src);
81 // The layout-time `em` base: the last `text(size:)` the batch sets, else the size already in force.
82 let em_base_pt = sets.iter().rev()
83 .find_map(|(target, args)| if target == "text" { named_length_pt(args, "size") } else { None })
84 .unwrap_or(scope_body_pt);
85 for (target, args) in &sets {
86 // A target the theme has no field for writes nothing; the reader keeps such a `#set` a refusal, so
87 // nothing is silently dropped here.
88 lower_set_into(target, args, &mut patch, em_base_pt);
89 }
90 patch
91}
92
93/// The [`ThemePatch`] a `#show: <template>.with(...)` application's named arguments lower to. Only the
94/// styling-relevant arguments map to theme fields -- `heading-font` names the heading face -- and the
95/// rest (title, subtitle, logos, meta-data) are the book's front matter, read on the book's own path.
96/// An argument this does not recognise is left alone rather than guessed at.
97pub fn lower_doc_with(args: &str) -> ThemePatch {
98 let mut patch = ThemePatch::default();
99 lower_doc_with_into(args, &mut patch);
100 patch
101}
102
103fn lower_doc_with_into(args: &str, patch: &mut ThemePatch) {
104 if let Some(font) = named_string(args, "heading-font") {
105 if !font.is_empty() {
106 // The doc template applies the heading font to levels 1 and 2 only, the body family below (its
107 // per-level show rule: `font: if it.level <= 2 { heading-font } else { "Libertinus Serif" }`).
108 // Lower it into those two levels' `face`, which the renderer resolves and applies, rather than
109 // the role-default `heading.face` nothing read.
110 while patch.heading.levels.len() < 2 {
111 patch.heading.levels.push(ThemeHeadingLevelPatch::default());
112 }
113 patch.heading.levels[0].face = Some(Some(font.clone()));
114 patch.heading.levels[1].face = Some(Some(font));
115 }
116 }
117}
118
119/// The [`ThemePatch`] a top-level `#set <target>(...)` lowers to. A `target` the theme has no field for
120/// ([`LOWERABLE_SET_TARGETS`]) lowers to an empty patch, and the reader keeps that `#set` a visible
121/// refusal rather than a silent no-op. A named argument the set omits leaves that field unnamed in the
122/// patch, so applying it leaves the theme's own value.
123pub fn lower_set(target: &str, args: &str) -> ThemePatch {
124 let mut patch = ThemePatch::default();
125 lower_set_into(target, args, &mut patch, DEFAULT_BODY_PT);
126 patch
127}
128
129/// The house default body size, in points -- the base an `em` length in a lone `#set` (no earlier
130/// `#set text(size:)` to move it) resolves against, matching [`crate::theme::ThemeText`]'s own default.
131const DEFAULT_BODY_PT: f64 = 11.0;
132
133/// `em_base_pt` is the text size in force, in points, that a font-relative (`em`) length resolves
134/// against; a caller with no size context passes [`DEFAULT_BODY_PT`].
135fn lower_set_into(target: &str, args: &str, patch: &mut ThemePatch, em_base_pt: f64) -> Vec<&'static str> {
136 // The argument keys this set applied AND the renderer consumes -- the invariant is that every lowered
137 // field is either read by the renderer or refused with a diagnostic, never written-and-ignored. A key
138 // that lowers into a field nothing reads yet (an equation `numbering`, a `page` dimension) is deliberately NOT pushed here, so the refusal check ([`set_refusal_reason`]) sees it as
139 // unapplied and records a visible "not yet supported" rather than a silent no-op. A key present in the
140 // source but absent for any other reason (unrecognised for the target, an `em` length, a bare `none`)
141 // is likewise not pushed.
142 let mut used: Vec<&'static str> = Vec::new();
143 match target {
144 "text" => {
145 if let Some(pt) = named_length_pt(args, "size") {
146 patch.text.body_size = Some(Sp::from_pt(pt));
147 used.push("size");
148 }
149 // The body family list: `font: "Name"` or the fallback array `font: ("A", "B")`. Resolved against
150 // the document's fonts at assembly, where a family no font declares is a hard error (Typst's
151 // missing-family precheck), so nothing named here ever falls back silently.
152 if let Some(expr) = named_value(args, "font") {
153 if let Some(families) = font_families(&expr) {
154 patch.text.faces.body = Some(families);
155 used.push("font");
156 }
157 }
158 if let Some(b) = named_bool(args, "hyphenate") {
159 patch.text.hyphenate = Some(b);
160 used.push("hyphenate");
161 }
162 // The prose fill colour: `rgb("#…")`, `luma(n)`, a named colour, each optionally `.lighten`/
163 // `.darken`. Read with the same grammar as a rule's fill ([`crate::lang::rules::parse_colour`])
164 // so the two readers cannot drift. A palette reference (`colours.blue`) resolves to nothing here
165 // and is left unmarked, so a `#set text(fill: colours.x)` is refused rather than set wrongly.
166 // This lowers a body `#set text(fill:)` only; a heading-selector fill stays refused on the rule
167 // engine's own path.
168 if let Some(expr) = named_value(args, "fill") {
169 if let Some(rgba) = crate::lang::rules::parse_colour(&expr) {
170 patch.text.fill = Some(rgba);
171 used.push("fill");
172 }
173 }
174 },
175 "par" => {
176 // `spacing` (the space between paragraphs) and `first-line-indent` are pure lengths: a `pt` value
177 // is taken verbatim, and a font-relative `em` -- the book/doc template idiom (`#set par(spacing:
178 // 0.75em)`) -- resolves against the text size in force rather than being dropped and left at the
179 // theme default.
180 //
181 // `leading` is deliberately `pt`-only here. Typst's `par(leading:)` is the GAP added between line
182 // boxes, whereas the theme's `text.leading` is the baseline-to-baseline distance; converting one
183 // to the other needs the calibrated line-box height ([`crate::theme::ThemeCalibration`]), the way
184 // the book path's `build_style` does. Lowering an `em` (or even a `pt`) leading straight into the
185 // baseline field would set the line grid wrongly, so that conversion is left to a dedicated leading
186 // pass -- see this crate's parity notes. A `pt` leading keeps the pre-existing behaviour.
187 if let Some(pt) = named_length_pt(args, "leading") {
188 patch.text.leading = Some(Sp::from_pt(pt));
189 used.push("leading");
190 }
191 if let Some(pt) = named_length_pt_em(args, "spacing", em_base_pt) {
192 patch.par.skip = Some(Sp::from_pt(pt));
193 used.push("spacing");
194 }
195 if let Some(pt) = named_length_pt_em(args, "first-line-indent", em_base_pt) {
196 patch.par.indent = Some(Sp::from_pt(pt));
197 used.push("first-line-indent");
198 }
199 if let Some(b) = named_bool(args, "justify") {
200 patch.text.justify = Some(b);
201 used.push("justify");
202 }
203 },
204 "heading" => {
205 // `numbering` applies across the levels, the way Typst's own `set heading(numbering: ...)` does:
206 // one group-level leaf the patch folds onto every level, whatever their count.
207 if let Some(pattern) = named_string(args, "numbering") {
208 let pat = if pattern.is_empty() { None } else { Some(pattern) };
209 patch.heading.numbering_all = Some(pat);
210 used.push("numbering");
211 }
212 },
213 "list" => {
214 if let Some(pt) = named_length_pt(args, "spacing") {
215 patch.list.item_skip = Some(Some(Sp::from_pt(pt)));
216 used.push("spacing");
217 }
218 if let Some(pt) = named_length_pt(args, "indent") {
219 patch.list.marker_gap = Some(Sp::from_pt(pt));
220 used.push("indent");
221 }
222 },
223 "enum" => {
224 if let Some(pt) = named_length_pt(args, "spacing") {
225 patch.enumeration.item_skip = Some(Some(Sp::from_pt(pt)));
226 used.push("spacing");
227 }
228 if let Some(pt) = named_length_pt(args, "indent") {
229 patch.enumeration.marker_gap = Some(Sp::from_pt(pt));
230 used.push("indent");
231 }
232 if let Some(pattern) = named_string(args, "numbering") {
233 patch.enumeration.numbering = Some(if pattern.is_empty() { None } else { Some(pattern) });
234 used.push("numbering");
235 }
236 },
237 "math.equation" => {
238 // The equation renderer numbers displays "(N)" unconditionally and reads no pattern yet, so a
239 // `numbering` lowers into the theme but is left unmarked -- refused, not silently ignored.
240 if let Some(pattern) = named_string(args, "numbering") {
241 patch.equation.numbering = Some(if pattern.is_empty() { None } else { Some(pattern) });
242 }
243 },
244 "page" => {
245 // Page geometry lowers onto the body part's reserved override, but no unit consumes it yet (the
246 // driver still supplies the document geometry), so `width`/`height` are left unmarked and a lone
247 // `#set page(...)` is refused as not-yet-supported rather than silently doing nothing.
248 if let Some(pt) = named_length_mm_or_pt(args, "width") {
249 patch.page.body.default.width = Some(Some(pt));
250 }
251 if let Some(pt) = named_length_mm_or_pt(args, "height") {
252 patch.page.body.default.height = Some(Some(pt));
253 }
254 // The column count the body flows in, read by the author and the driver: a whole number of at
255 // least one.
256 if let Some(expr) = named_value(args, "columns") {
257 if let Ok(n) = expr.trim().parse::<usize>() {
258 if n >= 1 {
259 patch.page.columns = Some(n);
260 used.push("columns");
261 }
262 }
263 }
264 },
265 "columns" => {
266 // The space between two columns: a percentage of the content width, or an absolute length.
267 if let Some(expr) = named_value(args, "gutter") {
268 if let Some(len) = crate::lang::parse::parse_length(&expr) {
269 patch.page.column_gutter = Some(len);
270 used.push("gutter");
271 }
272 }
273 },
274 _ => {},
275 }
276 used
277}
278
279// ┌───────────────────────────────────────────────────────────────────────────┐
280// │ REFUSING A #set THAT LOWERED TO NOTHING (H2) │
281// └───────────────────────────────────────────────────────────────────────────┘
282
283/// The top-level argument keys `args` names, in source order: an identifier at depth zero immediately
284/// before a `:`. A key nested inside a `(...)`, `[...]` or `"..."` is not top-level, so `header: [x: y]`
285/// names only `header`. Used to tell which of a `#set`'s arguments the lowering left unapplied.
286fn arg_keys(args: &str) -> Vec<String> {
287 let chars: Vec<char> = args.chars().collect();
288 let mut keys = Vec::new();
289 let mut depth = 0i32;
290 let mut in_str = false;
291 let mut esc = false;
292 // The start of the current top-level token, or `None` once its `:` has been passed, so only the first
293 // `:` of a `key: value` names a key and a `:` inside the value is ignored.
294 let mut token_start: Option<usize> = Some(0);
295 let mut i = 0usize;
296 while i < chars.len() {
297 let c = chars[i];
298 if in_str {
299 if esc { esc = false; }
300 else if c == '\\' { esc = true; }
301 else if c == '"' { in_str = false; }
302 i += 1;
303 continue;
304 }
305 match c {
306 '"' => in_str = true,
307 '(' | '[' | '{' => depth += 1,
308 ')' | ']' | '}' => depth -= 1,
309 ',' if depth == 0 => token_start = Some(i + 1),
310 ':' if depth == 0 => {
311 if let Some(start) = token_start.take() {
312 let key: String = chars[start..i].iter().collect();
313 let key = key.trim().to_string();
314 if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
315 keys.push(key);
316 }
317 }
318 },
319 _ => {},
320 }
321 i += 1;
322 }
323 keys
324}
325
326/// Why a lowerable `#set <target>(...)` should be refused rather than pass silently: it applied none of
327/// its arguments, or named one the lowering does not recognise or could not convert (an `em` length with
328/// no context, a `#set text(lang: ...)`, a `heading(numbering: none)`). `None` when every argument the
329/// source named was applied. Only a lowerable target is judged here; a `#set` on any other target is
330/// refused by the reader's own skip path.
331fn set_refusal_reason(target: &str, args: &str) -> Option<String> {
332 if !LOWERABLE_SET_TARGETS.iter().any(|t| *t == target) {
333 return None;
334 }
335 let present = arg_keys(args);
336 let mut patch = ThemePatch::default();
337 let used = lower_set_into(target, args, &mut patch, DEFAULT_BODY_PT);
338 if present.is_empty() {
339 return Some(fmt!("#set {} applied no argument", target));
340 }
341 let leftover: Vec<String> = present.into_iter()
342 .filter(|k| !used.iter().any(|u| *u == k.as_str()))
343 .collect();
344 if leftover.is_empty() {
345 None
346 } else {
347 Some(fmt!("#set {} left unapplied: {}", target, leftover.join(", ")))
348 }
349}
350
351/// If a captured declarative-styling construct is a `#set` on a theme element that lowered to nothing --
352/// applying no argument, or hitting an unrecognised or unconvertible one -- the construct name to record
353/// as a refusal, so a `#set` that silently did nothing becomes a visible refusal (H2). `None` for a `#set`
354/// that fully lowered, and for a `#show: <t>.with(...)` (whose non-theme arguments are the book's front
355/// matter, not a no-op). The reader calls this as it dispatches a captured `DeclStyle` construct.
356pub fn declstyle_refusal(buf: &str) -> Option<String> {
357 let (target, args) = top_level_sets(buf).into_iter().next()?;
358 set_refusal_reason(&target, &args).map(|_| fmt!("#set {}", target))
359}
360
361// ┌───────────────────────────────────────────────────────────────────────────┐
362// │ EXTRACTING A CONSTRUCT'S ARGUMENTS FROM SOURCE │
363// └───────────────────────────────────────────────────────────────────────────┘
364
365/// The balanced argument text of the first `#show: <ident>.with(...)` application in `src`, without its
366/// enclosing parentheses. `None` when the source has no such application.
367fn show_doc_with_args(src: &str) -> Option<String> {
368 let mut from = 0usize;
369 while let Some(rel) = src[from..].find("#show:") {
370 let at = from + rel;
371 let rest = &src[at..];
372 // The application is `#show: <ident>.with(` -- find the `.with(` that follows, on the same line.
373 let line_end = rest.find('\n').map(|n| at + n).unwrap_or(src.len());
374 if let Some(wrel) = src[at..line_end].find(".with(") {
375 let open = at + wrel + ".with".len(); // the '(' of the argument list
376 return balanced_parens(&src[open..]);
377 }
378 from = line_end.max(at + 1);
379 }
380 None
381}
382
383/// Every top-level `#set <target>(...)` in `src`, as `(target, args)` pairs with the argument text
384/// stripped of its enclosing parentheses. "Top-level" is by line: a line whose trimmed text opens with
385/// `#set `. A malformed set (no balanced parentheses) is skipped.
386///
387/// The `(`'s position is found by tracking the running byte offset of each line rather than by searching
388/// `src` for the line's text: two `#set text(...)` lines with the same target read the same after
389/// `#set `, so a search would resolve the second to the first's arguments. The offset is exact, so the
390/// balanced scan starts at this line's own `(` and reads its own arguments, even when they run on across
391/// several following lines.
392fn top_level_sets(src: &str) -> Vec<(String, String)> {
393 let mut out = Vec::new();
394 let mut offset = 0usize; // running byte offset of the current line's start within `src`
395 // The running bracket balance across lines, folded through the reader's own content-aware scanner. A
396 // `#set` on a line that opens inside a `#styled-box[...]`/`#columns[...]` body -- a bracket still open at
397 // the line's start -- is that body's own declaration, lowered onto its scope when the body is re-parsed;
398 // capturing it here too would apply it to the enclosing scope as well. Only a `#set` at true top level
399 // (no open bracket) lowers to this source's scope.
400 let mut state = crate::lang::parse::SkipState::new();
401 for raw in src.split_inclusive('\n') {
402 let line_start = offset;
403 offset = offset.saturating_add(raw.len());
404
405 // The depth in force at this line's start, before its own delimiters are folded in.
406 let nested = state.has_open_bracket();
407 crate::lang::parse::scan_brackets(raw, &mut state);
408 if nested {
409 continue;
410 }
411
412 let indent = raw.len() - raw.trim_start().len(); // leading-whitespace bytes
413 let trimmed = raw.trim_start();
414 let after = match trimmed.strip_prefix("#set ") {
415 Some(a) => a,
416 None => continue,
417 };
418 let rest_ws = after.len() - after.trim_start().len(); // whitespace between `#set ` and the target
419 let rest = after.trim_start();
420 let open = match rest.find('(') {
421 Some(i) => i,
422 None => continue,
423 };
424 let target = rest[..open].trim().to_string();
425 if target.is_empty() {
426 continue;
427 }
428 // The byte offset of this line's own `(`, so the balanced scan reads this set's arguments -- which
429 // may run past the line's end -- rather than an earlier identical prefix's.
430 let abs = line_start + indent + "#set ".len() + rest_ws + open;
431 if let Some(args) = balanced_parens(&src[abs..]) {
432 out.push((target, args));
433 }
434 }
435 out
436}
437
438/// The text inside a balanced `(...)` at the start of `s` (which must begin with `(`). Delegates to the
439/// reader's content-aware group scanner ([`crate::lang::parse::read_group`]), so a `(` an author left
440/// unbalanced inside a `[...]` content block or a `"..."` string does not throw off the count -- the
441/// naive byte counter this replaced miscounted a `set page(header: [p (1)])`. `None` when the
442/// parentheses never close.
443fn balanced_parens(s: &str) -> Option<String> {
444 let chars: Vec<char> = s.chars().collect();
445 if chars.first() != Some(&'(') {
446 return None;
447 }
448 crate::lang::parse::read_group(&chars, 0).map(|(inner, _)| inner)
449}
450
451// ┌───────────────────────────────────────────────────────────────────────────┐
452// │ READING ONE NAMED ARGUMENT │
453// └───────────────────────────────────────────────────────────────────────────┘
454
455/// The byte offset just past a `key:` binding in `args`, matched only where `key` is preceded by a
456/// non-identifier character (or the start), so `font:` is not found inside `heading-font:`. `None` when
457/// the key is not present.
458fn key_value_start(args: &str, key: &str) -> Option<usize> {
459 let bytes = args.as_bytes();
460 let mut from = 0usize;
461 while let Some(rel) = args[from..].find(key) {
462 let at = from + rel;
463 let before_ok = at == 0 || {
464 let p = bytes[at - 1];
465 !(p.is_ascii_alphanumeric() || p == b'-' || p == b'_')
466 };
467 // After the key: optional spaces, then a colon.
468 let mut j = at + key.len();
469 while j < bytes.len() && bytes[j] == b' ' {
470 j += 1;
471 }
472 if before_ok && j < bytes.len() && bytes[j] == b':' {
473 return Some(j + 1);
474 }
475 from = at + key.len();
476 }
477 None
478}
479
480/// The string a `key: "..."` or `key: [...]` names, without its quotes or brackets, trimmed. `None`
481/// when the key is absent or its value is neither a string nor a content block.
482fn named_string(args: &str, key: &str) -> Option<String> {
483 let start = key_value_start(args, key)?;
484 let rest = args[start..].trim_start();
485 if let Some(inner) = rest.strip_prefix('"') {
486 let end = inner.find('"')?;
487 return Some(inner[..end].to_string());
488 }
489 if rest.starts_with('[') {
490 let mut depth = 0i32;
491 for (i, c) in rest.char_indices() {
492 match c {
493 '[' => depth += 1,
494 ']' => {
495 depth -= 1;
496 if depth == 0 {
497 return Some(rest[1..i].trim().to_string());
498 }
499 },
500 _ => {},
501 }
502 }
503 }
504 None
505}
506
507/// The family list a `font:` value names: a lone string, or an array of strings (Typst's fallback list,
508/// tried in order). `None` for anything else -- a variable, a dictionary form, an empty name -- so the
509/// argument stays unapplied and the `#set` is refused rather than set wrongly.
510fn font_families(expr: &str) -> Option<Vec<String>> {
511 let e = expr.trim();
512 let inner = match e.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
513 Some(i) => i,
514 None => e,
515 };
516 let mut out: Vec<String> = Vec::new();
517 for part in inner.split(',') {
518 let p = part.trim();
519 if p.is_empty() {
520 continue; // the trailing comma of a one-element array, `("A",)`
521 }
522 let Some(name) = p.strip_prefix('"').and_then(|r| r.strip_suffix('"')) else { return None; };
523 if name.trim().is_empty() || name.contains('"') {
524 return None;
525 }
526 out.push(name.trim().to_string());
527 }
528 if out.is_empty() { None } else { Some(out) }
529}
530
531/// The raw value expression a `key:` names, read to the next top-level comma -- one not nested inside a
532/// `(...)`, `[...]`, `{...}` or `"..."` -- and trimmed. Unlike [`named_string`] it keeps the value's own
533/// delimiters, so a call like `rgb("#ff0000")`, an argument list `rgb(0, 0, 0)` or a modifier chain
534/// `red.darken(20%)` arrives whole for a colour reader. `None` when the key is absent or the value empty.
535fn named_value(args: &str, key: &str) -> Option<String> {
536 let start = key_value_start(args, key)?;
537 let chars: Vec<char> = args[start..].chars().collect();
538 let mut depth = 0i32;
539 let mut in_str = false;
540 let mut esc = false;
541 let mut end = chars.len();
542 for (i, c) in chars.iter().enumerate() {
543 if in_str {
544 if esc { esc = false; }
545 else if *c == '\\' { esc = true; }
546 else if *c == '"' { in_str = false; }
547 continue;
548 }
549 match c {
550 '"' => in_str = true,
551 '(' | '[' | '{' => depth += 1,
552 ')' | ']' | '}' => depth -= 1,
553 ',' if depth == 0 => { end = i; break; },
554 _ => {},
555 }
556 }
557 let v: String = chars[..end].iter().collect();
558 let v = v.trim().to_string();
559 if v.is_empty() { None } else { Some(v) }
560}
561
562/// The boolean a `key: true`/`key: false` names. `None` when absent or not a boolean literal.
563fn named_bool(args: &str, key: &str) -> Option<bool> {
564 let start = key_value_start(args, key)?;
565 let rest = args[start..].trim_start();
566 if rest.starts_with("true") {
567 Some(true)
568 } else if rest.starts_with("false") {
569 Some(false)
570 } else {
571 None
572 }
573}
574
575/// The leading real number of a `key:`'s value, and the unit token immediately after it (`pt`, `mm`,
576/// `em`, or empty). `None` when the key is absent or its value does not begin with a number.
577fn named_number_unit(args: &str, key: &str) -> Option<(f64, String)> {
578 let start = key_value_start(args, key)?;
579 let rest = args[start..].trim_start();
580 let mut end = 0usize;
581 let mut seen_dot = false;
582 for (i, c) in rest.char_indices() {
583 if c.is_ascii_digit() || (c == '-' && i == 0) {
584 end = i + c.len_utf8();
585 } else if c == '.' && !seen_dot {
586 seen_dot = true;
587 end = i + c.len_utf8();
588 } else {
589 break;
590 }
591 }
592 if end == 0 {
593 return None;
594 }
595 let num: f64 = rest[..end].parse().ok()?;
596 let unit: String = rest[end..].chars().take_while(|c| c.is_ascii_alphabetic()).collect();
597 Some((num, unit))
598}
599
600/// The point value of a `key:`'s length, accepting a bare number or one suffixed `pt`. An `em` or `mm`
601/// value returns `None` so the field is left unchanged rather than set wrongly; where a field is legally
602/// written in ems (a paragraph metric), the caller uses [`named_length_pt_em`] with the body size instead.
603fn named_length_pt(args: &str, key: &str) -> Option<f64> {
604 let (num, unit) = named_number_unit(args, key)?;
605 match unit.as_str() {
606 "" | "pt" => Some(num),
607 _ => None,
608 }
609}
610
611/// The point value of a `key:`'s length, accepting a bare number, `pt`, or a font-relative `em` resolved
612/// against `em_base_pt` (the text size in force). `mm` is still `None` here -- a paragraph metric is never
613/// set in millimetres, and leaving it unconverted keeps such a `#set` a visible refusal rather than a
614/// wrong write. Used where a metric may legitimately be written in ems, unlike [`named_length_pt`].
615fn named_length_pt_em(args: &str, key: &str, em_base_pt: f64) -> Option<f64> {
616 let (num, unit) = named_number_unit(args, key)?;
617 match unit.as_str() {
618 "" | "pt" => Some(num),
619 "em" => Some(num * em_base_pt),
620 _ => None,
621 }
622}
623
624const MM_PER_PT: f64 = 72.0 / 25.4; // points in one millimetre
625
626/// The scaled-point length of a `key:`'s value, accepting `pt` or `mm` (a page dimension is usually set
627/// in millimetres). An `em` value is left to a later unit that knows the body size.
628fn named_length_mm_or_pt(args: &str, key: &str) -> Option<Sp> {
629 let (num, unit) = named_number_unit(args, key)?;
630 match unit.as_str() {
631 "pt" => Some(Sp::from_pt(num)),
632 "mm" => Some(Sp::from_pt(num * MM_PER_PT)),
633 _ => None,
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 /// `#show: doc.with(heading-font: "...")` lowers the heading face into levels 1 and 2 (the doc
642 /// template's per-level rule), leaving deeper levels and the rest of the theme at their defaults, so a
643 /// document that names only a heading font changes only those two levels' face.
644 #[test]
645 fn doc_with_lowers_the_heading_font() {
646 let mut theme = Theme::default();
647 let src = "#import \"template.typ\": *\n#show: doc.with(\n title: [X],\n heading-font: \"Graystroke\",\n)\n\n= Body\n";
648 lower_root_declarations(src, &mut theme);
649 assert_eq!(theme.heading.levels[0].face, Some("Graystroke".to_string()));
650 assert_eq!(theme.heading.levels[1].face, Some("Graystroke".to_string()));
651 // The body family below level 2, and the rest of the theme, are untouched.
652 assert_eq!(theme.heading.levels[2].face, None);
653 assert_eq!(theme.heading.face, None);
654 assert_eq!(theme.text.body_size, Theme::default().text.body_size);
655 }
656
657 /// A lowerable `#set text(size: ...)` lowers to a patch that writes the body size and body face; an
658 /// omitted argument leaves its field unnamed, so applying the patch leaves the theme's own value.
659 #[test]
660 fn set_text_lowers_size_and_font() {
661 let patch = lower_set("text", "size: 12pt, font: \"Libertinus Serif\"");
662 let mut theme = Theme::default();
663 theme.apply(&patch);
664 assert_eq!(theme.text.body_size, Sp::from_pt(12.0));
665 assert_eq!(theme.text.faces.body, vec!["Libertinus Serif".to_string()]);
666 // A fallback array lowers to the list in order.
667 let listed = lower_set("text", "font: (\"Felipa\", \"Libertinus Serif\",)");
668 assert_eq!(listed.text.faces.body, Some(vec!["Felipa".to_string(), "Libertinus Serif".to_string()]));
669 // A value that is not a literal family is left unapplied, so the set is refused.
670 assert_eq!(lower_set("text", "font: fonts.display").text.faces.body, None);
671 // The leading was not named, so it kept its default.
672 assert_eq!(theme.text.leading, Theme::default().text.leading);
673 }
674
675 /// `#set page(columns: n)` lowers the body's column count and `#set columns(gutter: ..)` the space between
676 /// two, both consumed, so neither is refused; a count that is not a whole number is left unapplied.
677 #[test]
678 fn set_page_columns_and_gutter_lower() {
679 assert_eq!(lower_set("page", "columns: 2").page.columns, Some(2));
680 assert_eq!(set_refusal_reason("page", "columns: 2"), None);
681 assert_eq!(lower_set("columns", "gutter: 12pt").page.column_gutter, Some(crate::ir::Length::Abs(12.0)));
682 assert_eq!(lower_set("columns", "gutter: 5%").page.column_gutter, Some(crate::ir::Length::Rel(0.05)));
683 assert!(set_refusal_reason("page", "columns: auto").is_some());
684 }
685
686 /// A `#set par(...)` lowers its pure-length metrics (`spacing`, `first-line-indent`) in `em` against the
687 /// text size IN FORCE AT LAYOUT, the template idiom (`#set par(spacing: 0.75em)`) that was silently
688 /// dropped before `em` was convertible. Typst resolves the `em` lazily -- the batch's final `text(size:)`
689 /// wins whether it precedes OR follows the `#set par` -- so source order does not change the result.
690 /// `leading` is deliberately NOT converted from `em` here: it is a line-box gap, not a baseline distance,
691 /// so it stays a visible refusal rather than a wrong write (see the `par` arm and [`named_length_pt_em`]).
692 #[test]
693 fn set_par_lowers_em_pure_lengths_against_layout_text_size() {
694 // A lone `#set par` resolves spacing/indent em against the 11 pt house default; an em leading is left
695 // unlowered (its baseline conversion needs the calibrated line box, deferred to a leading pass).
696 let patch = lower_set("par", "leading: 0.78em, spacing: 0.75em, first-line-indent: 1.5em");
697 assert_eq!(patch.par.skip, Some(Sp::from_pt(0.75 * 11.0)));
698 assert_eq!(patch.par.indent, Some(Sp::from_pt(1.5 * 11.0)));
699 assert_eq!(patch.text.leading, None);
700
701 // `text(size:)` BEFORE the `par` -- the em resolves against 12 pt.
702 let mut theme = Theme::default();
703 lower_root_declarations("#set text(size: 12pt)\n#set par(spacing: 0.75em)\n\n= Body\n", &mut theme);
704 assert_eq!(theme.par.skip, Sp::from_pt(0.75 * 12.0));
705
706 // `text(size:)` AFTER the `par` -- Typst resolves the em lazily against the size in force at layout,
707 // so the final 20 pt still wins and the result is order-independent (order.typ/order2.typ are
708 // byte-identical under typst 0.15.1).
709 let mut theme = Theme::default();
710 lower_root_declarations("#set par(spacing: 1em)\n#set text(size: 20pt)\n\n= Body\n", &mut theme);
711 assert_eq!(theme.par.skip, Sp::from_pt(20.0));
712
713 // No `text(size:)` in the batch -- the em resolves against the size the theme already carries, not the
714 // raw house default (here a 12 pt theme).
715 let mut theme = Theme::default();
716 theme.text.body_size = Sp::from_pt(12.0);
717 lower_root_declarations("#set par(spacing: 1em)\n\n= Body\n", &mut theme);
718 assert_eq!(theme.par.skip, Sp::from_pt(12.0));
719
720 // A `pt` spacing is still taken verbatim, and a `pt` leading keeps its pre-existing pass-through.
721 assert_eq!(lower_set("par", "spacing: 9pt").par.skip, Some(Sp::from_pt(9.0)));
722 assert_eq!(lower_set("par", "leading: 16pt").text.leading, Some(Sp::from_pt(16.0)));
723 }
724
725 /// `#set text(fill: rgb("#ff0000"))` lowers the prose fill, consuming the argument so it is not refused;
726 /// the default theme carries black. A palette reference the reader cannot resolve without a palette
727 /// (`fill: colours.blue`) leaves the field unset and is flagged for refusal rather than guessed at.
728 #[test]
729 fn set_text_lowers_fill_colour() {
730 use oxedyne_fe2o3_graphics::colour::Rgba;
731
732 // The default is black, so an unset document renders exactly as before text carried a colour.
733 assert_eq!(Theme::default().text.fill, Rgba::BLACK);
734
735 let patch = lower_set("text", "fill: rgb(\"#ff0000\")");
736 assert_eq!(patch.text.fill, Some(Rgba::opaque(255, 0, 0)));
737 let mut theme = Theme::default();
738 theme.apply(&patch);
739 assert_eq!(theme.text.fill, Rgba::opaque(255, 0, 0));
740 // The fill was consumed, so a `#set text(fill: ...)` is not spuriously refused.
741 assert_eq!(set_refusal_reason("text", "fill: rgb(\"#ff0000\")"), None);
742
743 // A `luma`, a named colour, and a lightened named colour all read, so the grammar matches a rule's.
744 assert_eq!(lower_set("text", "fill: luma(0)").text.fill, Some(Rgba::opaque(0, 0, 0)));
745 assert_eq!(lower_set("text", "fill: red").text.fill, Some(Rgba::opaque(255, 65, 54)));
746 assert!(lower_set("text", "fill: black.lighten(50%)").text.fill.is_some());
747
748 // A palette reference resolves to nothing here, so the field is left unset and the set is refused
749 // rather than set wrongly.
750 assert_eq!(lower_set("text", "fill: colours.blue").text.fill, None);
751 assert!(set_refusal_reason("text", "fill: colours.blue").is_some());
752 }
753
754 /// `set heading(numbering: "1.1")` lowers to a patch that applies the pattern across every level.
755 #[test]
756 fn set_heading_numbering_applies_to_all_levels() {
757 let patch = lower_set("heading", "numbering: \"1.1\"");
758 let mut theme = Theme::default();
759 theme.apply(&patch);
760 for level in &theme.heading.levels {
761 assert_eq!(level.numbering, Some("1.1".to_string()));
762 }
763 }
764
765 /// A `#set` nested inside a `#styled-box[...]` body is that body's own declaration -- lowered onto its
766 /// scope when the body is re-parsed -- not captured at the enclosing source's top level. Only a
767 /// genuinely top-level `#set` lowers to this source's scope, so the nested 40pt never reaches it and the
768 /// top-level 20pt does (the nesting-aware source scan; without it the flat scan would fold both and the
769 /// later 40pt would win).
770 #[test]
771 fn nested_set_inside_a_bracketed_body_is_not_captured() {
772 let src = "#set text(size: 20pt)\n#styled-box[\n#set text(size: 40pt)\nInside the box.\n]\n";
773 let patch = lower_declarations(src);
774 assert_eq!(patch.text.body_size, Some(Sp::from_pt(20.0)),
775 "only the top-level #set should lower here; the box body's #set must not leak out");
776 }
777
778 /// A `#set` on a target the theme has no field for lowers to an empty patch -- the caller keeps it a
779 /// refusal, and applying the empty patch changes nothing.
780 #[test]
781 fn set_on_unknown_target_is_not_lowered() {
782 let patch = lower_set("rect", "stroke: 1pt");
783 assert_eq!(patch, ThemePatch::default());
784 let mut theme = Theme::default();
785 theme.apply(&patch);
786 assert_eq!(theme, Theme::default());
787 }
788
789 /// Two top-level `#set` lines whose text reads identically after `#set ` are read by their own byte
790 /// offset, so the second's arguments are its own -- not the first's, which a naive `src.find` returned.
791 #[test]
792 fn top_level_sets_reads_each_lines_own_arguments() {
793 let src = "#set text(size: 11pt)\n#set text(size: 13pt)\n";
794 let sets = top_level_sets(src);
795 assert_eq!(sets.len(), 2);
796 assert_eq!(sets[0], ("text".to_string(), "size: 11pt".to_string()));
797 assert_eq!(sets[1], ("text".to_string(), "size: 13pt".to_string()));
798 }
799
800 /// The named-argument reader does not confuse a suffix key: `font:` is not found inside
801 /// `heading-font:`.
802 #[test]
803 fn key_reader_respects_word_boundaries() {
804 assert_eq!(named_string("heading-font: \"A\"", "font"), None);
805 assert_eq!(named_string("heading-font: \"A\", font: \"B\"", "font"), Some("B".to_string()));
806 }
807
808 /// Balanced-paren extraction spans newlines and ignores a parenthesis inside a string.
809 #[test]
810 fn balanced_parens_spans_lines_and_skips_strings() {
811 let s = "(\n a: 1,\n b: \"a)b\",\n c: (1, 2),\n)tail";
812 assert_eq!(balanced_parens(s), Some("\n a: 1,\n b: \"a)b\",\n c: (1, 2),\n".to_string()));
813 }
814
815 /// The top-level argument keys are read at depth zero, so a nested `key:` inside a `[...]` value is not
816 /// mistaken for one of the set's own arguments.
817 #[test]
818 fn arg_keys_reads_top_level_keys_only() {
819 assert_eq!(arg_keys("size: 12pt, font: \"A\""), vec!["size".to_string(), "font".to_string()]);
820 assert_eq!(arg_keys("header: [page: 1]"), vec!["header".to_string()]);
821 assert_eq!(arg_keys(""), Vec::<String>::new());
822 }
823
824 /// H2: a lowerable `#set` whose named arguments the renderer does not consume -- an unknown key, an
825 /// unconvertible `em`, a bare `none`, or a field lowered but not yet read (an equation
826 /// `numbering`, a `page` dimension) -- is flagged for refusal, so it is a visible "not yet supported"
827 /// rather than a silent no-op; a set every one of whose arguments the renderer consumes is not.
828 #[test]
829 fn unconsumed_set_is_flagged_for_refusal() {
830 // Fully consumed: no refusal.
831 assert_eq!(set_refusal_reason("text", "size: 12pt"), None);
832 assert_eq!(set_refusal_reason("par", "leading: 14pt, first-line-indent: 12pt, justify: false"), None);
833 assert_eq!(set_refusal_reason("text", "hyphenate: false"), None);
834 assert_eq!(set_refusal_reason("heading", "numbering: \"1.1\""), None);
835 assert_eq!(set_refusal_reason("enum", "numbering: \"(a)\""), None);
836 // An unrecognised argument key.
837 assert!(set_refusal_reason("text", "lang: \"de\"").is_some());
838 // A recognised key whose value does not convert (an `em` needs a context this lowering has not).
839 assert!(set_refusal_reason("text", "size: 1em").is_some());
840 // A bare `none` is not a string the numbering reader accepts, so nothing is applied.
841 assert!(set_refusal_reason("heading", "numbering: none").is_some());
842 // A partially-applied set is still flagged, for the argument it dropped.
843 assert!(set_refusal_reason("text", "size: 12pt, weight: 700").is_some());
844 // A body font is read by the renderer now, so it is consumed; one that is not a literal family is not.
845 assert_eq!(set_refusal_reason("text", "font: \"Radley\""), None);
846 assert!(set_refusal_reason("text", "font: fonts.display").is_some());
847 // Lowered-but-unread fields are flagged, so a `#set` into one alone is refused rather than a no-op.
848 assert!(set_refusal_reason("math.equation", "numbering: \"(1)\"").is_some(),
849 "equation numbering lowers but the renderer always sets (N), so it must be refused");
850 assert!(set_refusal_reason("page", "width: 200mm").is_some(),
851 "page geometry lowers but no unit consumes it yet, so it must be refused");
852
853 // declstyle_refusal drives it off a captured construct buffer, naming the set, and never flags a
854 // `#show: doc.with(...)`, whose non-theme arguments are front matter rather than a no-op.
855 assert_eq!(declstyle_refusal("#set text(size: 12pt)\n"), None);
856 assert_eq!(declstyle_refusal("#set text(lang: \"de\")\n"), Some("#set text".to_string()));
857 assert_eq!(declstyle_refusal("#show: doc.with(title: [X])\n"), None);
858 }
859}