use super::header_footer::hf_part; use super::{ Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value, delete_selection, json, p, para_block, sel_result, type_text, }; pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult { let position = p::str(v, "position").unwrap_or("bottom"); let align = match p::str(v, "align").unwrap_or("center") { "left" => Align::Left, "right" => Align::Right, _ => Align::Center, }; let x_of_y = p::str(v, "format").is_some_and(|f| f.contains("of")); let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }; if position == "current" { let props = s.typing_props(); let at = delete_selection(s)?; let end = s.doc.insert_object(&at, field, &props)?; s.sel = Selection::caret(end); return sel_result(s); } let header = position == "top"; let id = hf_part(s, header); let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" }); para.props.align = Some(align); para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]); if x_of_y { para.insert_text(0, "Page of ", &CharProps::default())?; para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?; let end = para.len(); para.insert_object( end, InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps { bold: Some(true), ..Default::default() }, )?; } else { para.insert_object(0, field, &CharProps::default())?; } s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?; Ok(json!({"story": id})) } pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult { let fmt = p::str(v, "format").unwrap_or("M/d/yyyy"); let text = format_date(fmt); if p::bool(v, "update").unwrap_or(false) { let props = s.typing_props(); let at = delete_selection(s)?; let end = s.doc.insert_object( &at, InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false, dirty: false, code: false, form: String::new() }, &props, )?; s.sel = Selection::caret(end); } else { type_text(s, &text)?; } sel_result(s) } /// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…). pub fn format_date(fmt: &str) -> String { // Word shows the computer's local date and time (#1707), not UTC's. let now = crate::cmd::now_unix(); format_date_at(fmt, now, crate::cmd::local_offset_secs(now)) } /// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone /// `offset_secs` east of UTC. pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String { let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs); format_date_parts(fmt, y, m as usize, d, hms, days) } /// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday. pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String { const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"]; let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January"); let day = DAYS.get(days_since.rem_euclid(7) as usize).copied().unwrap_or("Monday"); let mut out = String::new(); let chars: Vec = fmt.chars().collect(); let mut i = 0; while i < chars.len() { let c = chars.get(i).copied().unwrap_or(' '); // Text in single quotes is shown as it is; two quotes in a row are one, inside quoted // text or out of it. if c == '\'' && chars.get(i + 1) == Some(&'\'') { out.push('\''); i += 2; continue; } if c == '\'' { i += 1; while let Some(&q) = chars.get(i) { i += 1; if q != '\'' { out.push(q); } else if chars.get(i) == Some(&'\'') { out.push('\''); i += 1; } else { break; } } continue; } // `am/pm` (any case) is one token: AM or PM. if chars.get(i..i + 5).is_some_and(|w| w.iter().collect::().eq_ignore_ascii_case("am/pm")) { out.push_str(if hh < 12 { "AM" } else { "PM" }); i += 5; continue; } let mut n = 1; while chars.get(i + n) == Some(&c) { n += 1; } match (c, n) { ('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")), ('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)), ('M', 4..) => out.push_str(month), ('M', 3) => out.push_str(month.get(..3).unwrap_or(month)), ('M', 2) => out.push_str(&format!("{m:02}")), ('M', 1) => out.push_str(&m.to_string()), ('d' | 'D', 4..) => out.push_str(day), ('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)), ('d' | 'D', 2) => out.push_str(&format!("{d:02}")), ('d' | 'D', 1) => out.push_str(&d.to_string()), ('H', 2..) => out.push_str(&format!("{hh:02}")), ('H', _) => out.push_str(&hh.to_string()), ('h', 2..) => out.push_str(&format!("{:02}", if hh.is_multiple_of(12) { 12 } else { hh % 12 })), ('h', _) => out.push_str(&(if hh.is_multiple_of(12) { 12 } else { hh % 12 }).to_string()), ('m', 2..) => out.push_str(&format!("{mm:02}")), ('m', _) => out.push_str(&mm.to_string()), ('s', 2..) => out.push_str(&format!("{ss:02}")), ('s', _) => out.push_str(&ss.to_string()), ('a' | 'A', _) => out.push_str(if hh < 12 { "AM" } else { "PM" }), _ => { for _ in 0..n { out.push(c); } } } i += n; } out } /// Insert › Symbol: the character at the caret, in its own font when `font` names one (the rest of /// the text keeps the caret's font). pub(super) fn symbol(s: &mut Session, v: &Value) -> CmdResult { let c = p::req_str(v, "char")?; if c.is_empty() || c.chars().count() > 64 { return Err(CmdError::Params("char: 1–64 characters".into())); } let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()); let Some(font) = font else { type_text(s, c)?; return sel_result(s); }; if font.len() > 256 { return Err(CmdError::Params("font: a family name".into())); } let base = s.typing_props(); // A symbol font's character remembers it as its own font (`w:sym`), so changing the text's // font later keeps the symbol; other fonts are ordinary run formatting. `w:sym` holds a 16-bit // code, so a character beyond the Basic Multilingual Plane stays ordinary text in that font. // Each character decides for itself, so a mixed string keeps its symbols and its text apart. let font_is_symbol = wordcraft_doc::symbol_fonts::is_symbol_font(font); let mut r = Ok(()); let mut buf = [0u8; 4]; for ch in c.chars() { let mut props = base.clone(); let private_use = ('\u{F020}'..='\u{F0FF}').contains(&ch); if u32::from(ch) <= 0xFFFF && (font_is_symbol || private_use) { props.sym_font = Some(font.to_string()); } else { props.font = Some(font.to_string()); } s.pending = Some(props); r = type_text(s, ch.encode_utf8(&mut buf)); if r.is_err() { break; } } // Typing on after the symbol goes on in the text's own font. s.pending = Some(base); r?; sel_result(s) } pub(super) fn field(s: &mut Session, v: &Value) -> CmdResult { let instr = p::req_str(v, "instr")?.trim().to_string(); if instr.is_empty() || instr.len() > 4096 { return Err(CmdError::Params("bad field code".into())); } let result = p::str(v, "result").unwrap_or("").to_string(); let props = s.typing_props(); let at = delete_selection(s)?; let end = s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false, dirty: false, code: false, form: String::new() }, &props)?; s.sel = Selection::caret(end); let _ = crate::cmd::references::update_fields(s); sel_result(s) } pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult { let title = p::str(v, "title").unwrap_or("Document Title").to_string(); let subtitle = p::str(v, "subtitle").unwrap_or("Document subtitle").to_string(); let author = p::str(v, "author").unwrap_or(&s.author).to_string(); let accent = Rgb(0x15, 0x60, 0x82); let mut blocks = Vec::new(); for _ in 0..8 { blocks.push(Block::Para(Paragraph::new())); } let mut t = Paragraph::with_text(&title, CharProps { size: Some(44.0), color: Some(TextColor::Rgb(accent)), ..Default::default() }).styled("Title"); t.props.borders = Some(wordcraft_doc::props::Borders { bottom: Some(wordcraft_doc::props::Border { style: wordcraft_doc::props::BorderStyle::Single, width: 2.0, color: Some(accent), space: 6.0 }), ..Default::default() }); blocks.push(Block::Para(t)); blocks.push(Block::Para(Paragraph::with_text(&subtitle, CharProps::default()).styled("Subtitle"))); for _ in 0..14 { blocks.push(Block::Para(Paragraph::new())); } blocks .push(Block::Para(Paragraph::with_text(&author, CharProps { bold: Some(true), color: Some(TextColor::Rgb(accent)), ..Default::default() }))); let mut date = Paragraph::with_text(&format_date("MMMM d, yyyy"), CharProps::default()); date.insert_text(date.len(), "\u{000C}", &CharProps::default())?; blocks.push(Block::Para(date)); let frag = wordcraft_doc::edit::Fragment { blocks, ..Default::default() }; let start = s.doc.start_of(StoryRef::Body); let at = s.doc.split_paragraph(&start)?; let _ = at; s.doc.insert_fragment(&start, &frag)?; s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); sel_result(s) } /// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph /// and in one that starts with a space. A selection must be fit throughout (what Dropped and In /// margin need), unless a paragraph in it has a drop cap for None to take away. pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> { let why = drop_cap_selection_off(s)?; let story = s.sel.focus.story; let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some())); (!has_cap).then_some(why) } fn drop_cap_first_letter(first: Option) -> Option<&'static str> { match first { None => Some("the paragraph is empty"), Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"), Some(_) => None, } } /// The same check for every selected paragraph, as the command formats them all. fn drop_cap_selection_off(s: &Session) -> Option<&'static str> { let story = s.sel.focus.story; for path in crate::cmd::selected_paths(s) { if path.cell().is_some() { return Some("drop caps can't go in a table"); } let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next()); if let Some(why) = drop_cap_first_letter(first) { return Some(why); } } None } /// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in /// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's). /// None takes all of it away, the letter's font with it. pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult { let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite()); let none = p::str(v, "position") == Some("none") || lines == Some(0.0); let margin = p::str(v, "position") == Some("margin"); let lines_given = lines.is_some(); let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8; let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE)); // A family name, not a paragraph of text. let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::()); if !none && let Some(why) = drop_cap_selection_off(s) { return Err(CmdError::Failed(why.into())); } // `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own // lines, distance and font unless given here. let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true); let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some()); crate::cmd::para::fmt(s, &|p| { let own = keep && p.drop_cap.is_some(); p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines }); p.drop_cap_margin = (!none && margin).then_some(true); let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance }; p.drop_cap_distance = (!none && d > 0.0).then_some(d); if !(own && !font_given) { p.drop_cap_font = font.clone().filter(|_| !none); } }) } /// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values. #[derive(Clone, Debug, PartialEq)] pub struct DropCapState { /// `none`, `dropped` or `margin`. pub position: &'static str, /// Lines to drop (3 when there is no drop cap). pub lines: u8, /// Distance from the text, points. pub distance: f32, /// The letter's own font, if it has one. pub font: Option, /// The font of the paragraph's first character. pub text_font: String, } pub fn drop_cap_state(s: &Session) -> DropCapState { let para = s.doc.para_at(&s.sel.focus); let props = para.map(|p| p.props.clone()).unwrap_or_default(); let rp = s.doc.styles.resolve_para(&props); let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default(); let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font; DropCapState { position: match (rp.drop_cap, rp.drop_cap_margin) { (0, _) => "none", (_, true) => "margin", _ => "dropped", }, lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap }, distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance }, font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0), text_font, } }