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 | |
| 22 | use crate::ir::Sp; |
| 23 | use crate::theme::{ |
| 24 | Theme, |
| 25 | ThemeHeadingLevelPatch, |
| 26 | ThemePatch, |
| 27 | }; |
| 28 | |
| 29 | use 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. |
| 35 | pub 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. |
| 51 | pub 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. |
| 63 | pub 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. |
| 75 | fn 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. |
| 97 | pub 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 | |
| 103 | fn 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. |
| 123 | pub 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. |
| 131 | const 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`]. |
| 135 | fn 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. |
| 286 | fn 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. |
| 331 | fn 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. |
| 356 | pub 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. |
| 367 | fn 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. |
| 392 | fn 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. |
| 443 | fn 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. |
| 458 | fn 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. |
| 482 | fn 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. |
| 510 | fn 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. |
| 535 | fn 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. |
| 563 | fn 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. |
| 577 | fn 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. |
| 603 | fn 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`]. |
| 615 | fn 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 | |
| 624 | const 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. |
| 628 | fn 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)] |
| 638 | mod 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 | } |