1use mos_core::{AttrValue, Diagnostic, Document, Node, NodeKind, codes};
2use mos_fonts::FontFamily;
3
4use crate::{PageStyle, TextStyle};
5
6pub(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 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 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 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 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 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
153fn reject(node: &Node, message: String) -> Diagnostic {
157 Diagnostic::simple(&codes::MOS0023, None, message).with_span(node.span.clone())
158}
159
160#[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#[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}