mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:31:54 -06:00
refactor(engine): split cmd/insert.rs into insert/
This commit is contained in:
+15
-941
@@ -8,6 +8,21 @@ use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para
|
||||
use super::{delete_selection, sel_result, split_para, type_text};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod header_footer;
|
||||
mod items;
|
||||
mod links;
|
||||
mod objects;
|
||||
|
||||
pub(crate) use header_footer::saved_copy;
|
||||
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
|
||||
pub use items::{DropCapState, drop_cap_state, format_date, format_date_parts};
|
||||
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
|
||||
use links::{bookmark, delete_bookmark, link, link_targets};
|
||||
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
|
||||
pub use objects::{base64_decode, base64_encode};
|
||||
use objects::{icon, shape, table, text_box};
|
||||
pub(crate) use objects::{image_ext, picture};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| {
|
||||
@@ -130,946 +145,5 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// 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<Path, CmdError> {
|
||||
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);
|
||||
}
|
||||
// Like Word, a table inserted in a right-to-left paragraph is a right-to-left table (#2634):
|
||||
// its first cell stands at the right, so Tab, which follows reading order, moves leftwards.
|
||||
if s.doc.para_at(&s.sel.focus).is_some_and(|p| s.doc.styles.resolve_para(&p.props).bidi) {
|
||||
t.props.bidi_visual = true;
|
||||
}
|
||||
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<Vec<u8>> {
|
||||
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
|
||||
}
|
||||
|
||||
pub(crate) 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") {
|
||||
crate::io::read_file(path).map_err(CmdError::Failed)?
|
||||
} else {
|
||||
s.push_ui_request(json!({"open": "insertPicture"}));
|
||||
return sel_result(s);
|
||||
};
|
||||
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
|
||||
}
|
||||
|
||||
/// The media extension of an encoded image by its magic bytes, as the picture probe accepted it
|
||||
/// (TIFF included when its compression is one the `image` crate decodes within limits).
|
||||
pub(crate) fn image_ext(bytes: &[u8]) -> &'static str {
|
||||
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",
|
||||
Some([b'I', b'I', 0x2A, 0x00]) | Some([b'M', b'M', 0x00, 0x2A]) => "tiff",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. The caret ends after it, so a
|
||||
/// script's next typing doesn't replace it (#965); Picture Format commands still act on it. The
|
||||
/// app selects a picture the person inserts (`WordApp::run`).
|
||||
fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str) -> CmdResult {
|
||||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). A picture that
|
||||
// is never decoded (a TIFF with JPEG strips, #3645) isn't inserted.
|
||||
let (natural_w, natural_h, ext) = match wordcraft_geom::picture::probe(&bytes) {
|
||||
Ok(pic) => (pic.width as f32 * 0.75, pic.height as f32 * 0.75, image_ext(&bytes)),
|
||||
Err(wordcraft_geom::picture::Refusal::Unsupported(why)) => return Err(CmdError::Failed(why.into())),
|
||||
Err(_) => match wordcraft_render::metafile_size(&bytes) {
|
||||
Some(size) => size,
|
||||
None => return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into())),
|
||||
},
|
||||
};
|
||||
let key = s.doc.add_media(bytes, ext);
|
||||
// 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) = (natural_w, natural_h);
|
||||
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 obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
|
||||
// A selected Drawing Canvas takes it in.
|
||||
if let Some(i) = super::canvas::add_member(s, obj.clone())? {
|
||||
return Ok(json!({"media": key, "canvasMember": i}));
|
||||
}
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection::caret(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<Value> = 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.push_ui_request(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(if kind.is_connector() { 0.0 } else { 1.0 }, 2000.0);
|
||||
let fill = match p::str(v, "fill") {
|
||||
Some(c) => Rgb::parse(c),
|
||||
None if kind.is_open() => None,
|
||||
None => Some(Rgb(0x15, 0x60, 0x82)),
|
||||
};
|
||||
// Arrowheads on lines and connectors.
|
||||
let (arrow_start, arrow_end) = match p::str(v, "arrow") {
|
||||
Some("end") => (false, true),
|
||||
Some("start") => (true, false),
|
||||
Some("both") => (true, true),
|
||||
Some("none") | None => (false, false),
|
||||
Some(x) => return Err(CmdError::Params(format!("`arrow`: none, start, end or both, not `{x}`"))),
|
||||
};
|
||||
let extra = wordcraft_doc::connector::ShapeExtra { arrow_start, arrow_end, ..Default::default() };
|
||||
// Connectors float in front of the text, so they can be glued to floating shapes.
|
||||
let float = if kind.is_connector() {
|
||||
Float { wrap: Wrap::InFrontOfText, ..Default::default() }
|
||||
} else {
|
||||
Float { wrap: Wrap::Inline, ..Default::default() }
|
||||
};
|
||||
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
|
||||
let obj = InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill: fill.map(wordcraft_doc::fill::Fill::solid),
|
||||
stroke,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra,
|
||||
};
|
||||
// A selected Drawing Canvas takes it in (and stays selected for the next one).
|
||||
if super::canvas::add_member(s, obj.clone())?.is_some() {
|
||||
return sel_result(s);
|
||||
}
|
||||
// Word draws a new shape beside what is selected and never deletes it: the shape just
|
||||
// inserted (which is selected) stays, and so does selected text (#2019). It goes after the
|
||||
// selection and becomes the selection. (A picture does replace the selection, as in Word.)
|
||||
let props = s.typing_props();
|
||||
let (_, at) = s.sel.ordered();
|
||||
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.try_add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))])?;
|
||||
let obj = InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill: Some(wordcraft_doc::fill::Fill::solid(Rgb::WHITE)),
|
||||
stroke: Some(Rgb::BLACK),
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
story: Some(id),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
// A selected Drawing Canvas takes it in; otherwise it goes at the caret.
|
||||
if super::canvas::add_member(s, obj.clone())?.is_none() {
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
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}))
|
||||
}
|
||||
|
||||
/// What a link can point to inside the document (Insert › Link › Place in This Document): the top
|
||||
/// of the document, its headings (body block, level, text) and its bookmarks, without the hidden
|
||||
/// ones (names starting with `_`).
|
||||
fn link_targets(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let headings: Vec<Value> =
|
||||
super::references::headings(s).into_iter().map(|(block, level, text, _)| json!({"block": block, "level": level, "text": text})).collect();
|
||||
let mut bookmarks: Vec<String> = Vec::new();
|
||||
for (name, _) in s.doc.bookmarks() {
|
||||
if !name.starts_with('_') && !bookmarks.contains(&name) {
|
||||
bookmarks.push(name);
|
||||
}
|
||||
}
|
||||
Ok(json!({"top": true, "headings": headings, "bookmarks": bookmarks}))
|
||||
}
|
||||
|
||||
/// The address and the default text of a link to a place in this document, from `top`, `bookmark`
|
||||
/// or `heading` (a heading gets a hidden `_Toc` bookmark, as in Word); None when none is given.
|
||||
fn place_target(s: &mut Session, v: &Value) -> Result<Option<(String, String)>, CmdError> {
|
||||
if p::bool(v, "top") == Some(true) {
|
||||
return Ok(Some(("#_top".into(), "Top of the Document".into())));
|
||||
}
|
||||
if let Some(name) = p::str(v, "bookmark") {
|
||||
if !s.doc.bookmarks().iter().any(|(n, _)| n == name) {
|
||||
return Err(CmdError::Params(format!("no bookmark `{name}`")));
|
||||
}
|
||||
return Ok(Some((format!("#{name}"), name.to_string())));
|
||||
}
|
||||
if let Some(block) = p::u64(v, "heading") {
|
||||
let Some((i, _, text, lead)) = super::references::headings(s).into_iter().find(|h| h.0 as u64 == block) else {
|
||||
return Err(CmdError::Params(format!("block {block} is not a heading")));
|
||||
};
|
||||
let name = super::references::toc_bookmark(s, i, lead)?;
|
||||
return Ok(Some((format!("#{name}"), text)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn link(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (url, label) = match p::str(v, "url") {
|
||||
Some(url) => (url.to_string(), url.to_string()),
|
||||
None => match place_target(s, v)? {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
s.push_ui_request(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(&label).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)
|
||||
}
|
||||
|
||||
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
|
||||
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
|
||||
pub fn bookmark_name_ok(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(|c| c.is_alphanumeric() || c == '_') && name.chars().count() <= 40
|
||||
}
|
||||
|
||||
/// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark.
|
||||
pub fn bookmark_range(doc: &wordcraft_doc::Document, name: &str) -> Option<(Pos, Pos)> {
|
||||
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
|
||||
Some(bookmark_span(doc, name, &start))
|
||||
}
|
||||
|
||||
/// The text of bookmark `name` whose start mark is at `start`: from just after that mark to its
|
||||
/// end mark — in a later paragraph when it spans several (the start again when it has no end).
|
||||
pub fn bookmark_span(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> (Pos, Pos) {
|
||||
let from = Pos { off: start.off.saturating_add(wordcraft_doc::para::OBJ.len_utf8()), ..start.clone() };
|
||||
let end = doc.para_paths(start.story).into_iter().skip_while(|p| *p != start.path).find_map(|path| {
|
||||
let para = doc.para(start.story, &path)?;
|
||||
let off = para.object_offsets().into_iter().find(|o| {
|
||||
(path != start.path || *o > start.off) && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name)
|
||||
})?;
|
||||
Some(Pos { story: start.story, path, off })
|
||||
});
|
||||
let end = end.unwrap_or_else(|| from.clone());
|
||||
(from, end)
|
||||
}
|
||||
|
||||
/// Remove bookmark `name`'s start and end marks, in whichever paragraphs and stories they are,
|
||||
/// keeping the selection on the same text. Returns whether the bookmark was there.
|
||||
fn remove_bookmark(s: &mut Session, name: &str) -> Result<bool, CmdError> {
|
||||
let mark_len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let mut found = false;
|
||||
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
|
||||
for story in stories {
|
||||
for path in s.doc.para_paths(story) {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
let mut marks = Vec::new();
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::BookmarkStart { name: n }) if n == name => {
|
||||
found = true;
|
||||
marks.push(off);
|
||||
}
|
||||
Some(InlineObject::BookmarkEnd { name: n }) if n == name => marks.push(off),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if marks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let para = s.doc.para_mut(story, &path)?;
|
||||
for off in marks.into_iter().rev() {
|
||||
para.delete(off, off + mark_len)?;
|
||||
for at in [&mut s.sel.anchor, &mut s.sel.focus] {
|
||||
if at.story == story && at.path == path && at.off > off {
|
||||
at.off = at.off.saturating_sub(mark_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.clamp_selection();
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn delete_bookmark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let name = p::req_str(v, "name")?.trim();
|
||||
if !remove_bookmark(s, name)? {
|
||||
return Err(CmdError::Params(format!("no bookmark `{name}`")));
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn bookmark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let name = p::req_str(v, "name")?.trim().to_string();
|
||||
if !bookmark_name_ok(&name) {
|
||||
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.
|
||||
remove_bookmark(s, &name)?;
|
||||
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)
|
||||
}
|
||||
|
||||
/// The header (or footer) of the caret's section in the slot the caret's page uses (first page,
|
||||
/// even page or the default), made when there is none: with Different First Page on, a header
|
||||
/// inserted on page 1 is the one page 1 shows (#2826).
|
||||
fn hf_part(s: &mut Session, header: bool) -> u32 {
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
let layout = s.layout();
|
||||
let page = if s.sel.focus.story == StoryRef::Body { layout.page_of(&s.sel.focus) } else { Some(s.page_hint) };
|
||||
let slot = page.and_then(|pg| layout.pages.get(pg)).map(|pg| pg.hf_slot).unwrap_or_default();
|
||||
fn slot_of(slot: wordcraft_layout::HfSlot, set: &mut wordcraft_doc::section::HeaderSet) -> &mut Option<u32> {
|
||||
match slot {
|
||||
wordcraft_layout::HfSlot::First => &mut set.first,
|
||||
wordcraft_layout::HfSlot::Even => &mut set.even,
|
||||
wordcraft_layout::HfSlot::Default => &mut set.default,
|
||||
}
|
||||
}
|
||||
let mut sect = s.doc.section_mut(block).clone();
|
||||
let existing = *slot_of(slot, if header { &mut sect.headers } else { &mut sect.footers });
|
||||
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);
|
||||
*slot_of(slot, if header { &mut sect.headers } else { &mut sect.footers }) = Some(id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Three independently editable cells spanning the section's text width.
|
||||
fn three_column_hf(s: &Session, style: &str) -> wordcraft_doc::Blocks {
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
// The section `section_mut` would pick, read without making anything unique.
|
||||
let sections = s.doc.sections();
|
||||
let own = sections.iter().position(|(end, _)| block <= *end).filter(|i| i + 1 < sections.len()).and_then(|i| sections.get(i));
|
||||
let width = own.map_or_else(|| s.doc.last_section.text_width(), |(_, sec)| sec.text_width());
|
||||
let mut table = Table::new(1, 3, width);
|
||||
table.props.style = Some("TableNormal".into());
|
||||
table.props.width = Some(width);
|
||||
table.props.fixed = true;
|
||||
// Explicit nil borders also override borders inherited from the document's table style.
|
||||
table.props.borders = Some(Borders::all(Border::default()));
|
||||
for row in &mut table.rows {
|
||||
for (cell, align) in row.cells.iter_mut().zip([Align::Left, Align::Center, Align::Right]) {
|
||||
let mut para = Paragraph::new().styled(style);
|
||||
para.props.align = Some(align);
|
||||
cell.blocks = vec![para_block(para)];
|
||||
}
|
||||
}
|
||||
// Keep a trailing paragraph so the story can also be edited after the table.
|
||||
vec![std::sync::Arc::new(Block::Table(table)), para_block(Paragraph::new().styled(style))]
|
||||
}
|
||||
|
||||
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 p::str(v, "preset") == Some("blankThree") {
|
||||
let blocks = three_column_hf(s, style);
|
||||
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 {
|
||||
"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, dirty: false, code: false, form: String::new() },
|
||||
&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, v: &Value, header: bool) -> CmdResult {
|
||||
let mut page = p::u64(v, "page").map(|page| usize::try_from(page).unwrap_or(usize::MAX));
|
||||
if page.is_none() && s.sel.focus.story == StoryRef::Body {
|
||||
// The caret's page: with Different First Page on, page 1 edits its own header (#2826).
|
||||
let focus = s.sel.focus.clone();
|
||||
page = s.layout().page_of(&focus);
|
||||
}
|
||||
let shown = match page {
|
||||
Some(page) => shown_hf(s, page, header)?,
|
||||
None => {
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
let set = s
|
||||
.doc
|
||||
.sections()
|
||||
.into_iter()
|
||||
.find(|(end, _)| block <= *end)
|
||||
.or_else(|| s.doc.sections().pop())
|
||||
.map(|(_, sp)| if header { sp.headers.default } else { sp.footers.default });
|
||||
set.flatten().filter(|id| s.doc.parts.contains_key(id))
|
||||
}
|
||||
};
|
||||
let Some(id) = shown else {
|
||||
// None yet: made by the mutating command, so it is an undo step and protection applies.
|
||||
let cmd = if header { "insert.createHeader" } else { "insert.createFooter" };
|
||||
let params = page.map_or_else(|| json!({}), |page| json!({ "page": page }));
|
||||
return s.run(cmd, ¶ms);
|
||||
};
|
||||
if let Some(page) = page {
|
||||
s.page_hint = page;
|
||||
}
|
||||
s.touch();
|
||||
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
|
||||
Ok(json!({"story": id}))
|
||||
}
|
||||
|
||||
/// The header (or footer) page `page` shows, if any; an error for a page that isn't there.
|
||||
fn shown_hf(s: &mut Session, page: usize, header: bool) -> Result<Option<u32>, CmdError> {
|
||||
let layout = s.layout();
|
||||
let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?;
|
||||
let shown = if header { pg.header_story } else { pg.footer_story };
|
||||
Ok(shown.filter(|id| s.doc.parts.contains_key(id)))
|
||||
}
|
||||
|
||||
fn create_hf(s: &mut Session, v: &Value, header: bool) -> CmdResult {
|
||||
let id = match p::u64(v, "page") {
|
||||
Some(page) => {
|
||||
let page = usize::try_from(page).unwrap_or(usize::MAX);
|
||||
s.page_hint = page;
|
||||
page_hf_part(s, page, header)?
|
||||
}
|
||||
None => {
|
||||
let before = s.doc.parts.len();
|
||||
let id = hf_part(s, header);
|
||||
if s.doc.parts.len() > before {
|
||||
s.new_header_parts.push(id);
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
|
||||
Ok(json!({"story": id}))
|
||||
}
|
||||
|
||||
/// The header (or footer) page `page` shows; when it shows none, a new empty one in the slot the
|
||||
/// page uses (first page, even page or the default), as Word creates on a double-click in the
|
||||
/// empty header area (#960). It goes in the first section: sections link to the previous one by
|
||||
/// default, so as in Word it shows on every page that uses that slot and has none of its own.
|
||||
fn page_hf_part(s: &mut Session, page: usize, header: bool) -> Result<u32, CmdError> {
|
||||
if let Some(id) = shown_hf(s, page, header)? {
|
||||
return Ok(id);
|
||||
}
|
||||
let layout = s.layout();
|
||||
let slot = layout.pages.get(page).map(|pg| pg.hf_slot).unwrap_or_default();
|
||||
// The page shows none, so no section up to its own has this slot: the first section's is
|
||||
// inherited all the way to it.
|
||||
let block = s.doc.sections().first().map(|(end, _)| *end).unwrap_or(0);
|
||||
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);
|
||||
let set = if header { &mut sect.headers } else { &mut sect.footers };
|
||||
match slot {
|
||||
wordcraft_layout::HfSlot::First => set.first = Some(id),
|
||||
wordcraft_layout::HfSlot::Even => set.even = Some(id),
|
||||
wordcraft_layout::HfSlot::Default => set.default = Some(id),
|
||||
}
|
||||
s.new_header_parts.push(id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The headers/footers made by `insert.createHeader`/`insert.createFooter` that are still empty
|
||||
/// (one empty paragraph). The list keeps them after they are taken away, so one an undo brings
|
||||
/// back is taken away again on the next close.
|
||||
fn new_empty_headers(s: &Session) -> Vec<u32> {
|
||||
s.new_header_parts
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| {
|
||||
s.doc.parts.get(id).is_some_and(|part| {
|
||||
matches!(part.kind, PartKind::Header | PartKind::Footer)
|
||||
&& part.blocks.len() == 1
|
||||
&& part.blocks.first().is_some_and(|b| matches!(&**b, Block::Para(p) if p.text.is_empty() && p.objects.is_empty()))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The document as saved: without the headers/footers just made and still empty, so an unwritten
|
||||
/// one is never saved (`None` when there are none, to save the document as it is).
|
||||
pub(crate) fn saved_copy(s: &Session) -> Option<wordcraft_doc::Document> {
|
||||
let ids = new_empty_headers(s);
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut doc = s.doc.clone();
|
||||
strip_headers(&mut doc, &ids).then_some(doc)
|
||||
}
|
||||
|
||||
/// Takes headers/footers `ids` out of the document and every section that refers to them;
|
||||
/// whether anything was taken away.
|
||||
fn strip_headers(doc: &mut wordcraft_doc::Document, ids: &[u32]) -> bool {
|
||||
if ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let clear = |set: &mut wordcraft_doc::section::HeaderSet| {
|
||||
for slot in [&mut set.default, &mut set.first, &mut set.even] {
|
||||
if slot.is_some_and(|id| ids.contains(&id)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
};
|
||||
clear(&mut doc.last_section.headers);
|
||||
clear(&mut doc.last_section.footers);
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
if doc.para(StoryRef::Body, &path).is_some_and(|p| p.section.is_some())
|
||||
&& let Ok(p) = doc.para_mut(StoryRef::Body, &path)
|
||||
&& let Some(sec) = p.section.as_mut()
|
||||
{
|
||||
clear(&mut sec.headers);
|
||||
clear(&mut sec.footers);
|
||||
}
|
||||
}
|
||||
let mut any = false;
|
||||
for id in ids {
|
||||
any |= doc.parts.remove(id).is_some();
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
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}))
|
||||
}
|
||||
|
||||
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 = 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);
|
||||
format_date_parts(fmt, y, m, d, (hh, mm, ss), (super::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).
|
||||
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)
|
||||
}
|
||||
|
||||
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 _ = 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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
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>());
|
||||
super::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,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::*;
|
||||
|
||||
/// The header (or footer) of the caret's section in the slot the caret's page uses (first page,
|
||||
/// even page or the default), made when there is none: with Different First Page on, a header
|
||||
/// inserted on page 1 is the one page 1 shows (#2826).
|
||||
pub(super) fn hf_part(s: &mut Session, header: bool) -> u32 {
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
let layout = s.layout();
|
||||
let page = if s.sel.focus.story == StoryRef::Body { layout.page_of(&s.sel.focus) } else { Some(s.page_hint) };
|
||||
let slot = page.and_then(|pg| layout.pages.get(pg)).map(|pg| pg.hf_slot).unwrap_or_default();
|
||||
fn slot_of(slot: wordcraft_layout::HfSlot, set: &mut wordcraft_doc::section::HeaderSet) -> &mut Option<u32> {
|
||||
match slot {
|
||||
wordcraft_layout::HfSlot::First => &mut set.first,
|
||||
wordcraft_layout::HfSlot::Even => &mut set.even,
|
||||
wordcraft_layout::HfSlot::Default => &mut set.default,
|
||||
}
|
||||
}
|
||||
let mut sect = s.doc.section_mut(block).clone();
|
||||
let existing = *slot_of(slot, if header { &mut sect.headers } else { &mut sect.footers });
|
||||
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);
|
||||
*slot_of(slot, if header { &mut sect.headers } else { &mut sect.footers }) = Some(id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Three independently editable cells spanning the section's text width.
|
||||
fn three_column_hf(s: &Session, style: &str) -> wordcraft_doc::Blocks {
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
// The section `section_mut` would pick, read without making anything unique.
|
||||
let sections = s.doc.sections();
|
||||
let own = sections.iter().position(|(end, _)| block <= *end).filter(|i| i + 1 < sections.len()).and_then(|i| sections.get(i));
|
||||
let width = own.map_or_else(|| s.doc.last_section.text_width(), |(_, sec)| sec.text_width());
|
||||
let mut table = Table::new(1, 3, width);
|
||||
table.props.style = Some("TableNormal".into());
|
||||
table.props.width = Some(width);
|
||||
table.props.fixed = true;
|
||||
// Explicit nil borders also override borders inherited from the document's table style.
|
||||
table.props.borders = Some(Borders::all(Border::default()));
|
||||
for row in &mut table.rows {
|
||||
for (cell, align) in row.cells.iter_mut().zip([Align::Left, Align::Center, Align::Right]) {
|
||||
let mut para = Paragraph::new().styled(style);
|
||||
para.props.align = Some(align);
|
||||
cell.blocks = vec![para_block(para)];
|
||||
}
|
||||
}
|
||||
// Keep a trailing paragraph so the story can also be edited after the table.
|
||||
vec![std::sync::Arc::new(Block::Table(table)), para_block(Paragraph::new().styled(style))]
|
||||
}
|
||||
|
||||
pub(super) 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 p::str(v, "preset") == Some("blankThree") {
|
||||
let blocks = three_column_hf(s, style);
|
||||
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 {
|
||||
"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, dirty: false, code: false, form: String::new() },
|
||||
&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}))
|
||||
}
|
||||
|
||||
pub(super) fn edit_hf(s: &mut Session, v: &Value, header: bool) -> CmdResult {
|
||||
let mut page = p::u64(v, "page").map(|page| usize::try_from(page).unwrap_or(usize::MAX));
|
||||
if page.is_none() && s.sel.focus.story == StoryRef::Body {
|
||||
// The caret's page: with Different First Page on, page 1 edits its own header (#2826).
|
||||
let focus = s.sel.focus.clone();
|
||||
page = s.layout().page_of(&focus);
|
||||
}
|
||||
let shown = match page {
|
||||
Some(page) => shown_hf(s, page, header)?,
|
||||
None => {
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
let set = s
|
||||
.doc
|
||||
.sections()
|
||||
.into_iter()
|
||||
.find(|(end, _)| block <= *end)
|
||||
.or_else(|| s.doc.sections().pop())
|
||||
.map(|(_, sp)| if header { sp.headers.default } else { sp.footers.default });
|
||||
set.flatten().filter(|id| s.doc.parts.contains_key(id))
|
||||
}
|
||||
};
|
||||
let Some(id) = shown else {
|
||||
// None yet: made by the mutating command, so it is an undo step and protection applies.
|
||||
let cmd = if header { "insert.createHeader" } else { "insert.createFooter" };
|
||||
let params = page.map_or_else(|| json!({}), |page| json!({ "page": page }));
|
||||
return s.run(cmd, ¶ms);
|
||||
};
|
||||
if let Some(page) = page {
|
||||
s.page_hint = page;
|
||||
}
|
||||
s.touch();
|
||||
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
|
||||
Ok(json!({"story": id}))
|
||||
}
|
||||
|
||||
/// The header (or footer) page `page` shows, if any; an error for a page that isn't there.
|
||||
fn shown_hf(s: &mut Session, page: usize, header: bool) -> Result<Option<u32>, CmdError> {
|
||||
let layout = s.layout();
|
||||
let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?;
|
||||
let shown = if header { pg.header_story } else { pg.footer_story };
|
||||
Ok(shown.filter(|id| s.doc.parts.contains_key(id)))
|
||||
}
|
||||
|
||||
pub(super) fn create_hf(s: &mut Session, v: &Value, header: bool) -> CmdResult {
|
||||
let id = match p::u64(v, "page") {
|
||||
Some(page) => {
|
||||
let page = usize::try_from(page).unwrap_or(usize::MAX);
|
||||
s.page_hint = page;
|
||||
page_hf_part(s, page, header)?
|
||||
}
|
||||
None => {
|
||||
let before = s.doc.parts.len();
|
||||
let id = hf_part(s, header);
|
||||
if s.doc.parts.len() > before {
|
||||
s.new_header_parts.push(id);
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
|
||||
Ok(json!({"story": id}))
|
||||
}
|
||||
|
||||
/// The header (or footer) page `page` shows; when it shows none, a new empty one in the slot the
|
||||
/// page uses (first page, even page or the default), as Word creates on a double-click in the
|
||||
/// empty header area (#960). It goes in the first section: sections link to the previous one by
|
||||
/// default, so as in Word it shows on every page that uses that slot and has none of its own.
|
||||
fn page_hf_part(s: &mut Session, page: usize, header: bool) -> Result<u32, CmdError> {
|
||||
if let Some(id) = shown_hf(s, page, header)? {
|
||||
return Ok(id);
|
||||
}
|
||||
let layout = s.layout();
|
||||
let slot = layout.pages.get(page).map(|pg| pg.hf_slot).unwrap_or_default();
|
||||
// The page shows none, so no section up to its own has this slot: the first section's is
|
||||
// inherited all the way to it.
|
||||
let block = s.doc.sections().first().map(|(end, _)| *end).unwrap_or(0);
|
||||
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);
|
||||
let set = if header { &mut sect.headers } else { &mut sect.footers };
|
||||
match slot {
|
||||
wordcraft_layout::HfSlot::First => set.first = Some(id),
|
||||
wordcraft_layout::HfSlot::Even => set.even = Some(id),
|
||||
wordcraft_layout::HfSlot::Default => set.default = Some(id),
|
||||
}
|
||||
s.new_header_parts.push(id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The headers/footers made by `insert.createHeader`/`insert.createFooter` that are still empty
|
||||
/// (one empty paragraph). The list keeps them after they are taken away, so one an undo brings
|
||||
/// back is taken away again on the next close.
|
||||
pub(super) fn new_empty_headers(s: &Session) -> Vec<u32> {
|
||||
s.new_header_parts
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| {
|
||||
s.doc.parts.get(id).is_some_and(|part| {
|
||||
matches!(part.kind, PartKind::Header | PartKind::Footer)
|
||||
&& part.blocks.len() == 1
|
||||
&& part.blocks.first().is_some_and(|b| matches!(&**b, Block::Para(p) if p.text.is_empty() && p.objects.is_empty()))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The document as saved: without the headers/footers just made and still empty, so an unwritten
|
||||
/// one is never saved (`None` when there are none, to save the document as it is).
|
||||
pub(crate) fn saved_copy(s: &Session) -> Option<wordcraft_doc::Document> {
|
||||
let ids = new_empty_headers(s);
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut doc = s.doc.clone();
|
||||
strip_headers(&mut doc, &ids).then_some(doc)
|
||||
}
|
||||
|
||||
/// Takes headers/footers `ids` out of the document and every section that refers to them;
|
||||
/// whether anything was taken away.
|
||||
pub(super) fn strip_headers(doc: &mut wordcraft_doc::Document, ids: &[u32]) -> bool {
|
||||
if ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let clear = |set: &mut wordcraft_doc::section::HeaderSet| {
|
||||
for slot in [&mut set.default, &mut set.first, &mut set.even] {
|
||||
if slot.is_some_and(|id| ids.contains(&id)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
};
|
||||
clear(&mut doc.last_section.headers);
|
||||
clear(&mut doc.last_section.footers);
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
if doc.para(StoryRef::Body, &path).is_some_and(|p| p.section.is_some())
|
||||
&& let Ok(p) = doc.para_mut(StoryRef::Body, &path)
|
||||
&& let Some(sec) = p.section.as_mut()
|
||||
{
|
||||
clear(&mut sec.headers);
|
||||
clear(&mut sec.footers);
|
||||
}
|
||||
}
|
||||
let mut any = false;
|
||||
for id in ids {
|
||||
any |= doc.parts.remove(id).is_some();
|
||||
}
|
||||
any
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::*;
|
||||
|
||||
/// What a link can point to inside the document (Insert › Link › Place in This Document): the top
|
||||
/// of the document, its headings (body block, level, text) and its bookmarks, without the hidden
|
||||
/// ones (names starting with `_`).
|
||||
pub(super) fn link_targets(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let headings: Vec<Value> = crate::cmd::references::headings(s)
|
||||
.into_iter()
|
||||
.map(|(block, level, text, _)| json!({"block": block, "level": level, "text": text}))
|
||||
.collect();
|
||||
let mut bookmarks: Vec<String> = Vec::new();
|
||||
for (name, _) in s.doc.bookmarks() {
|
||||
if !name.starts_with('_') && !bookmarks.contains(&name) {
|
||||
bookmarks.push(name);
|
||||
}
|
||||
}
|
||||
Ok(json!({"top": true, "headings": headings, "bookmarks": bookmarks}))
|
||||
}
|
||||
|
||||
/// The address and the default text of a link to a place in this document, from `top`, `bookmark`
|
||||
/// or `heading` (a heading gets a hidden `_Toc` bookmark, as in Word); None when none is given.
|
||||
fn place_target(s: &mut Session, v: &Value) -> Result<Option<(String, String)>, CmdError> {
|
||||
if p::bool(v, "top") == Some(true) {
|
||||
return Ok(Some(("#_top".into(), "Top of the Document".into())));
|
||||
}
|
||||
if let Some(name) = p::str(v, "bookmark") {
|
||||
if !s.doc.bookmarks().iter().any(|(n, _)| n == name) {
|
||||
return Err(CmdError::Params(format!("no bookmark `{name}`")));
|
||||
}
|
||||
return Ok(Some((format!("#{name}"), name.to_string())));
|
||||
}
|
||||
if let Some(block) = p::u64(v, "heading") {
|
||||
let Some((i, _, text, lead)) = crate::cmd::references::headings(s).into_iter().find(|h| h.0 as u64 == block) else {
|
||||
return Err(CmdError::Params(format!("block {block} is not a heading")));
|
||||
};
|
||||
let name = crate::cmd::references::toc_bookmark(s, i, lead)?;
|
||||
return Ok(Some((format!("#{name}"), text)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (url, label) = match p::str(v, "url") {
|
||||
Some(url) => (url.to_string(), url.to_string()),
|
||||
None => match place_target(s, v)? {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
s.push_ui_request(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(&label).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)
|
||||
}
|
||||
|
||||
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
|
||||
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
|
||||
pub fn bookmark_name_ok(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(|c| c.is_alphanumeric() || c == '_') && name.chars().count() <= 40
|
||||
}
|
||||
|
||||
/// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark.
|
||||
pub fn bookmark_range(doc: &wordcraft_doc::Document, name: &str) -> Option<(Pos, Pos)> {
|
||||
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
|
||||
Some(bookmark_span(doc, name, &start))
|
||||
}
|
||||
|
||||
/// The text of bookmark `name` whose start mark is at `start`: from just after that mark to its
|
||||
/// end mark — in a later paragraph when it spans several (the start again when it has no end).
|
||||
pub fn bookmark_span(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> (Pos, Pos) {
|
||||
let from = Pos { off: start.off.saturating_add(wordcraft_doc::para::OBJ.len_utf8()), ..start.clone() };
|
||||
let end = doc.para_paths(start.story).into_iter().skip_while(|p| *p != start.path).find_map(|path| {
|
||||
let para = doc.para(start.story, &path)?;
|
||||
let off = para.object_offsets().into_iter().find(|o| {
|
||||
(path != start.path || *o > start.off) && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name)
|
||||
})?;
|
||||
Some(Pos { story: start.story, path, off })
|
||||
});
|
||||
let end = end.unwrap_or_else(|| from.clone());
|
||||
(from, end)
|
||||
}
|
||||
|
||||
/// Remove bookmark `name`'s start and end marks, in whichever paragraphs and stories they are,
|
||||
/// keeping the selection on the same text. Returns whether the bookmark was there.
|
||||
fn remove_bookmark(s: &mut Session, name: &str) -> Result<bool, CmdError> {
|
||||
let mark_len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let mut found = false;
|
||||
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
|
||||
for story in stories {
|
||||
for path in s.doc.para_paths(story) {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
let mut marks = Vec::new();
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::BookmarkStart { name: n }) if n == name => {
|
||||
found = true;
|
||||
marks.push(off);
|
||||
}
|
||||
Some(InlineObject::BookmarkEnd { name: n }) if n == name => marks.push(off),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if marks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let para = s.doc.para_mut(story, &path)?;
|
||||
for off in marks.into_iter().rev() {
|
||||
para.delete(off, off + mark_len)?;
|
||||
for at in [&mut s.sel.anchor, &mut s.sel.focus] {
|
||||
if at.story == story && at.path == path && at.off > off {
|
||||
at.off = at.off.saturating_sub(mark_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.clamp_selection();
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub(super) fn delete_bookmark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let name = p::req_str(v, "name")?.trim();
|
||||
if !remove_bookmark(s, name)? {
|
||||
return Err(CmdError::Params(format!("no bookmark `{name}`")));
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
pub(super) fn bookmark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let name = p::req_str(v, "name")?.trim().to_string();
|
||||
if !bookmark_name_ok(&name) {
|
||||
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.
|
||||
remove_bookmark(s, &name)?;
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use super::*;
|
||||
|
||||
/// 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<Path, CmdError> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub(super) 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);
|
||||
}
|
||||
// Like Word, a table inserted in a right-to-left paragraph is a right-to-left table (#2634):
|
||||
// its first cell stands at the right, so Tab, which follows reading order, moves leftwards.
|
||||
if s.doc.para_at(&s.sel.focus).is_some_and(|p| s.doc.styles.resolve_para(&p.props).bidi) {
|
||||
t.props.bidi_visual = true;
|
||||
}
|
||||
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<Vec<u8>> {
|
||||
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
|
||||
}
|
||||
|
||||
pub(crate) 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") {
|
||||
crate::io::read_file(path).map_err(CmdError::Failed)?
|
||||
} else {
|
||||
s.push_ui_request(json!({"open": "insertPicture"}));
|
||||
return sel_result(s);
|
||||
};
|
||||
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
|
||||
}
|
||||
|
||||
/// The media extension of an encoded image by its magic bytes, as the picture probe accepted it
|
||||
/// (TIFF included when its compression is one the `image` crate decodes within limits).
|
||||
pub(crate) fn image_ext(bytes: &[u8]) -> &'static str {
|
||||
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",
|
||||
Some([b'I', b'I', 0x2A, 0x00]) | Some([b'M', b'M', 0x00, 0x2A]) => "tiff",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. The caret ends after it, so a
|
||||
/// script's next typing doesn't replace it (#965); Picture Format commands still act on it. The
|
||||
/// app selects a picture the person inserts (`WordApp::run`).
|
||||
fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str) -> CmdResult {
|
||||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). A picture that
|
||||
// is never decoded (a TIFF with JPEG strips, #3645) isn't inserted.
|
||||
let (natural_w, natural_h, ext) = match wordcraft_geom::picture::probe(&bytes) {
|
||||
Ok(pic) => (pic.width as f32 * 0.75, pic.height as f32 * 0.75, image_ext(&bytes)),
|
||||
Err(wordcraft_geom::picture::Refusal::Unsupported(why)) => return Err(CmdError::Failed(why.into())),
|
||||
Err(_) => match wordcraft_render::metafile_size(&bytes) {
|
||||
Some(size) => size,
|
||||
None => return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into())),
|
||||
},
|
||||
};
|
||||
let key = s.doc.add_media(bytes, ext);
|
||||
// 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) = (natural_w, natural_h);
|
||||
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 obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
|
||||
// A selected Drawing Canvas takes it in.
|
||||
if let Some(i) = crate::cmd::canvas::add_member(s, obj.clone())? {
|
||||
return Ok(json!({"media": key, "canvasMember": i}));
|
||||
}
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection::caret(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.
|
||||
pub(super) 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<Value> = 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.push_ui_request(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)
|
||||
}
|
||||
|
||||
pub(super) 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(if kind.is_connector() { 0.0 } else { 1.0 }, 2000.0);
|
||||
let fill = match p::str(v, "fill") {
|
||||
Some(c) => Rgb::parse(c),
|
||||
None if kind.is_open() => None,
|
||||
None => Some(Rgb(0x15, 0x60, 0x82)),
|
||||
};
|
||||
// Arrowheads on lines and connectors.
|
||||
let (arrow_start, arrow_end) = match p::str(v, "arrow") {
|
||||
Some("end") => (false, true),
|
||||
Some("start") => (true, false),
|
||||
Some("both") => (true, true),
|
||||
Some("none") | None => (false, false),
|
||||
Some(x) => return Err(CmdError::Params(format!("`arrow`: none, start, end or both, not `{x}`"))),
|
||||
};
|
||||
let extra = wordcraft_doc::connector::ShapeExtra { arrow_start, arrow_end, ..Default::default() };
|
||||
// Connectors float in front of the text, so they can be glued to floating shapes.
|
||||
let float = if kind.is_connector() {
|
||||
Float { wrap: Wrap::InFrontOfText, ..Default::default() }
|
||||
} else {
|
||||
Float { wrap: Wrap::Inline, ..Default::default() }
|
||||
};
|
||||
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
|
||||
let obj = InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill: fill.map(wordcraft_doc::fill::Fill::solid),
|
||||
stroke,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra,
|
||||
};
|
||||
// A selected Drawing Canvas takes it in (and stays selected for the next one).
|
||||
if crate::cmd::canvas::add_member(s, obj.clone())?.is_some() {
|
||||
return sel_result(s);
|
||||
}
|
||||
// Word draws a new shape beside what is selected and never deletes it: the shape just
|
||||
// inserted (which is selected) stays, and so does selected text (#2019). It goes after the
|
||||
// selection and becomes the selection. (A picture does replace the selection, as in Word.)
|
||||
let props = s.typing_props();
|
||||
let (_, at) = s.sel.ordered();
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection { anchor: at, focus: end };
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
pub(super) 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.try_add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))])?;
|
||||
let obj = InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill: Some(wordcraft_doc::fill::Fill::solid(Rgb::WHITE)),
|
||||
stroke: Some(Rgb::BLACK),
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
story: Some(id),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
// A selected Drawing Canvas takes it in; otherwise it goes at the caret.
|
||||
if crate::cmd::canvas::add_member(s, obj.clone())?.is_none() {
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
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}))
|
||||
}
|
||||
Reference in New Issue
Block a user