Files
wordcraft/crates/doc/src/section.rs
T

421 lines
13 KiB
Rust

//! Section properties: page setup, columns, headers/footers, page numbering.
use serde::{Deserialize, Serialize};
use crate::props::{Borders, VAlign};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum SectionStart {
#[default]
NextPage,
Continuous,
EvenPage,
OddPage,
NextColumn,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum NumFormat {
#[default]
Decimal,
UpperRoman,
LowerRoman,
UpperLetter,
LowerLetter,
Ordinal,
CardinalText,
OrdinalText,
DecimalZero,
Bullet,
None,
}
impl NumFormat {
pub fn ooxml(self) -> &'static str {
match self {
NumFormat::Decimal => "decimal",
NumFormat::UpperRoman => "upperRoman",
NumFormat::LowerRoman => "lowerRoman",
NumFormat::UpperLetter => "upperLetter",
NumFormat::LowerLetter => "lowerLetter",
NumFormat::Ordinal => "ordinal",
NumFormat::CardinalText => "cardinalText",
NumFormat::OrdinalText => "ordinalText",
NumFormat::DecimalZero => "decimalZero",
NumFormat::Bullet => "bullet",
NumFormat::None => "none",
}
}
pub fn from_ooxml(s: &str) -> NumFormat {
[
NumFormat::Decimal,
NumFormat::UpperRoman,
NumFormat::LowerRoman,
NumFormat::UpperLetter,
NumFormat::LowerLetter,
NumFormat::Ordinal,
NumFormat::CardinalText,
NumFormat::OrdinalText,
NumFormat::DecimalZero,
NumFormat::Bullet,
NumFormat::None,
]
.into_iter()
.find(|f| f.ooxml() == s)
.unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based).
pub fn format(self, n: u32) -> String {
match self {
NumFormat::Decimal => n.to_string(),
NumFormat::DecimalZero => format!("{n:02}"),
NumFormat::UpperRoman => roman(n).to_uppercase(),
NumFormat::LowerRoman => roman(n),
NumFormat::UpperLetter => letters(n).to_uppercase(),
NumFormat::LowerLetter => letters(n),
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
NumFormat::CardinalText => cardinal_text(n),
NumFormat::OrdinalText => ordinal_text(n),
NumFormat::Bullet | NumFormat::None => String::new(),
}
}
}
fn ordinal_suffix(n: u32) -> &'static str {
match (n % 10, n % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
}
}
/// Lowercase roman numerals (0 → empty; values above 3999 use repeated `m`).
pub fn roman(mut n: u32) -> String {
const T: [(u32, &str); 13] = [
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut s = String::new();
n = n.min(50_000);
for (v, r) in T {
while n >= v {
s.push_str(r);
n -= v;
}
}
s
}
/// Word-style letters: a..z, aa..zz, aaa.. (repeated letter, not base-26).
pub fn letters(n: u32) -> String {
if n == 0 {
return String::new();
}
let n = n.min(780);
let k = (n - 1) / 26 + 1;
let c = (b'a' + ((n - 1) % 26) as u8) as char;
std::iter::repeat_n(c, k as usize).collect()
}
const ONES: [&str; 20] = [
"zero",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
"thirteen",
"fourteen",
"fifteen",
"sixteen",
"seventeen",
"eighteen",
"nineteen",
];
const TENS: [&str; 10] = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
/// English cardinal words, capitalised first letter (`Twenty-One`).
pub fn cardinal_text(n: u32) -> String {
fn words(n: u32) -> String {
match n {
0..=19 => ONES.get(n as usize).copied().unwrap_or("").to_string(),
20..=99 => {
let t = TENS.get((n / 10) as usize).copied().unwrap_or("");
if n.is_multiple_of(10) { t.to_string() } else { format!("{t}-{}", ONES.get((n % 10) as usize).copied().unwrap_or("")) }
}
100..=999 => {
let h = format!("{} hundred", words(n / 100));
if n.is_multiple_of(100) { h } else { format!("{h} {}", words(n % 100)) }
}
_ => {
let th = format!("{} thousand", words(n / 1000));
if n.is_multiple_of(1000) { th } else { format!("{th} {}", words(n % 1000)) }
}
}
}
capitalize(&words(n.min(999_999)))
}
/// English ordinal words (`Twenty-First`).
pub fn ordinal_text(n: u32) -> String {
let c = cardinal_text(n).to_lowercase();
let (head, last) = match c.rfind(['-', ' ']) {
Some(i) => (c.get(..=i).unwrap_or(""), c.get(i + 1..).unwrap_or("")),
None => ("", c.as_str()),
};
let last = match last {
"one" => "first".to_string(),
"two" => "second".to_string(),
"three" => "third".to_string(),
"five" => "fifth".to_string(),
"eight" => "eighth".to_string(),
"nine" => "ninth".to_string(),
"twelve" => "twelfth".to_string(),
l if l.ends_with('y') => format!("{}ieth", l.trim_end_matches('y')),
l => format!("{l}th"),
};
capitalize(&format!("{head}{last}"))
}
fn capitalize(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut up = true;
for c in s.chars() {
if up {
out.extend(c.to_uppercase());
} else {
out.push(c);
}
up = c == '-' || c == ' ';
}
out
}
/// Header/footer story ids (in `Document::parts`) for a section.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct HeaderSet {
pub default: Option<u32>,
pub first: Option<u32>,
pub even: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Columns {
pub count: u32,
/// Space between columns, points.
pub space: f32,
/// Line between columns.
pub separator: bool,
/// Unequal widths (width, space after) per column; empty = equal.
pub widths: Vec<(f32, f32)>,
}
impl Default for Columns {
fn default() -> Self {
Columns { count: 1, space: 36.0, separator: false, widths: Vec::new() }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LineNumbering {
pub count_by: u32,
pub start: u32,
/// Distance from text, points (0 = auto).
pub distance: f32,
/// Restart each page / section / continuous.
pub restart: LineNumberRestart,
}
impl Default for LineNumbering {
fn default() -> Self {
LineNumbering { count_by: 1, start: 1, distance: 0.0, restart: LineNumberRestart::Page }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum LineNumberRestart {
#[default]
Page,
Section,
Continuous,
}
/// Page setup and section-level settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SectionProps {
pub start: SectionStart,
/// Page width/height, points (already swapped for landscape).
pub page_w: f32,
pub page_h: f32,
pub landscape: bool,
pub margin_top: f32,
pub margin_bottom: f32,
pub margin_left: f32,
pub margin_right: f32,
/// Distance from the page edge to the header / footer.
pub header: f32,
pub footer: f32,
pub gutter: f32,
/// Mirror margins (inside/outside) — set from the document settings.
pub columns: Columns,
pub headers: HeaderSet,
pub footers: HeaderSet,
/// Different first page header/footer.
pub title_page: bool,
pub page_num_start: Option<u32>,
pub page_num_format: NumFormat,
pub line_numbers: Option<LineNumbering>,
pub valign: VAlign,
pub page_borders: Option<Borders>,
/// Text direction / bidi section.
pub rtl: bool,
}
impl Default for SectionProps {
/// Word's default: US Letter, 1" margins, 0.5" header/footer distance.
fn default() -> Self {
SectionProps {
start: SectionStart::NextPage,
page_w: 612.0,
page_h: 792.0,
landscape: false,
margin_top: 72.0,
margin_bottom: 72.0,
margin_left: 72.0,
margin_right: 72.0,
header: 36.0,
footer: 36.0,
gutter: 0.0,
columns: Columns::default(),
headers: HeaderSet::default(),
footers: HeaderSet::default(),
title_page: false,
page_num_start: None,
page_num_format: NumFormat::Decimal,
line_numbers: None,
valign: VAlign::Top,
page_borders: None,
rtl: false,
}
}
}
impl SectionProps {
/// Width of the text area, points.
pub fn text_width(&self) -> f32 {
(self.page_w - self.margin_left - self.margin_right - self.gutter).max(18.0)
}
pub fn text_height(&self) -> f32 {
(self.page_h - self.margin_top - self.margin_bottom).max(18.0)
}
/// Swap to landscape/portrait (keeps margins relative to the paper as Word does: rotates them).
pub fn set_landscape(&mut self, on: bool) {
if on == self.landscape {
return;
}
self.landscape = on;
std::mem::swap(&mut self.page_w, &mut self.page_h);
let (t, b, l, r) = (self.margin_top, self.margin_bottom, self.margin_left, self.margin_right);
if on {
self.margin_top = l;
self.margin_bottom = r;
self.margin_left = b;
self.margin_right = t;
} else {
self.margin_left = t;
self.margin_right = b;
self.margin_bottom = l;
self.margin_top = r;
}
}
/// Column (x offset from the text area's left, width) for each column.
pub fn column_boxes(&self) -> Vec<(f32, f32)> {
let tw = self.text_width();
let n = self.columns.count.clamp(1, 45) as usize;
if !self.columns.widths.is_empty() && self.columns.widths.len() == n {
let mut x = 0.0;
return self
.columns
.widths
.iter()
.map(|(w, sp)| {
let b = (x, w.max(18.0));
x += w + sp;
b
})
.collect();
}
let space = self.columns.space.max(0.0);
let w = ((tw - space * (n as f32 - 1.0)) / n as f32).max(18.0);
(0..n).map(|i| (i as f32 * (w + space), w)).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn number_formats() {
assert_eq!(NumFormat::UpperRoman.format(1994), "MCMXCIV");
assert_eq!(NumFormat::LowerLetter.format(27), "aa");
assert_eq!(NumFormat::LowerLetter.format(0), "");
assert_eq!(NumFormat::Ordinal.format(12), "12th");
assert_eq!(NumFormat::Ordinal.format(22), "22nd");
assert_eq!(NumFormat::CardinalText.format(21), "Twenty-One");
assert_eq!(NumFormat::OrdinalText.format(21), "Twenty-First");
assert_eq!(NumFormat::OrdinalText.format(20), "Twentieth");
assert_eq!(NumFormat::OrdinalText.format(112), "One Hundred Twelfth");
assert_eq!(NumFormat::DecimalZero.format(3), "03");
assert!(!NumFormat::UpperRoman.format(u32::MAX).is_empty());
assert_eq!(NumFormat::LowerLetter.format(u32::MAX).len(), 30);
}
#[test]
fn landscape_swaps() {
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
s.set_landscape(true);
assert_eq!((s.page_w, s.page_h), (792.0, 612.0));
assert_eq!(s.margin_top, 50.0);
s.set_landscape(false);
assert_eq!(s.margin_left, 50.0);
assert_eq!((s.page_w, s.page_h), (612.0, 792.0));
}
#[test]
fn columns_split() {
let s = SectionProps { columns: Columns { count: 2, space: 36.0, ..Default::default() }, ..Default::default() };
let b = s.column_boxes();
assert_eq!(b.len(), 2);
assert_eq!(b[0], (0.0, 216.0));
assert_eq!(b[1], (252.0, 216.0));
}
}