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