mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 04:47:10 -06:00
282 lines
13 KiB
Rust
282 lines
13 KiB
Rust
use super::header_footer::hf_part;
|
||
use super::*;
|
||
|
||
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 {
|
||
let iso = crate::cmd::now_iso();
|
||
let y: i64 = iso.get(0..4).and_then(|x| x.parse().ok()).unwrap_or(2026);
|
||
let m: usize = iso.get(5..7).and_then(|x| x.parse().ok()).unwrap_or(1);
|
||
let d: u32 = iso.get(8..10).and_then(|x| x.parse().ok()).unwrap_or(1);
|
||
let hh: u32 = iso.get(11..13).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||
let mm: u32 = iso.get(14..16).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||
let ss: u32 = iso.get(17..19).and_then(|x| x.parse().ok()).unwrap_or(0);
|
||
format_date_parts(fmt, y, m, d, (hh, mm, ss), (crate::cmd::now_unix() / 86_400) as i64)
|
||
}
|
||
|
||
/// 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<char> = fmt.chars().collect();
|
||
let mut i = 0;
|
||
while i < chars.len() {
|
||
let c = chars.get(i).copied().unwrap_or(' ');
|
||
// `am/pm` (any case) is one token: AM or PM.
|
||
if chars.get(i..i + 5).is_some_and(|w| w.iter().collect::<String>().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.
|
||
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
|
||
if s.sel.focus.path.cell().is_some() {
|
||
return Some("drop caps can't go in a table");
|
||
}
|
||
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
|
||
match first {
|
||
None => Some("the paragraph is empty"),
|
||
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
|
||
Some(_) => 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 = 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::<String>());
|
||
crate::cmd::para::fmt(s, &|p| {
|
||
p.drop_cap = (!none).then_some(lines);
|
||
p.drop_cap_margin = (!none && margin).then_some(true);
|
||
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
|
||
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<String>,
|
||
/// 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,
|
||
}
|
||
}
|