Files
wordcraft/crates/doc/src/section.rs
T
Marco ArnulfoandBrandon Thomas 7bd3eb8ca7 Backspace and Delete remove a section break, imported or not (#2133)
* Backspace and Delete remove a section break, imported or not (fixes #2127)

* Delete removes a section break on the last paragraph too (#2127)

A continuous break on the mark of the document's last paragraph, with the
last section holding no paragraph (as in the file attached to #2127), had
nothing for Delete to join, so it stayed. Delete at the end now removes it.
The text takes the last section's setup, as in Word, and the headers and
footers that section showed by linking to the removed one stay, here and
when the break goes by joining two paragraphs. Undo brings it back.

* Deleting over several section breaks keeps the headers they linked to (#2127)

A range across two or more breaks dropped the paragraphs in between with
their sections, so only the first section's headers and footers were carried
over. The joined section now takes those it leaves out from every removed
section, nearest first, as "link to previous" resolved them before the delete.
The typing-parity row says that Delete with Track Changes on still leaves a
break on the last paragraph alone.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:00:49 -04:00

972 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Section properties: page setup, columns, headers/footers, page numbering.
use serde::{Deserialize, Serialize};
use crate::props::Borders;
/// How a section's text sits on its pages, top to bottom (`w:vAlign`, ECMA-376 §17.6.23,
/// ST_VerticalJc). Table cells and text boxes use [`crate::props::VAlign`], which has no
/// Justified.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PageVAlign {
#[default]
Top,
Center,
/// Justified (`both`): the room left on each page is shared out equally between the page's
/// paragraphs and tables, so the text runs from the top margin to the bottom one.
#[serde(alias = "both", alias = "justified")]
Justify,
Bottom,
}
impl PageVAlign {
/// From a command or file value: `top|center|justify|bottom` (also `both`, `justified`,
/// `middle`); anything else is Top.
pub fn parse(v: &str) -> PageVAlign {
match v {
"center" | "middle" => PageVAlign::Center,
"justify" | "justified" | "both" => PageVAlign::Justify,
"bottom" => PageVAlign::Bottom,
_ => PageVAlign::Top,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum SectionStart {
#[default]
NextPage,
Continuous,
EvenPage,
OddPage,
NextColumn,
}
/// A number format (ECMA-376 §17.18.59 ST_NumberFormat). Every value the format defines is
/// kept, so a document saves back the format it was opened with even where WordCraft draws it as
/// plain decimal (see [`NumFormat::format`]).
#[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,
Hex,
Chicago,
IdeographDigital,
JapaneseCounting,
Aiueo,
Iroha,
DecimalFullWidth,
DecimalHalfWidth,
JapaneseLegal,
JapaneseDigitalTenThousand,
DecimalEnclosedCircle,
DecimalFullWidth2,
AiueoFullWidth,
IrohaFullWidth,
Ganada,
Chosung,
DecimalEnclosedFullstop,
DecimalEnclosedParen,
DecimalEnclosedCircleChinese,
IdeographEnclosedCircle,
IdeographTraditional,
IdeographZodiac,
IdeographZodiacTraditional,
TaiwaneseCounting,
IdeographLegalTraditional,
TaiwaneseCountingThousand,
TaiwaneseDigital,
ChineseCounting,
ChineseLegalSimplified,
ChineseCountingThousand,
KoreanDigital,
KoreanCounting,
KoreanLegal,
KoreanDigital2,
VietnameseCounting,
RussianLower,
RussianUpper,
NumberInDash,
Hebrew1,
Hebrew2,
ArabicAlpha,
ArabicAbjad,
HindiVowels,
HindiConsonants,
HindiNumbers,
HindiCounting,
ThaiLetters,
ThaiNumbers,
ThaiCounting,
BahtText,
DollarText,
}
impl NumFormat {
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
/// binary .doc number format codes up to 0x27).
pub const ALL: [NumFormat; 62] = [
NumFormat::Decimal,
NumFormat::UpperRoman,
NumFormat::LowerRoman,
NumFormat::UpperLetter,
NumFormat::LowerLetter,
NumFormat::Ordinal,
NumFormat::CardinalText,
NumFormat::OrdinalText,
NumFormat::Hex,
NumFormat::Chicago,
NumFormat::IdeographDigital,
NumFormat::JapaneseCounting,
NumFormat::Aiueo,
NumFormat::Iroha,
NumFormat::DecimalFullWidth,
NumFormat::DecimalHalfWidth,
NumFormat::JapaneseLegal,
NumFormat::JapaneseDigitalTenThousand,
NumFormat::DecimalEnclosedCircle,
NumFormat::DecimalFullWidth2,
NumFormat::AiueoFullWidth,
NumFormat::IrohaFullWidth,
NumFormat::DecimalZero,
NumFormat::Bullet,
NumFormat::Ganada,
NumFormat::Chosung,
NumFormat::DecimalEnclosedFullstop,
NumFormat::DecimalEnclosedParen,
NumFormat::DecimalEnclosedCircleChinese,
NumFormat::IdeographEnclosedCircle,
NumFormat::IdeographTraditional,
NumFormat::IdeographZodiac,
NumFormat::IdeographZodiacTraditional,
NumFormat::TaiwaneseCounting,
NumFormat::IdeographLegalTraditional,
NumFormat::TaiwaneseCountingThousand,
NumFormat::TaiwaneseDigital,
NumFormat::ChineseCounting,
NumFormat::ChineseLegalSimplified,
NumFormat::ChineseCountingThousand,
NumFormat::KoreanDigital,
NumFormat::KoreanCounting,
NumFormat::KoreanLegal,
NumFormat::KoreanDigital2,
NumFormat::VietnameseCounting,
NumFormat::RussianLower,
NumFormat::RussianUpper,
NumFormat::None,
NumFormat::NumberInDash,
NumFormat::Hebrew1,
NumFormat::Hebrew2,
NumFormat::ArabicAlpha,
NumFormat::ArabicAbjad,
NumFormat::HindiVowels,
NumFormat::HindiConsonants,
NumFormat::HindiNumbers,
NumFormat::HindiCounting,
NumFormat::ThaiLetters,
NumFormat::ThaiNumbers,
NumFormat::ThaiCounting,
NumFormat::BahtText,
NumFormat::DollarText,
];
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",
NumFormat::Hex => "hex",
NumFormat::Chicago => "chicago",
NumFormat::IdeographDigital => "ideographDigital",
NumFormat::JapaneseCounting => "japaneseCounting",
NumFormat::Aiueo => "aiueo",
NumFormat::Iroha => "iroha",
NumFormat::DecimalFullWidth => "decimalFullWidth",
NumFormat::DecimalHalfWidth => "decimalHalfWidth",
NumFormat::JapaneseLegal => "japaneseLegal",
NumFormat::JapaneseDigitalTenThousand => "japaneseDigitalTenThousand",
NumFormat::DecimalEnclosedCircle => "decimalEnclosedCircle",
NumFormat::DecimalFullWidth2 => "decimalFullWidth2",
NumFormat::AiueoFullWidth => "aiueoFullWidth",
NumFormat::IrohaFullWidth => "irohaFullWidth",
NumFormat::Ganada => "ganada",
NumFormat::Chosung => "chosung",
NumFormat::DecimalEnclosedFullstop => "decimalEnclosedFullstop",
NumFormat::DecimalEnclosedParen => "decimalEnclosedParen",
NumFormat::DecimalEnclosedCircleChinese => "decimalEnclosedCircleChinese",
NumFormat::IdeographEnclosedCircle => "ideographEnclosedCircle",
NumFormat::IdeographTraditional => "ideographTraditional",
NumFormat::IdeographZodiac => "ideographZodiac",
NumFormat::IdeographZodiacTraditional => "ideographZodiacTraditional",
NumFormat::TaiwaneseCounting => "taiwaneseCounting",
NumFormat::IdeographLegalTraditional => "ideographLegalTraditional",
NumFormat::TaiwaneseCountingThousand => "taiwaneseCountingThousand",
NumFormat::TaiwaneseDigital => "taiwaneseDigital",
NumFormat::ChineseCounting => "chineseCounting",
NumFormat::ChineseLegalSimplified => "chineseLegalSimplified",
NumFormat::ChineseCountingThousand => "chineseCountingThousand",
NumFormat::KoreanDigital => "koreanDigital",
NumFormat::KoreanCounting => "koreanCounting",
NumFormat::KoreanLegal => "koreanLegal",
NumFormat::KoreanDigital2 => "koreanDigital2",
NumFormat::VietnameseCounting => "vietnameseCounting",
NumFormat::RussianLower => "russianLower",
NumFormat::RussianUpper => "russianUpper",
NumFormat::NumberInDash => "numberInDash",
NumFormat::Hebrew1 => "hebrew1",
NumFormat::Hebrew2 => "hebrew2",
NumFormat::ArabicAlpha => "arabicAlpha",
NumFormat::ArabicAbjad => "arabicAbjad",
NumFormat::HindiVowels => "hindiVowels",
NumFormat::HindiConsonants => "hindiConsonants",
NumFormat::HindiNumbers => "hindiNumbers",
NumFormat::HindiCounting => "hindiCounting",
NumFormat::ThaiLetters => "thaiLetters",
NumFormat::ThaiNumbers => "thaiNumbers",
NumFormat::ThaiCounting => "thaiCounting",
NumFormat::BahtText => "bahtText",
NumFormat::DollarText => "dollarText",
}
}
/// The format an ST_NumberFormat value names; a value the format doesn't define is decimal
/// (as Word shows it). So is `custom` (a Word 2010 format string, always written with a
/// decimal fallback).
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
use crate::cjk_numbers as cjk;
match self {
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::RussianUpper => russian_letters(n).to_uppercase(),
NumFormat::RussianLower => russian_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(),
NumFormat::Chicago => chicago(n),
NumFormat::NumberInDash => format!("- {n} -"),
NumFormat::IdeographDigital | NumFormat::KoreanDigital2 | NumFormat::TaiwaneseDigital => cjk::ideograph_digital(n),
NumFormat::JapaneseCounting => cjk::japanese_counting(n),
NumFormat::JapaneseLegal => cjk::japanese_legal(n),
NumFormat::JapaneseDigitalTenThousand => cjk::japanese_digital_ten_thousand(n),
NumFormat::Aiueo => cjk::aiueo(n),
NumFormat::AiueoFullWidth => cjk::aiueo_full_width(n),
NumFormat::Iroha => cjk::iroha(n),
NumFormat::IrohaFullWidth => cjk::iroha_full_width(n),
NumFormat::DecimalFullWidth | NumFormat::DecimalFullWidth2 => cjk::decimal_full_width(n),
NumFormat::DecimalEnclosedCircle => cjk::decimal_enclosed_circle(n),
NumFormat::DecimalEnclosedCircleChinese => cjk::decimal_enclosed_circle_chinese(n),
NumFormat::DecimalEnclosedFullstop => cjk::decimal_enclosed_fullstop(n),
NumFormat::DecimalEnclosedParen => cjk::decimal_enclosed_paren(n),
NumFormat::IdeographEnclosedCircle => cjk::ideograph_enclosed_circle(n),
NumFormat::IdeographTraditional => cjk::ideograph_traditional(n),
NumFormat::IdeographZodiac => cjk::ideograph_zodiac(n),
NumFormat::IdeographZodiacTraditional => cjk::ideograph_zodiac_traditional(n),
NumFormat::IdeographLegalTraditional => cjk::ideograph_legal_traditional(n),
NumFormat::TaiwaneseCounting => cjk::taiwanese_counting(n),
NumFormat::TaiwaneseCountingThousand => cjk::taiwanese_counting_thousand(n),
NumFormat::ChineseCounting => cjk::chinese_counting(n),
NumFormat::ChineseCountingThousand => cjk::chinese_counting_thousand(n),
NumFormat::ChineseLegalSimplified => cjk::chinese_legal_simplified(n),
NumFormat::KoreanCounting => cjk::korean_counting(n),
NumFormat::KoreanDigital => cjk::korean_digital(n),
NumFormat::KoreanLegal => cjk::korean_legal(n),
NumFormat::Ganada => cjk::ganada(n),
NumFormat::Chosung => cjk::chosung(n),
NumFormat::Decimal
| NumFormat::DecimalHalfWidth
| NumFormat::Hex
| NumFormat::VietnameseCounting
| NumFormat::Hebrew1
| NumFormat::Hebrew2
| NumFormat::ArabicAlpha
| NumFormat::ArabicAbjad
| NumFormat::HindiVowels
| NumFormat::HindiConsonants
| NumFormat::HindiNumbers
| NumFormat::HindiCounting
| NumFormat::ThaiLetters
| NumFormat::ThaiNumbers
| NumFormat::ThaiCounting
| NumFormat::BahtText
| NumFormat::DollarText => n.to_string(),
}
}
}
/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on.
fn chicago(n: u32) -> String {
const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}'];
if n == 0 {
return String::new();
}
let i = (n - 1) as usize;
let k = (i / 4 + 1).min(10);
std::iter::repeat_n(MARKS.get(i % 4).copied().unwrap_or('*'), k).collect()
}
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()
}
/// The 29 letters Word counts Russian lists with: а..я without ё, й, ъ and ь (ы stays).
const RUSSIAN_LETTERS: [char; 29] =
['а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ы', 'э', 'ю', 'я'];
/// Word-style Russian letters: а..я, аа..яя, ааа.. (repeated letter, as [`letters`]).
pub fn russian_letters(n: u32) -> String {
if n == 0 {
return String::new();
}
let len = RUSSIAN_LETTERS.len() as u32;
let n = n.min(len * 30);
let k = (n - 1) / len + 1;
let c = RUSSIAN_LETTERS.get(((n - 1) % len) as usize).copied().unwrap_or('а');
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
}
/// Where footnotes or endnotes are placed (ECMA-376 §17.18.33/§17.18.20, `ST_FtnPos`/`ST_EdnPos`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum NotePos {
/// Footnotes: at the bottom of the page.
#[default]
PageBottom,
/// Footnotes: right below the page's last line of text.
BeneathText,
/// Endnotes: at the end of each section.
SectEnd,
/// Endnotes: at the end of the document.
DocEnd,
}
impl NotePos {
pub fn ooxml(self) -> &'static str {
match self {
NotePos::PageBottom => "pageBottom",
NotePos::BeneathText => "beneathText",
NotePos::SectEnd => "sectEnd",
NotePos::DocEnd => "docEnd",
}
}
pub fn from_ooxml(s: &str) -> Option<NotePos> {
[NotePos::PageBottom, NotePos::BeneathText, NotePos::SectEnd, NotePos::DocEnd].into_iter().find(|p| p.ooxml() == s)
}
}
/// When note numbering starts again (ECMA-376 §17.18.66, `ST_RestartNumber`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum NoteRestart {
#[default]
Continuous,
EachSect,
EachPage,
}
impl NoteRestart {
pub fn ooxml(self) -> &'static str {
match self {
NoteRestart::Continuous => "continuous",
NoteRestart::EachSect => "eachSect",
NoteRestart::EachPage => "eachPage",
}
}
pub fn from_ooxml(s: &str) -> Option<NoteRestart> {
[NoteRestart::Continuous, NoteRestart::EachSect, NoteRestart::EachPage].into_iter().find(|r| r.ooxml() == s)
}
}
/// The highest note number "start at" takes (Word's own limit is lower; this keeps hostile files
/// from overflowing the counters).
pub const MAX_NOTE_START: u32 = 32_767;
/// Footnote or endnote options as a document or a section states them (ECMA-376 §17.11.11 and
/// §17.11.4, `w:footnotePr` / `w:endnotePr`): unset fields come from the level above (a
/// section's from the document's, the document's from Word's defaults). The document's number
/// format is `Settings::footnote_format` / `endnote_format`, so its `num_fmt` stays unset.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct NoteProps {
#[serde(skip_serializing_if = "Option::is_none")]
pub pos: Option<NotePos>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_fmt: Option<NumFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_start: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_restart: Option<NoteRestart>,
}
impl NoteProps {
pub fn is_empty(&self) -> bool {
*self == NoteProps::default()
}
}
/// Footnote or endnote options in effect for a section (see [`crate::Document::note_options`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NoteOptions {
pub pos: NotePos,
pub num_fmt: NumFormat,
/// 1..=[`MAX_NOTE_START`].
pub num_start: u32,
pub num_restart: NoteRestart,
}
/// 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,
}
/// Document grid kind (`w:docGrid/@w:type`, ECMA-376 §17.6.5, §17.18.14).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum DocGridType {
/// No grid.
#[default]
Default,
/// Line grid only: line heights snap to the line pitch.
Lines,
/// Line and character grid: East Asian characters advance by the character pitch.
LinesAndChars,
/// Line grid, and all text snaps to the character grid.
SnapToChars,
}
impl DocGridType {
pub fn ooxml(self) -> &'static str {
match self {
DocGridType::Default => "default",
DocGridType::Lines => "lines",
DocGridType::LinesAndChars => "linesAndChars",
DocGridType::SnapToChars => "snapToChars",
}
}
pub fn from_ooxml(s: &str) -> Option<DocGridType> {
[DocGridType::Default, DocGridType::Lines, DocGridType::LinesAndChars, DocGridType::SnapToChars].into_iter().find(|t| t.ooxml() == s)
}
}
/// Smallest and largest line or character pitch the layout uses, points.
pub const MIN_GRID_PITCH: f32 = 1.0;
pub const MAX_GRID_PITCH: f32 = 1584.0;
/// `w:charSpace` bound (4096ths of a point): ±1584 pt.
pub const MAX_CHAR_SPACE: i32 = 1584 * 4096;
/// The document grid (`w:docGrid`): lines per page and characters per line, as pitches.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocGrid {
#[serde(rename = "type")]
pub kind: DocGridType,
/// Distance between grid lines, points (`w:linePitch`, twips in the file).
pub line_pitch: f32,
/// Added to the default font size to give the character pitch, 4096ths of a point
/// (`w:charSpace`).
pub char_space: i32,
}
impl Default for DocGrid {
/// Word's grid for a new document: no grid, an 18 pt pitch kept for when one is turned on.
fn default() -> Self {
DocGrid { kind: DocGridType::Default, line_pitch: 18.0, char_space: 0 }
}
}
impl DocGrid {
/// The line pitch, clamped to something layout can use.
pub fn line_pitch(&self) -> f32 {
if self.line_pitch.is_finite() { self.line_pitch.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH) } else { 18.0 }
}
/// The character pitch for a document whose default font is `font_size` points.
pub fn char_pitch(&self, font_size: f32) -> f32 {
let size = if font_size.is_finite() { font_size.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH) } else { 10.5 };
(size + self.char_space.clamp(-MAX_CHAR_SPACE, MAX_CHAR_SPACE) as f32 / 4096.0).clamp(MIN_GRID_PITCH, MAX_GRID_PITCH)
}
/// The `w:charSpace` that gives `pitch` with the default font at `font_size` points.
pub fn char_space_for(pitch: f32, font_size: f32) -> i32 {
let pitch = if pitch.is_finite() { pitch.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH) } else { font_size };
let size = if font_size.is_finite() { font_size.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH) } else { 10.5 };
(((pitch - size) * 4096.0).round() as i32).clamp(-MAX_CHAR_SPACE, MAX_CHAR_SPACE)
}
}
/// 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,
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
/// format), so saving writes it back rather than dropping it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub page_num_set: bool,
pub line_numbers: Option<LineNumbering>,
pub valign: PageVAlign,
/// Form protection of the section (`w:formProt`, ECMA-376 §17.6.6): `Some` when the file
/// states it. Kept so saving writes it back; editing does not act on it.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_prot: Option<bool>,
pub page_borders: Option<Borders>,
/// Text direction / bidi section.
pub rtl: bool,
/// Which way the section's text runs (`w:textDirection`, ECMA-376 §17.6.20): vertical
/// sections turn the page's text area (headers and footers stay horizontal).
#[serde(skip_serializing_if = "is_horizontal")]
pub text_direction: crate::props::TextDirection,
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub text_direction_set: bool,
/// Tracked change of the section's properties (`w:sectPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<crate::props::PropChange<SectionProps>>>,
/// Document grid (`w:docGrid`); `None` when the file has none (no grid).
pub doc_grid: Option<DocGrid>,
/// The section's own footnote and endnote options (unset: the document's).
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
}
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,
page_num_set: false,
line_numbers: None,
valign: PageVAlign::Top,
form_prot: None,
page_borders: None,
rtl: false,
text_direction: crate::props::TextDirection::Horizontal,
text_direction_set: false,
fmt_change: None,
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
}
}
}
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
!d.is_turned()
}
impl SectionProps {
/// Join the section before this one into it, as when the break between them is deleted:
/// this section's setup stays, as in Word, and the headers and footers it leaves out (linked
/// to the previous section) are taken from `before`, so its text keeps showing them.
pub fn absorb(&mut self, before: &SectionProps) {
for (own, prev) in [(&mut self.headers, &before.headers), (&mut self.footers, &before.footers)] {
own.default = own.default.or(prev.default);
own.first = own.first.or(prev.first);
own.even = own.even.or(prev.even);
}
}
/// 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;
}
}
/// The line grid's pitch, points, when the section has a line grid (any grid but "no grid").
pub fn grid_line_pitch(&self) -> Option<f32> {
self.doc_grid.filter(|g| g.kind != DocGridType::Default).map(|g| g.line_pitch())
}
/// The character grid's pitch, points, when the section has one (`linesAndChars`,
/// `snapToChars`); `font_size` is the default font size (Normal style).
pub fn grid_char_pitch(&self, font_size: f32) -> Option<f32> {
self.doc_grid.filter(|g| matches!(g.kind, DocGridType::LinesAndChars | DocGridType::SnapToChars)).map(|g| g.char_pitch(font_size))
}
/// Lines per page the line pitch gives (at least 1).
pub fn grid_lines_per_page(&self) -> u32 {
let pitch = self.doc_grid.unwrap_or_default().line_pitch();
((self.text_height() / pitch + 0.001).floor() as u32).max(1)
}
/// Characters per line the character pitch gives (at least 1).
pub fn grid_chars_per_line(&self, font_size: f32) -> u32 {
let pitch = self.doc_grid.unwrap_or_default().char_pitch(font_size);
((self.text_width() / pitch + 0.001).floor() as u32).max(1)
}
/// Column (x offset from the text area's left, width) for each column, in reading order:
/// in a right-to-left section (`rtl`, `w:bidi`) the first column stands at the right edge
/// and the columns (with the space after each) run leftwards.
pub fn column_boxes(&self) -> Vec<(f32, f32)> {
let tw = self.text_width();
let n = self.columns.count.clamp(1, 45) as usize;
let boxes: Vec<(f32, f32)> = if !self.columns.widths.is_empty() && self.columns.widths.len() == n {
let mut x = 0.0;
self.columns
.widths
.iter()
.map(|(w, sp)| {
let b = (x, w.max(18.0));
x += w + sp;
b
})
.collect()
} else {
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()
};
if self.rtl { boxes.into_iter().map(|(x, w)| (tw - x - w, w)).collect() } else { boxes }
}
}
#[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);
assert_eq!([NumFormat::Chicago.format(1), NumFormat::Chicago.format(6)], ["*", "\u{2020}\u{2020}"]);
assert_eq!(NumFormat::ChineseCounting.format(11), "十一");
assert_eq!(NumFormat::ChineseCountingThousand.format(101), "一百零一");
assert_eq!(NumFormat::DecimalEnclosedCircle.format(3), "③");
assert_eq!(NumFormat::Hebrew1.format(3), "3", "formats not drawn yet show decimal");
assert_eq!(NumFormat::RussianLower.format(0), "");
assert_eq!(NumFormat::RussianUpper.format(u32::MAX).chars().count(), 30);
for f in NumFormat::ALL {
assert_eq!(NumFormat::from_ooxml(f.ooxml()), f, "{f:?} round-trips");
assert!(f == NumFormat::Bullet || f == NumFormat::None || !f.format(u32::MAX).is_empty(), "{f:?}");
}
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
}
#[test]
fn russian_letters_follow_word() {
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
let labels: Vec<String> = (1..=31).map(|n| NumFormat::RussianLower.format(n)).collect();
assert_eq!(labels.join(" "), "а б в г д е ж з и к л м н о п р с т у ф х ц ч ш щ ы э ю я аа бб");
assert_eq!(NumFormat::RussianLower.format(59), "ааа");
assert_eq!(NumFormat::RussianLower.format(100), "нннн");
assert_eq!(NumFormat::RussianUpper.format(26), "Ы");
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
}
#[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 grid_pitches_are_clamped() {
let mut s = SectionProps::default();
assert_eq!(s.grid_line_pitch(), None);
s.doc_grid = Some(DocGrid { kind: DocGridType::Lines, line_pitch: 0.0, char_space: i32::MIN });
assert_eq!(s.grid_line_pitch(), Some(MIN_GRID_PITCH));
assert_eq!(s.grid_char_pitch(10.5), None);
s.doc_grid = Some(DocGrid { kind: DocGridType::LinesAndChars, line_pitch: f32::NAN, char_space: i32::MIN });
assert_eq!(s.grid_line_pitch(), Some(18.0));
assert_eq!(s.grid_char_pitch(10.5), Some(MIN_GRID_PITCH));
// 648 pt of text height at an 18 pt pitch: 36 lines; 468 pt / 13 pt = 36 characters.
s.doc_grid = Some(DocGrid { kind: DocGridType::LinesAndChars, line_pitch: 18.0, char_space: DocGrid::char_space_for(13.0, 10.5) });
assert_eq!(s.grid_lines_per_page(), 36);
assert_eq!(s.grid_chars_per_line(10.5), 36);
}
#[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));
}
#[test]
fn rtl_columns_run_right_to_left() {
let s = SectionProps { rtl: true, columns: Columns { count: 2, space: 36.0, ..Default::default() }, ..Default::default() };
assert_eq!(s.column_boxes(), vec![(252.0, 216.0), (0.0, 216.0)]);
let s = SectionProps {
rtl: true,
columns: Columns { count: 2, widths: vec![(100.0, 20.0), (200.0, 0.0)], ..Default::default() },
..Default::default()
};
// Text width 468: the first column hugs the right edge, the second sits 20pt to its left.
assert_eq!(s.column_boxes(), vec![(368.0, 100.0), (148.0, 200.0)]);
}
}