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