mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 08:50:02 -06:00
Word brings a document's alternative format chunks (w:altChunk) in where they stand when it opens the file; WordCraft left them out, so a document made of one (as generators and mail-merge tools write them) opened empty. - docx: read_with/ReadOptions with a ChunkImporter for formats other than WordprocessingML; nested Word documents are read here (at most 4 deep, 256 chunks and 512 MB of chunk bytes a file, XML depth counted on). - doc: Document::adopt (moved from paste) brings another document's media, lists, styles, notes, comments and revisions along under new ids; pictures in groups and charts are relinked. Insert > Text from File uses it and now keeps the file's pictures and lists. - engine: chunks in MHT, HTML (in their charset), RTF and plain text go through the format importers. - formats: MHT web archives open (MIME, quoted-printable/base64, charsets, pictures by Content-Location or cid). HTML reads style element rules (simple selectors, cascade, budgets), paragraph margins, indents, line height, cell widths and borders; an opened page takes Word's web-page defaults (Times New Roman 12 pt, single lines, black headings, automatic spacing). html::decode knows the single-byte Windows code pages. docxide-pdf suite: croatian_regulations_altchunk goes from one empty page to Word's 8 pages, SSIM 2.7% -> 78.7%; no other document changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
10 KiB
Rust
151 lines
10 KiB
Rust
//! 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, Border, Borders, 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};
|
||
|
||
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, _| {
|
||
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|straightConnector|elbowConnector|curvedConnector", "width"?: pt, "height"?: pt, "fill"?: "RRGGBB", "stroke"?: "RRGGBB", "arrow"?: "none"|"start"|"end"|"both" (lines and connectors; glue a connector's ends with shape.connect)}"#),
|
||
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} | {"bookmark": name} | {"heading": block (see link.targets)} | {"top": true}, "text"?: string (Place in This Document: a bookmark, a heading or the top of the document)"#,
|
||
),
|
||
CommandSpec::new("link.targets", "Link", "Insert › Links", link_targets).pure(),
|
||
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.deleteBookmark", "Delete Bookmark", "Insert › Links", delete_bookmark).params(r#"{"name": string}"#),
|
||
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"}"#),
|
||
// Pure: going into an existing header/footer changes nothing; one the page doesn't have yet
|
||
// is made by the mutating `insert.createHeader`/`insert.createFooter` (undoable, refused in
|
||
// a protected document).
|
||
CommandSpec::new("insert.editHeader", "Edit Header", "Insert › Header & Footer › Header", |s, v| edit_hf(s, v, true))
|
||
.params(r#"{"page"?: number (0-based: the header that page shows, created if it has none)}"#)
|
||
.pure(),
|
||
CommandSpec::new("insert.editFooter", "Edit Footer", "Insert › Header & Footer › Footer", |s, v| edit_hf(s, v, false))
|
||
.params(r#"{"page"?: number (0-based: the footer that page shows, created if it has none)}"#)
|
||
.pure(),
|
||
CommandSpec::new("insert.createHeader", "Header", "Insert › Header & Footer", |s, v| create_hf(s, v, true))
|
||
.params(r#"{"page"?: number (0-based; default: the caret's section)} — the header that page shows, created if it has none, with the caret in it"#),
|
||
CommandSpec::new("insert.createFooter", "Footer", "Insert › Header & Footer", |s, v| create_hf(s, v, false))
|
||
.params(r#"{"page"?: number (0-based; default: the caret's section)} — the footer that page shows, created if it has none, with the caret in it"#),
|
||
CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| {
|
||
// Back where the caret was in the body, as Word does, not on page 1.
|
||
let back = s.hf_return.take().filter(|_| s.in_header_footer());
|
||
if !new_empty_headers(s).is_empty() {
|
||
// Its own undo step; a protected document keeps it, and the caret still leaves.
|
||
let _ = s.run("insert.removeEmptyHeaders", &json!({}));
|
||
}
|
||
s.sel = back.unwrap_or_else(|| Selection::caret(s.doc.start_of(StoryRef::Body)));
|
||
sel_result(s)
|
||
})
|
||
.pure(),
|
||
CommandSpec::new("insert.removeEmptyHeaders", "Close Header and Footer", "Header & Footer", |s, _| {
|
||
let ids = new_empty_headers(s);
|
||
if strip_headers(&mut s.doc, &ids) {
|
||
s.touch();
|
||
}
|
||
sel_result(s)
|
||
}),
|
||
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", drop_cap)
|
||
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
|
||
.when(drop_cap_off),
|
||
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.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)?;
|
||
// The file's pictures, lists and styles come along (its own keys and ids mean nothing here).
|
||
let mut frag = s.doc.adopt(doc);
|
||
super::paste::unlink_sections(&mut frag.blocks);
|
||
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}"#),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|