//! Insert tab: pages, tables, pictures, shapes, links, bookmarks, headers/footers, text, symbols. use serde_json::{Value, json}; use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap}; use wordcraft_doc::props::{Align, CharProps, Rgb, TabAlign, TabStop, TextColor}; use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block}; use super::{delete_selection, sel_result, split_para, type_text}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub fn specs() -> Vec { vec![ CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| { type_text(s, "\u{000C}")?; sel_result(s) }), CommandSpec::new("insert.blankPage", "Blank Page", "Insert › Pages", |s, _| { type_text(s, "\u{000C}")?; let at = s.sel.focus.clone(); let at = split_para(s, &at)?; s.doc.insert_text(&at, "\u{000C}", &CharProps::default())?; s.sel = Selection::caret(at); sel_result(s) }), CommandSpec::new("insert.coverPage", "Cover Page", "Insert › Pages", cover_page).params(r#"{"title"?: string, "subtitle"?: string, "author"?: string}"#), CommandSpec::new("insert.table", "Table", "Insert › Tables", table).params(r#"{"rows": n, "cols": n, "style"?: string}"#), CommandSpec::new("insert.picture", "Pictures", "Insert › Illustrations", picture).params(r#"{"path"?: string, "data"?: base64, "width"?: pt, "alt"?: string}"#), CommandSpec::new("insert.icon", "Icons", "Insert › Illustrations", icon) .params(r#"{"id"?: string (omit to list the library), "color"?: "RRGGBB", "size"?: pt, "query"?: string, "category"?: string}"#), CommandSpec::new("insert.shape", "Shapes", "Insert › Illustrations", shape) .params(r#"{"kind": "rectangle|roundedRectangle|ellipse|triangle|diamond|line|arrow|star|heart", "width"?: pt, "height"?: pt, "fill"?: "RRGGBB", "stroke"?: "RRGGBB"}"#), CommandSpec::new("insert.textBox", "Text Box", "Insert › Text", text_box) .params(r#"{"text"?: string, "width"?: pt, "height"?: pt}"#) .when(|s| { // As in Word: no text box inside a text box. matches!(s.sel.focus.story, StoryRef::Part(id) if s.doc.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox)) .then_some("text boxes can't go inside a text box") }), CommandSpec::new("insert.link", "Link", "Insert › Links", link).key("Mod+K").params(r#"{"url": string, "text"?: string}"#), CommandSpec::new("insert.removeLink", "Remove Hyperlink", "Insert › Links", |s, _| super::format::apply(s, &|c| { c.link = None; if c.style.as_deref() == Some("Hyperlink") { c.style = None; } })), CommandSpec::new("insert.bookmark", "Bookmark", "Insert › Links", bookmark).params(r#"{"name": string}"#).key("Mod+Shift+F5"), CommandSpec::new("insert.header", "Header", "Insert › Header & Footer", |s, v| header_footer(s, v, true)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|title"}"#), CommandSpec::new("insert.footer", "Footer", "Insert › Header & Footer", |s, v| header_footer(s, v, false)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|pageNumber"}"#), CommandSpec::new("insert.pageNumber", "Page Number", "Insert › Header & Footer", page_number).params(r#"{"position"?: "top|bottom|current", "align"?: "left|center|right", "format"?: "x of y"}"#), CommandSpec::new("insert.editHeader", "Edit Header", "Insert › Header & Footer › Header", |s, _| edit_hf(s, true)).pure(), CommandSpec::new("insert.editFooter", "Edit Footer", "Insert › Header & Footer › Footer", |s, _| edit_hf(s, false)).pure(), CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| { s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); sel_result(s) }) .pure(), CommandSpec::new("insert.removeHeader", "Remove Header", "Insert › Header & Footer › Header", |s, _| { s.doc.last_section.headers = Default::default(); for path in s.doc.para_paths(StoryRef::Body) { if s.doc.para(StoryRef::Body, &path).is_some_and(|p| p.section.is_some()) && let Ok(p) = s.doc.para_mut(StoryRef::Body, &path) && let Some(sec) = p.section.as_mut() { sec.headers = Default::default(); } } sel_result(s) }), CommandSpec::new("insert.removeFooter", "Remove Footer", "Insert › Header & Footer › Footer", |s, _| { s.doc.last_section.footers = Default::default(); sel_result(s) }), CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#), CommandSpec::new("insert.symbol", "Symbol", "Insert › Symbols", symbol).params(r#"{"char": string, "font"?: family (the symbol's own font)}"#), CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#), // Word's Alt+Shift+D / T / P (Control+Shift+… in Word for Mac): DATE, TIME and PAGE fields. CommandSpec::new("insert.dateField", "Insert Date Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "DATE"}))) .key(super::mac_or("Ctrl+Shift+D", "Alt+Shift+D")), CommandSpec::new("insert.timeField", "Insert Time Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "TIME"}))) .key(super::mac_or("Ctrl+Shift+T", "Alt+Shift+T")), CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"}))) .key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")), CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", |s, v| { let lines = p::u64(v, "lines").unwrap_or(3).min(10) as u8; super::para::fmt(s, &|p| p.drop_cap = Some(lines)) }) .params(r#"{"lines"?: n (0 = none)}"#), CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| { super::para::fmt(s, &|p| { p.borders = Some(wordcraft_doc::props::Borders { bottom: Some(wordcraft_doc::props::Border::single(1.5)), ..Default::default() }) }) }), CommandSpec::new("insert.wordArt", "WordArt", "Insert › Text", |s, v| { let text = p::str(v, "text").unwrap_or("Your text here").to_string(); let props = CharProps { size: Some(36.0), bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), outline: Some(false), ..Default::default() }; let at = delete_selection(s)?; let end = s.doc.insert_text(&at, &text, &props)?; s.sel = Selection { anchor: at, focus: end }; sel_result(s) }), CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| { let path = p::req_str(v, "path")?; let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; let frag = doc.body_fragment(); let at = delete_selection(s)?; let end = s.doc.insert_fragment(&at, &frag)?; s.sel = Selection::caret(end); sel_result(s) }) .params(r#"{"path": string}"#), ] } /// Insert a block after the caret's paragraph (splitting it if the caret is mid-paragraph). fn insert_block_at_caret(s: &mut Session, block: Block) -> Result { let at = delete_selection(s)?; let len = s.doc.para_at(&at).map(|p| p.len()).unwrap_or(0); let insert_at = if at.off == 0 { at.path.clone() } else { let new = s.doc.split_paragraph(&at)?; if at.off >= len { at.path.with_last(at.path.last() + 1) } else { new.path } }; s.doc.insert_block(at.story, &insert_at, block)?; // Word keeps a paragraph after a table. let next = insert_at.with_last(insert_at.last() + 1); if s.doc.block(at.story, &next).is_none() { s.doc.insert_block(at.story, &next, Block::Para(Paragraph::new()))?; } // An empty paragraph we inserted before? Remove if the table took its place at the start. Ok(insert_at) } fn table(s: &mut Session, v: &Value) -> CmdResult { let rows = p::u64(v, "rows").unwrap_or(2).clamp(1, 1000) as usize; let cols = p::u64(v, "cols").unwrap_or(2).clamp(1, 63) as usize; let width = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0); let mut t = Table::new(rows, cols, width); if let Some(st) = p::str(v, "style") { let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?; t.props.style = Some(id); } let story = s.sel.focus.story; let path = insert_block_at_caret(s, Block::Table(t))?; let mut first = path.0.clone(); first.extend([0, 0, 0]); s.sel = Selection::caret(Pos { story, path: Path(first), off: 0 }); Ok(json!({"table": path.0})) } /// Minimal base64 (standard alphabet, padding optional, whitespace ignored). pub fn base64_decode(s: &str) -> Option> { let mut out = Vec::with_capacity(s.len() * 3 / 4); let mut buf = 0u32; let mut n = 0; for c in s.bytes() { let v = match c { b'A'..=b'Z' => c - b'A', b'a'..=b'z' => c - b'a' + 26, b'0'..=b'9' => c - b'0' + 52, b'+' | b'-' => 62, b'/' | b'_' => 63, b'=' | b' ' | b'\n' | b'\r' | b'\t' => continue, _ => return None, } as u32; buf = (buf << 6) | v; n += 6; if n >= 8 { n -= 8; out.push((buf >> n) as u8); buf &= (1 << n) - 1; } } Some(out) } pub fn base64_encode(b: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(b.len().div_ceil(3) * 4); for ch in b.chunks(3) { let n = (ch.first().copied().unwrap_or(0) as u32) << 16 | (ch.get(1).copied().unwrap_or(0) as u32) << 8 | ch.get(2).copied().unwrap_or(0) as u32; for i in 0..4 { if i <= ch.len() { out.push(T[(n >> (18 - 6 * i) & 63) as usize] as char); } else { out.push('='); } } } out } fn picture(s: &mut Session, v: &Value) -> CmdResult { let bytes = if let Some(d) = p::str(v, "data") { base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))? } else if let Some(path) = p::str(v, "path") { #[cfg(not(target_arch = "wasm32"))] { std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))? } #[cfg(target_arch = "wasm32")] { let _ = path; return Err(CmdError::Failed("reading files by path isn't available on the web".into())); } } else { s.ui_requests.push(json!({"open": "insertPicture"})); return sel_result(s); }; place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or("")) } /// Insert an encoded image at the selection as an inline picture, `width` points wide (default: /// its natural size at 96 ppi), never wider than the text column. fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) -> CmdResult { if bytes.len() > 200 << 20 { return Err(CmdError::Failed("image is larger than 200 MB".into())); } let (pw, ph) = wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP)".into()))?; let ext = match bytes.get(..4) { Some([0x89, b'P', b'N', b'G']) => "png", Some([0xFF, 0xD8, ..]) => "jpeg", Some([b'G', b'I', b'F', _]) => "gif", Some([b'R', b'I', b'F', b'F']) => "webp", Some([b'B', b'M', ..]) => "bmp", _ => "png", }; let key = s.doc.add_media(bytes, ext); // Natural size at 96 ppi, fitted to the text width. let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0); let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75); if let Some(want) = width { let k = want.max(4.0) / w.max(1.0); w *= k; h *= k; } if w > max_w { h *= max_w / w; w = max_w; } let props = s.typing_props(); let at = delete_selection(s)?; let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None }; let end = s.doc.insert_object(&at, obj, &props)?; s.sel = Selection { anchor: at, focus: end }; Ok(json!({"media": key, "width": w, "height": h})) } /// Insert › Icons: a library icon (original artwork drawn in code, `wordcraft_render::icon_lib`) /// rendered to a PNG picture `size` points square (default 1 inch) in `color` (default black). /// Without an `id` it lists the library (narrowed by `query` and `category`) and asks a front end /// for its icon picker. fn icon(s: &mut Session, v: &Value) -> CmdResult { use wordcraft_render::icon_lib; let Some(id) = p::str(v, "id") else { let category = p::str(v, "category").and_then(icon_lib::Category::parse); let icons: Vec = icon_lib::search(p::str(v, "query").unwrap_or(""), category) .iter() .map(|i| json!({"id": i.id, "name": i.name, "category": i.category.name(), "keywords": i.keywords})) .collect(); s.ui_requests.push(json!({"open": "icons"})); return Ok(json!({"icons": icons})); }; let icon = icon_lib::find(id).ok_or_else(|| CmdError::Params(format!("no icon {id:?} (insert.icon without an id lists them)")))?; let ink = match p::str(v, "color") { Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params(format!("bad color {c:?}: use RRGGBB")))?, None => Rgb::BLACK, }; let size = p::f32(v, "size").unwrap_or(72.0).clamp(4.0, 1584.0); // About 300 pixels per inch, so it prints sharply. let px = (size * 300.0 / 72.0).round().clamp(64.0, 1024.0) as u32; let png = icon_lib::render(icon, px, ink).to_png(); if png.is_empty() { return Err(CmdError::Failed("couldn't draw the icon".into())); } let mut r = place_picture(s, png, Some(size), icon.name)?; r["icon"] = json!(icon.id); Ok(r) } fn shape(s: &mut Session, v: &Value) -> CmdResult { let kind: ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?; let w = p::f32(v, "width").unwrap_or(108.0).clamp(4.0, 2000.0); let h = p::f32(v, "height").unwrap_or(if kind == ShapeKind::Line { 1.0 } else { 72.0 }).clamp(1.0, 2000.0); let fill = match p::str(v, "fill") { Some(c) => Rgb::parse(c), None if kind == ShapeKind::Line => None, None => Some(Rgb(0x15, 0x60, 0x82)), }; let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A))); let props = s.typing_props(); let at = delete_selection(s)?; let obj = InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None, freeform: None, effects: Default::default(), }; let end = s.doc.insert_object(&at, obj, &props)?; s.sel = Selection { anchor: at, focus: end }; sel_result(s) } fn text_box(s: &mut Session, v: &Value) -> CmdResult { let text = p::str(v, "text").unwrap_or("").to_string(); let w = p::f32(v, "width").unwrap_or(144.0).clamp(18.0, 2000.0); let h = p::f32(v, "height").unwrap_or(72.0).clamp(18.0, 2000.0); let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))]); let props = s.typing_props(); let at = delete_selection(s)?; let obj = InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill: Some(Rgb::WHITE), stroke: Some(Rgb::BLACK), stroke_width: 0.75, float: Float::default(), story: Some(id), freeform: None, effects: Default::default(), }; s.doc.insert_object(&at, obj, &props)?; // Like Word, type straight into the new box. s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id))); Ok(json!({"story": id})) } fn link(s: &mut Session, v: &Value) -> CmdResult { let Some(url) = p::str(v, "url").map(str::to_string) else { s.ui_requests.push(json!({"open": "link"})); return sel_result(s); }; if url.is_empty() || url.len() > 4096 { return Err(CmdError::Params("bad url".into())); } let style = Some("Hyperlink".to_string()); if s.sel.is_collapsed() { let text = p::str(v, "text").unwrap_or(&url).to_string(); let mut props = s.typing_props(); props.link = Some(url.clone()); props.style = style; let at = s.sel.focus.clone(); let end = s.doc.insert_text(&at, &text, &props)?; s.sel = Selection::caret(end); s.pending = Some(CharProps::default()); } else { let (a, b) = s.sel.ordered(); s.doc.format_range(&a, &b, &|c| { c.link = Some(url.clone()); c.style = style.clone(); })?; } sel_result(s) } fn bookmark(s: &mut Session, v: &Value) -> CmdResult { let name = p::req_str(v, "name")?.trim().to_string(); if name.is_empty() || name.contains(' ') || name.len() > 40 || name.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Err(CmdError::Params("bookmark names start with a letter, have no spaces and at most 40 characters".into())); } // Replace an existing bookmark of the same name. for (_, pos) in s.doc.bookmarks().into_iter().filter(|(n, _)| *n == name).rev() { let para = s.doc.para_mut(pos.story, &pos.path)?; para.delete(pos.off, pos.off + wordcraft_doc::para::OBJ.len_utf8())?; let offs: Vec = para.object_offsets(); if let Some(e) = offs.into_iter().find(|o| matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if *n == name)) { para.delete(e, e + wordcraft_doc::para::OBJ.len_utf8())?; } } let (a, b) = s.sel.ordered(); let props = CharProps::default(); let end = s.doc.insert_object(&b, InlineObject::BookmarkEnd { name: name.clone() }, &props)?; s.doc.insert_object(&a, InlineObject::BookmarkStart { name: name.clone() }, &props)?; let _ = end; sel_result(s) } fn hf_part(s: &mut Session, header: bool) -> u32 { let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; let sect = s.doc.section_mut(block).clone(); let existing = if header { sect.headers.default } else { sect.footers.default }; if let Some(id) = existing.filter(|id| s.doc.parts.contains_key(id)) { return id; } let style = if header { "Header" } else { "Footer" }; let id = s.doc.add_part(if header { PartKind::Header } else { PartKind::Footer }, vec![para_block(Paragraph::new().styled(style))]); let sect = s.doc.section_mut(block); if header { sect.headers.default = Some(id); } else { sect.footers.default = Some(id); } id } fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult { let id = hf_part(s, header); let style = if header { "Header" } else { "Footer" }; if let Some(text) = p::str(v, "text") { let blocks = text.split('\n').map(|l| para_block(Paragraph::with_text(l, CharProps::default()).styled(style))).collect(); s.doc.set_story(StoryRef::Part(id), blocks)?; } else if let Some(preset) = p::str(v, "preset") { let mut para = Paragraph::new().styled(style); match preset { "blankThree" => { para.insert_text(0, "[Type here]\t[Type here]\t[Type here]", &CharProps::default())?; } "title" => { para.insert_text(0, if s.doc.core.title.is_empty() { "[Document title]" } else { &s.doc.core.title }, &CharProps::default())?; para.props.align = Some(Align::Center); } "pageNumber" => { para.props.align = Some(Align::Center); para.insert_object( 0, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }, &CharProps::default(), )?; } _ => { para.insert_text(0, "[Type here]", &CharProps::default())?; } } s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?; } s.sel = Selection::caret(s.doc.start_of(StoryRef::Part(id))); Ok(json!({"story": id})) } fn edit_hf(s: &mut Session, header: bool) -> CmdResult { let id = hf_part(s, header); s.touch(); s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id))); Ok(json!({"story": id})) } 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, code: false }; 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, code: false }, &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})) } 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, code: false }, &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 = super::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); 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"]; // Days since the epoch, for the weekday. let days_since = (super::now_unix() / 86_400) as usize; let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January"); let day = DAYS.get(days_since % 7).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(' '); // `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', 4..) => out.push_str(&format!("{y:04}")), ('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', 4..) => out.push_str(day), ('d', 3) => out.push_str(day.get(..3).unwrap_or(day)), ('d', 2) => out.push_str(&format!("{d:02}")), ('d', 1) => out.push_str(&d.to_string()), ('H', 2..) => out.push_str(&format!("{hh:02}")), ('H', _) => out.push_str(&hh.to_string()), ('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). 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(); let mut props = base.clone(); props.font = Some(font.to_string()); s.pending = Some(props); let r = type_text(s, c); // Typing on after the symbol goes on in the text's own font. s.pending = Some(base); r?; sel_result(s) } 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, code: false }, &props)?; s.sel = Selection::caret(end); let _ = super::references::update_fields(s); sel_result(s) } 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) } #[cfg(test)] mod tests { use super::*; #[test] fn base64_round_trip() { let data = b"hello world!?"; assert_eq!(base64_decode(&base64_encode(data)).unwrap(), data); assert!(base64_decode("!!!").is_none()); } /// Insert › Symbol with a font: only the symbol takes it, the caret's font carries on after. #[test] fn symbol_takes_its_own_font() { let mut s = Session::new(wordcraft_doc::Document::from_text("ab")); s.run("caret.docEnd", &json!({})).unwrap(); s.run("insert.symbol", &json!({"char": "\u{2665}", "font": "DejaVu Sans"})).unwrap(); s.run("text.insert", &json!({"text": "c"})).unwrap(); let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); assert_eq!(p.text, "ab\u{2665}c"); assert_eq!(p.props_of_char(2).font.as_deref(), Some("DejaVu Sans"), "the symbol"); assert_eq!(p.props_of_char(5).font, None, "typing after it (byte offsets)"); assert!(s.run("insert.symbol", &json!({"char": ""})).is_err()); } #[test] fn date_pictures() { let d = format_date("yyyy-MM-dd"); assert_eq!(d.len(), 10); assert!(format_date("MMMM d, yyyy").contains(", ")); let t = format_date("h:mm:ss am/pm"); assert!(t.ends_with(" AM") || t.ends_with(" PM"), "{t}"); assert_eq!(t.matches(':').count(), 2, "{t}"); use super::super::references::date_picture; assert_eq!(date_picture(r#"DATE \@ "MMMM d, yyyy" \* MERGEFORMAT"#).as_deref(), Some("MMMM d, yyyy")); assert_eq!(date_picture(r#"TIME \@ HH:mm"#).as_deref(), Some("HH:mm")); assert_eq!(date_picture("DATE"), None); } }