Skip to main content

mos_layout/
style.rs

1use mos_core::{AttrValue, Diagnostic, Document, Node, NodeKind, codes};
2use mos_fonts::FontFamily;
3
4use crate::{PageStyle, TextStyle};
5
6/// Resolve page and text styles from root-level `#set` blocks.
7///
8/// Later directives win. `#set document(...)` is consumed by the lowerer for
9/// PDF metadata and ignored here.
10#[must_use]
11pub fn resolve_styles(document: &Document) -> (PageStyle, TextStyle, Vec<Diagnostic>) {
12    let mut page = PageStyle::default();
13    let mut text = TextStyle::default();
14    let mut diagnostics: Vec<Diagnostic> = Vec::new();
15    let Some(root) = document.get(document.root) else {
16        return (page, text, diagnostics);
17    };
18    for child_id in &root.children {
19        let Some(node) = document.get(*child_id) else {
20            continue;
21        };
22        if node.kind != NodeKind::Raw {
23            continue;
24        }
25        let Some(AttrValue::Str(target)) = node.attributes.get("set") else {
26            continue;
27        };
28        match target.as_str() {
29            "page" => apply_page_set(node, &mut page, &text, &mut diagnostics),
30            "text" => apply_text_set(node, &mut text, &page, &mut diagnostics),
31            _ => {}
32        }
33    }
34    (page, text, diagnostics)
35}
36
37fn apply_page_set(
38    node: &Node,
39    page: &mut PageStyle,
40    text: &TextStyle,
41    diagnostics: &mut Vec<Diagnostic>,
42) {
43    // Stage all updates from this directive into `next` and validate
44    // the *combined* result against both the new page geometry and
45    // the carried-over text style. Validating field-at-a-time would
46    // miss the case where only `paper` changes and either the carried
47    // margin or the carried text.size becomes unworkable on the new
48    // page (e.g. `paper: "A0", margin: 300pt` then `paper: "A5"`, or
49    // `text(size: 50pt)` then `paper: "A8"`).
50    let mut next = *page;
51    if let Some(AttrValue::Str(name)) = node.attributes.get("set.arg.paper") {
52        if let Some((w, h)) = paper_size_pt(name) {
53            next.width = w;
54            next.height = h;
55        } else {
56            diagnostics.push(
57                Diagnostic::simple(
58                    &codes::MOS0017,
59                    None,
60                    format!(
61                        "unknown paper size `{name}` (expected an ISO A/B size or `Letter`/`Legal`)"
62                    ),
63                )
64                .with_span(node.span.clone()),
65            );
66        }
67    }
68    if let Some(AttrValue::Length(pt)) = node.attributes.get("set.arg.margin") {
69        next.margin = pt_to_f32(*pt);
70    }
71    // Reject geometrically impossible margins.
72    if next.margin < 0.0 || 2.0 * next.margin >= next.width {
73        diagnostics.push(reject(
74            node,
75            format!(
76                "page margin {:.2}pt is invalid for a {:.0}pt-wide page; previous value retained",
77                next.margin, next.width
78            ),
79        ));
80        return;
81    }
82    // Reject page changes that would make the carried text.size_pt
83    // overflow the page's vertical margin gap.
84    let available_pt = 2.0f32.mul_add(-next.margin, next.height);
85    if available_pt > 0.0 && text.size_pt > available_pt {
86        diagnostics.push(reject(
87            node,
88            format!(
89                "page change to {:.0}×{:.0}pt leaves text size {:.2}pt too large for {:.2}pt of vertical space; previous page geometry retained",
90                next.width, next.height, text.size_pt, available_pt
91            ),
92        ));
93        return;
94    }
95    *page = next;
96}
97
98fn apply_text_set(
99    node: &Node,
100    text: &mut TextStyle,
101    page: &PageStyle,
102    diagnostics: &mut Vec<Diagnostic>,
103) {
104    let mut next = *text;
105    if let Some(AttrValue::Length(pt)) = node.attributes.get("set.arg.size") {
106        next.size_pt = pt_to_f32(*pt);
107    }
108    if let Some(AttrValue::Float(v)) = node.attributes.get("set.arg.leading") {
109        next.leading = pt_to_f32(*v);
110    }
111    if let Some(AttrValue::Str(name)) = node.attributes.get("set.arg.font") {
112        next.family = FontFamily::resolve(name, Some(node.span.clone()), diagnostics);
113    }
114    if next.size_pt <= 0.0 {
115        diagnostics.push(reject(
116            node,
117            format!(
118                "text size {:.2}pt is not positive; previous value retained",
119                next.size_pt
120            ),
121        ));
122        return;
123    }
124    // Leading must be strictly positive: zero or negative would
125    // stack lines on top of each other or walk upward.
126    if next.leading <= 0.0 {
127        diagnostics.push(reject(
128            node,
129            format!(
130                "text leading {:.2} is not positive; previous value retained",
131                next.leading
132            ),
133        ));
134        return;
135    }
136    // The new text.size_pt must fit in the page's vertical margin
137    // gap; otherwise `flush_line` would page-break repeatedly into
138    // the same off-page state. text.size_pt is a safe upper bound on
139    // a line's ascent for our standard fonts (ascent < size).
140    let available_pt = 2.0f32.mul_add(-page.margin, page.height);
141    if available_pt > 0.0 && next.size_pt > available_pt {
142        diagnostics.push(reject(
143            node,
144            format!(
145                "text size {:.2}pt does not fit in {:.2}pt of vertical space on the {:.0}×{:.0}pt page; previous value retained",
146                next.size_pt, available_pt, page.width, page.height
147            ),
148        ));
149        return;
150    }
151    *text = next;
152}
153
154/// Build an `MOS0023` diagnostic for a `#set` argument whose value, while
155/// well-typed, would produce broken page geometry. The value is *not*
156/// applied; the previous (or default) value is retained.
157fn reject(node: &Node, message: String) -> Diagnostic {
158    Diagnostic::simple(&codes::MOS0023, None, message).with_span(node.span.clone())
159}
160
161/// Narrow a page/style measurement to `f32`.
162///
163/// Values arriving here are bounded to typographic ranges, so lost precision
164/// sits well below a point.
165#[allow(
166    clippy::cast_possible_truncation,
167    reason = "values bounded to typographic ranges; loss is sub-pt"
168)]
169#[must_use]
170pub const fn pt_to_f32(v: f64) -> f32 {
171    v as f32
172}
173
174/// Resolve a paper-size name (`"A4"`, `"B5"`, `"Letter"`, `"Legal"`) to
175/// `(width_pt, height_pt)`. ISO 216 `A` and `B` sizes are computed
176/// algorithmically; non-ISO sizes are explicit constants.
177///
178/// Formula: A0 = 841 × 1189 mm. Each subsequent size halves the long
179/// edge: `A(n+1)` has width = `floor(A_n.height / 2)`, height =
180/// `A_n.width`. B0 = 1000 × 1414 mm follows the same recurrence.
181#[allow(
182    clippy::cast_precision_loss,
183    reason = "ISO 216 dimensions max out at ~4000mm, well inside f32's 23-bit mantissa"
184)]
185#[must_use]
186pub fn paper_size_pt(name: &str) -> Option<(f32, f32)> {
187    let mm_to_pt = 72.0_f32 / 25.4_f32;
188    if let Some(rest) = name.strip_prefix(['A', 'a'])
189        && let Ok(n) = rest.parse::<u8>()
190        && n <= 10
191    {
192        let (w_mm, h_mm) = iso_size(841, 1189, n);
193        return Some((w_mm as f32 * mm_to_pt, h_mm as f32 * mm_to_pt));
194    }
195    if let Some(rest) = name.strip_prefix(['B', 'b'])
196        && let Ok(n) = rest.parse::<u8>()
197        && n <= 10
198    {
199        let (w_mm, h_mm) = iso_size(1000, 1414, n);
200        return Some((w_mm as f32 * mm_to_pt, h_mm as f32 * mm_to_pt));
201    }
202    match name {
203        "Letter" | "letter" | "US-Letter" => Some((612.0, 792.0)),
204        "Legal" | "legal" | "US-Legal" => Some((612.0, 1008.0)),
205        _ => None,
206    }
207}
208
209fn iso_size(w0_mm: u32, h0_mm: u32, n: u8) -> (u32, u32) {
210    let mut w = w0_mm;
211    let mut h = h0_mm;
212    for _ in 0..n {
213        let new_w = h / 2;
214        let new_h = w;
215        w = new_w;
216        h = new_h;
217    }
218    (w, h)
219}
220
221#[cfg(test)]
222mod tests {
223    #![allow(
224        clippy::unwrap_used,
225        clippy::expect_used,
226        reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
227    )]
228
229    use std::path::PathBuf;
230
231    use mos_core::{AttrMap, AttrValue, Document, NodeId, NodeKind, NodeSpec, SourceSpan, codes};
232
233    use crate::{A4_WIDTH_PT, MARGIN_PT};
234
235    use super::{paper_size_pt, resolve_styles};
236
237    fn alloc_set_block(doc: &mut Document, target: &str, args: &[(&str, AttrValue)]) -> NodeId {
238        let mut attrs = AttrMap::new();
239        attrs.insert("set".to_owned(), AttrValue::Str(target.to_owned()));
240        for (key, value) in args {
241            attrs.insert(format!("set.arg.{key}"), value.clone());
242        }
243        doc.alloc_child(
244            doc.root,
245            NodeSpec::new(
246                NodeKind::Raw,
247                SourceSpan::placeholder(PathBuf::from("test.mos")),
248            )
249            .with_attributes(attrs),
250        )
251    }
252
253    #[test]
254    fn set_page_margin_shifts_runs_inward() {
255        let mut doc = Document::new(PathBuf::from("test.mos"));
256        alloc_set_block(
257            &mut doc,
258            "page",
259            &[("margin", AttrValue::Length(50.0 * 72.0 / 25.4))],
260        );
261
262        let (page, _, diagnostics) = resolve_styles(&doc);
263
264        assert!(diagnostics.is_empty(), "{diagnostics:?}");
265        let expected = 50.0_f32 * 72.0 / 25.4;
266        assert!((page.margin - expected).abs() < 0.05);
267    }
268
269    #[test]
270    fn set_page_paper_a5_changes_page_dimensions() {
271        let mut doc = Document::new(PathBuf::from("test.mos"));
272        alloc_set_block(
273            &mut doc,
274            "page",
275            &[("paper", AttrValue::Str("A5".to_owned()))],
276        );
277
278        let (page, _, diagnostics) = resolve_styles(&doc);
279
280        assert!(diagnostics.is_empty(), "{diagnostics:?}");
281        let expected_w = 148.0_f32 * 72.0 / 25.4;
282        let expected_h = 210.0_f32 * 72.0 / 25.4;
283        assert!((page.width - expected_w).abs() < 1.0, "w = {}", page.width);
284        assert!(
285            (page.height - expected_h).abs() < 1.0,
286            "h = {}",
287            page.height
288        );
289    }
290
291    #[test]
292    fn set_text_size_changes_run_size() {
293        let mut doc = Document::new(PathBuf::from("test.mos"));
294        alloc_set_block(&mut doc, "text", &[("size", AttrValue::Length(20.0))]);
295
296        let (_, text, diagnostics) = resolve_styles(&doc);
297
298        assert!(diagnostics.is_empty(), "{diagnostics:?}");
299        assert!((text.size_pt - 20.0).abs() < 0.01);
300    }
301
302    #[test]
303    fn negative_margin_is_rejected_with_mos0023() {
304        let mut doc = Document::new(PathBuf::from("test.mos"));
305        alloc_set_block(&mut doc, "page", &[("margin", AttrValue::Length(-10.0))]);
306
307        let (page, _, diagnostics) = resolve_styles(&doc);
308
309        assert!(
310            diagnostics
311                .iter()
312                .any(|d| d.def().code() == codes::MOS0023.code())
313        );
314        assert!((page.margin - MARGIN_PT).abs() < 0.5);
315    }
316
317    #[test]
318    fn oversized_margin_is_rejected_with_mos0023() {
319        let mut doc = Document::new(PathBuf::from("test.mos"));
320        alloc_set_block(&mut doc, "page", &[("margin", AttrValue::Length(400.0))]);
321
322        let (_, _, diagnostics) = resolve_styles(&doc);
323
324        assert!(
325            diagnostics
326                .iter()
327                .any(|d| d.def().code() == codes::MOS0023.code())
328        );
329    }
330
331    #[test]
332    fn paper_shrink_revalidates_carried_margin() {
333        let mut doc = Document::new(PathBuf::from("test.mos"));
334        alloc_set_block(
335            &mut doc,
336            "page",
337            &[
338                ("paper", AttrValue::Str("A0".to_owned())),
339                ("margin", AttrValue::Length(300.0)),
340            ],
341        );
342        alloc_set_block(
343            &mut doc,
344            "page",
345            &[("paper", AttrValue::Str("A5".to_owned()))],
346        );
347
348        let (page, _, diagnostics) = resolve_styles(&doc);
349
350        assert!(
351            diagnostics
352                .iter()
353                .any(|d| d.def().code() == codes::MOS0023.code()),
354            "expected MOS0023 from paper shrink, got {diagnostics:?}"
355        );
356        assert!((page.width - 2383.94).abs() < 1.0, "w = {}", page.width);
357    }
358
359    #[test]
360    fn earlier_valid_size_survives_later_rejection() {
361        let mut doc = Document::new(PathBuf::from("test.mos"));
362        alloc_set_block(&mut doc, "text", &[("size", AttrValue::Length(50.0))]);
363        alloc_set_block(&mut doc, "text", &[("size", AttrValue::Length(1000.0))]);
364
365        let (_, text, diagnostics) = resolve_styles(&doc);
366
367        assert!(
368            diagnostics
369                .iter()
370                .any(|d| d.def().code() == codes::MOS0023.code())
371        );
372        assert!((text.size_pt - 50.0).abs() < 0.01);
373    }
374
375    #[test]
376    fn page_change_that_invalidates_carried_text_size_is_rejected() {
377        let mut doc = Document::new(PathBuf::from("test.mos"));
378        alloc_set_block(&mut doc, "text", &[("size", AttrValue::Length(100.0))]);
379        alloc_set_block(
380            &mut doc,
381            "page",
382            &[("paper", AttrValue::Str("A8".to_owned()))],
383        );
384
385        let (page, text, diagnostics) = resolve_styles(&doc);
386
387        assert!(
388            diagnostics
389                .iter()
390                .any(|d| d.def().code() == codes::MOS0023.code()
391                    && d.message().contains("page change")),
392            "expected MOS0023 about page change, got {diagnostics:?}"
393        );
394        assert!((page.width - A4_WIDTH_PT).abs() < 0.5);
395        assert!((text.size_pt - 100.0).abs() < 0.01);
396    }
397
398    #[test]
399    fn oversized_text_size_is_rejected_with_mos0023() {
400        let mut doc = Document::new(PathBuf::from("test.mos"));
401        alloc_set_block(&mut doc, "text", &[("size", AttrValue::Length(1000.0))]);
402
403        let (_, text, diagnostics) = resolve_styles(&doc);
404
405        assert!(
406            diagnostics
407                .iter()
408                .any(|d| d.def().code() == codes::MOS0023.code()
409                    && d.message().contains("vertical space")),
410            "expected MOS0023 about vertical space, got {diagnostics:?}"
411        );
412        assert!((text.size_pt - crate::types::BODY_SIZE_PT).abs() < f32::EPSILON);
413    }
414
415    #[test]
416    fn rejected_text_size_says_previous_value_retained() {
417        let mut doc = Document::new(PathBuf::from("test.mos"));
418        alloc_set_block(&mut doc, "text", &[("size", AttrValue::Length(14.0))]);
419        alloc_set_block(&mut doc, "text", &[("size", AttrValue::Length(-1.0))]);
420
421        let (_, _, diagnostics) = resolve_styles(&doc);
422
423        let msg = diagnostics
424            .iter()
425            .find(|d| d.def().code() == codes::MOS0023.code())
426            .expect("MOS0023 emitted")
427            .message();
428        assert!(
429            msg.contains("previous value retained"),
430            "message does not say `previous value retained`: {msg}"
431        );
432    }
433
434    #[test]
435    fn nonpositive_leading_is_rejected_with_mos0023() {
436        let mut doc = Document::new(PathBuf::from("test.mos"));
437        alloc_set_block(&mut doc, "text", &[("leading", AttrValue::Float(0.0))]);
438
439        let (_, _, diagnostics) = resolve_styles(&doc);
440
441        assert!(
442            diagnostics
443                .iter()
444                .any(|d| d.def().code() == codes::MOS0023.code())
445        );
446    }
447
448    #[test]
449    fn unknown_paper_emits_mos0017() {
450        let mut doc = Document::new(PathBuf::from("test.mos"));
451        alloc_set_block(
452            &mut doc,
453            "page",
454            &[("paper", AttrValue::Str("Foolscap".to_owned()))],
455        );
456
457        let (page, _, diagnostics) = resolve_styles(&doc);
458
459        assert!(
460            diagnostics
461                .iter()
462                .any(|d| d.def().code() == codes::MOS0017.code())
463        );
464        assert!((page.width - A4_WIDTH_PT).abs() < 0.5);
465    }
466
467    #[test]
468    fn last_set_wins() {
469        let mut doc = Document::new(PathBuf::from("test.mos"));
470        alloc_set_block(&mut doc, "text", &[("size", AttrValue::Length(8.0))]);
471        alloc_set_block(&mut doc, "text", &[("size", AttrValue::Length(20.0))]);
472
473        let (_, text, diagnostics) = resolve_styles(&doc);
474
475        assert!(diagnostics.is_empty(), "{diagnostics:?}");
476        assert!((text.size_pt - 20.0).abs() < 0.01);
477    }
478
479    #[test]
480    fn paper_size_pt_resolves_iso_a_and_letter() {
481        let (w, h) = paper_size_pt("A4").unwrap();
482        assert!((w - 595.276).abs() < 1.0);
483        assert!((h - 841.89).abs() < 1.0);
484        let (w, h) = paper_size_pt("A5").unwrap();
485        assert!((w - 419.527).abs() < 1.0);
486        assert!((h - 595.276).abs() < 1.0);
487        let (w, h) = paper_size_pt("Letter").unwrap();
488        assert_eq!((w, h), (612.0, 792.0));
489        assert!(paper_size_pt("Foolscap").is_none());
490    }
491}