diff --git a/Cargo.lock b/Cargo.lock index 72255a971..f1f532735 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -623,6 +632,29 @@ dependencies = [ "once_cell", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.8.11" @@ -916,6 +948,22 @@ dependencies = [ "wordcraft-geom", ] +[[package]] +name = "wordcraft-engine" +version = "0.1.0" +dependencies = [ + "log", + "regex", + "serde", + "serde_json", + "thiserror", + "wordcraft-doc", + "wordcraft-fonts", + "wordcraft-geom", + "wordcraft-layout", + "wordcraft-render", +] + [[package]] name = "wordcraft-fonts" version = "0.1.0" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 000000000..cfa63b0a4 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wordcraft-engine" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "WordCraft engine: session, selection, undo, the command registry (every action is a command), Word's command catalog, file I/O dispatch" + +[lints] +workspace = true + +[dependencies] +wordcraft-geom = { workspace = true } +wordcraft-doc = { workspace = true } +wordcraft-fonts = { workspace = true } +wordcraft-layout = { workspace = true } +wordcraft-render = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +regex = { workspace = true } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs new file mode 100644 index 000000000..3d36d5ef0 --- /dev/null +++ b/crates/engine/src/catalog.rs @@ -0,0 +1,482 @@ +//! The word-processor feature catalog: the incumbent's ribbon and menu commands (feature names +//! only) mapped to our command ids. `parity()` compares it with the registry; the gap is the +//! work list (`cargo xtask parity` → docs/parity.md). + +use serde_json::{Value, json}; + +use crate::Registry; + +/// `Tab|Group|Feature|command id` +pub const CATALOG: &str = "\ +File|Backstage|New|file.new +File|Backstage|Open|file.open +File|Backstage|Save|file.save +File|Backstage|Save As|file.saveAs +File|Backstage|Print|file.print +File|Backstage|Close|file.close +File|Backstage|Info|file.info +File|Backstage|Properties|file.properties +File|Backstage|Export PDF|file.exportPdf +File|Backstage|Export Image|file.exportPng +File|Backstage|Options|file.options +File|Backstage|Share|file.share +File|Backstage|Protect Document|file.protect +File|Backstage|Inspect Document|file.inspect +File|Backstage|Check Accessibility|file.accessibility +File|Backstage|Check Compatibility|file.compatibility +File|Backstage|Version History|file.versions +File|Backstage|Recover Unsaved|file.recover +File|Backstage|New from Template|file.newFromTemplate +File|Backstage|Save as Template|file.saveTemplate +Quick Access|Toolbar|Undo|edit.undo +Quick Access|Toolbar|Redo|edit.redo +Quick Access|Toolbar|Repeat|edit.repeat +Quick Access|Toolbar|AutoSave|file.autosave +Home|Clipboard|Paste|edit.paste +Home|Clipboard|Paste Keep Text Only|edit.pasteText +Home|Clipboard|Paste Merge Formatting|edit.pasteMerge +Home|Clipboard|Paste Special|edit.pasteSpecial +Home|Clipboard|Cut|edit.cut +Home|Clipboard|Copy|edit.copy +Home|Clipboard|Format Painter|edit.formatPainter +Home|Clipboard|Copy Formatting|edit.copyFormat +Home|Clipboard|Paste Formatting|edit.pasteFormat +Home|Clipboard|Clipboard Pane|edit.clipboardPane +Home|Font|Font|format.font +Home|Font|Font Size|format.size +Home|Font|Increase Font Size|format.growFont +Home|Font|Decrease Font Size|format.shrinkFont +Home|Font|Change Case|format.changeCase +Home|Font|Clear All Formatting|format.clear +Home|Font|Bold|format.bold +Home|Font|Italic|format.italic +Home|Font|Underline|format.underline +Home|Font|Double Underline|format.doubleUnderline +Home|Font|Strikethrough|format.strikethrough +Home|Font|Subscript|format.subscript +Home|Font|Superscript|format.superscript +Home|Font|Text Effects Outline|format.outline +Home|Font|Text Effects Shadow|format.shadow +Home|Font|Text Highlight Color|format.highlight +Home|Font|Font Color|format.color +Home|Font|Character Shading|format.shading +Home|Font|Font Dialog|format.fontDialog +Home|Font|Double Strikethrough|format.doubleStrikethrough +Home|Font|Small Caps|format.smallCaps +Home|Font|All Caps|format.allCaps +Home|Font|Hidden|format.hidden +Home|Font|Emboss|format.emboss +Home|Font|Engrave|format.engrave +Home|Font|Character Spacing|format.spacing +Home|Font|Character Scale|format.scale +Home|Font|Character Position|format.position +Home|Font|Character Border|format.border +Home|Font|Phonetic Guide|format.phonetic +Home|Font|Enclose Characters|format.enclose +Home|Paragraph|Bullets|para.bullets +Home|Paragraph|Numbering|para.numbering +Home|Paragraph|Multilevel List|para.multilevel +Home|Paragraph|Change List Level|para.listLevel +Home|Paragraph|Restart Numbering|para.restartNumbering +Home|Paragraph|Define New Bullet|para.defineBullet +Home|Paragraph|Define New Number Format|para.defineNumber +Home|Paragraph|Set Numbering Value|para.setNumberingValue +Home|Paragraph|Decrease Indent|para.outdent +Home|Paragraph|Increase Indent|para.indent +Home|Paragraph|Sort|para.sort +Home|Paragraph|Show/Hide Marks|view.marks +Home|Paragraph|Align Left|para.alignLeft +Home|Paragraph|Center|para.alignCenter +Home|Paragraph|Align Right|para.alignRight +Home|Paragraph|Justify|para.justify +Home|Paragraph|Distributed|para.distribute +Home|Paragraph|Line Spacing|para.lineSpacing +Home|Paragraph|Add Space Before|para.addSpaceBefore +Home|Paragraph|Remove Space After|para.removeSpaceAfter +Home|Paragraph|Shading|para.shading +Home|Paragraph|Borders|para.borders +Home|Paragraph|Horizontal Line|insert.horizontalLine +Home|Paragraph|Paragraph Dialog|para.dialog +Home|Paragraph|Tabs|para.tabs +Home|Paragraph|Keep with Next|para.keepNext +Home|Paragraph|Keep Lines Together|para.keepLines +Home|Paragraph|Page Break Before|para.pageBreakBefore +Home|Paragraph|Widow/Orphan Control|para.widowControl +Home|Paragraph|Outline Level|para.outlineLevel +Home|Paragraph|Hanging Indent|para.hangingIndent +Home|Paragraph|Text Direction RTL|para.rtl +Home|Paragraph|Asian Typography|para.asianTypography +Home|Styles|Styles Gallery|para.style +Home|Styles|Normal|para.normal +Home|Styles|Heading 1|para.heading1 +Home|Styles|Heading 2|para.heading2 +Home|Styles|Heading 3|para.heading3 +Home|Styles|Create a Style|styles.create +Home|Styles|Modify Style|styles.modify +Home|Styles|Update Style to Match|styles.updateToMatch +Home|Styles|Delete Style|styles.delete +Home|Styles|Styles Pane|styles.pane +Home|Styles|Style Inspector|styles.inspector +Home|Styles|Manage Styles|styles.manage +Home|Styles|Apply Styles|styles.apply +Home|Styles|Character Style|format.charStyle +Home|Editing|Find|edit.find +Home|Editing|Find Next|edit.findNext +Home|Editing|Find Previous|edit.findPrevious +Home|Editing|Advanced Find|edit.advancedFind +Home|Editing|Replace|edit.replace +Home|Editing|Replace All|edit.replaceAll +Home|Editing|Go To|edit.goto +Home|Editing|Select All|select.all +Home|Editing|Select Objects|select.objects +Home|Editing|Select Similar Formatting|select.similar +Home|Voice|Dictate|tools.dictate +Home|Editor|Editor|review.editor +Insert|Pages|Cover Page|insert.coverPage +Insert|Pages|Blank Page|insert.blankPage +Insert|Pages|Page Break|insert.pageBreak +Insert|Tables|Insert Table|insert.table +Insert|Tables|Draw Table|table.draw +Insert|Tables|Convert Text to Table|table.fromText +Insert|Tables|Excel Spreadsheet|insert.spreadsheet +Insert|Tables|Quick Tables|table.quick +Insert|Illustrations|Pictures|insert.picture +Insert|Illustrations|Online Pictures|insert.onlinePicture +Insert|Illustrations|Shapes|insert.shape +Insert|Illustrations|Icons|insert.icon +Insert|Illustrations|3D Models|insert.model3d +Insert|Illustrations|SmartArt|insert.smartArt +Insert|Illustrations|Chart|insert.chart +Insert|Illustrations|Screenshot|insert.screenshot +Insert|Illustrations|Drawing Canvas|insert.canvas +Insert|Add-ins|Get Add-ins|addins.get +Insert|Media|Online Video|insert.video +Insert|Links|Link|insert.link +Insert|Links|Remove Link|insert.removeLink +Insert|Links|Bookmark|insert.bookmark +Insert|Links|Cross-reference|insert.crossReference +Insert|Comments|Comment|review.newComment +Insert|Header & Footer|Header|insert.header +Insert|Header & Footer|Footer|insert.footer +Insert|Header & Footer|Page Number|insert.pageNumber +Insert|Header & Footer|Edit Header|insert.editHeader +Insert|Header & Footer|Edit Footer|insert.editFooter +Insert|Header & Footer|Remove Header|insert.removeHeader +Insert|Header & Footer|Remove Footer|insert.removeFooter +Insert|Header & Footer|Format Page Numbers|layout.pageNumberFormat +Insert|Header & Footer|Close Header and Footer|insert.closeHeader +Insert|Header & Footer|Different First Page|layout.differentFirstPage +Insert|Header & Footer|Different Odd & Even|layout.differentOddEven +Insert|Header & Footer|Link to Previous|layout.linkToPrevious +Insert|Text|Text Box|insert.textBox +Insert|Text|Quick Parts|insert.quickParts +Insert|Text|AutoText|insert.autoText +Insert|Text|Document Property|insert.docProperty +Insert|Text|Field|insert.field +Insert|Text|WordArt|insert.wordArt +Insert|Text|Drop Cap|insert.dropCap +Insert|Text|Signature Line|insert.signatureLine +Insert|Text|Date & Time|insert.dateTime +Insert|Text|Object|insert.object +Insert|Text|Text from File|insert.textFromFile +Insert|Symbols|Equation|insert.equation +Insert|Symbols|Ink Equation|insert.inkEquation +Insert|Symbols|Symbol|insert.symbol +Insert|Symbols|Nonbreaking Space|text.nbsp +Insert|Symbols|Nonbreaking Hyphen|text.nbHyphen +Insert|Symbols|Optional Hyphen|text.optionalHyphen +Draw|Tools|Select|draw.select +Draw|Tools|Lasso Select|draw.lasso +Draw|Tools|Eraser|draw.eraser +Draw|Pens|Pen|draw.pen +Draw|Pens|Pencil|draw.pencil +Draw|Pens|Highlighter|draw.highlighter +Draw|Pens|Add Pen|draw.addPen +Draw|Convert|Ink to Shape|draw.inkToShape +Draw|Convert|Ink to Math|draw.inkToMath +Draw|Insert|Drawing Canvas|insert.canvas +Draw|Replay|Ink Replay|draw.replay +Design|Document Formatting|Themes|design.theme +Design|Document Formatting|Style Set|design.styleSet +Design|Document Formatting|Colors|design.themeColors +Design|Document Formatting|Fonts|design.themeFonts +Design|Document Formatting|Paragraph Spacing|design.paragraphSpacing +Design|Document Formatting|Effects|design.effects +Design|Document Formatting|Set as Default|design.setDefault +Design|Page Background|Watermark|design.watermark +Design|Page Background|Page Color|design.pageColor +Design|Page Background|Page Borders|design.pageBorders +Layout|Page Setup|Margins|layout.margins +Layout|Page Setup|Orientation|layout.orientation +Layout|Page Setup|Size|layout.size +Layout|Page Setup|Columns|layout.columns +Layout|Page Setup|Breaks|layout.break +Layout|Page Setup|Line Numbers|layout.lineNumbers +Layout|Page Setup|Hyphenation|layout.hyphenation +Layout|Page Setup|Page Setup Dialog|layout.pageSetup +Layout|Page Setup|Vertical Alignment|layout.verticalAlign +Layout|Paragraph|Indent|para.indents +Layout|Paragraph|Spacing|para.spacing +Layout|Arrange|Position|arrange.position +Layout|Arrange|Wrap Text|arrange.wrap +Layout|Arrange|Bring Forward|arrange.bringForward +Layout|Arrange|Send Backward|arrange.sendBackward +Layout|Arrange|Selection Pane|arrange.selectionPane +Layout|Arrange|Align|arrange.align +Layout|Arrange|Group|arrange.group +Layout|Arrange|Rotate|arrange.rotate +References|Table of Contents|Table of Contents|references.toc +References|Table of Contents|Add Text|references.addText +References|Table of Contents|Update Table|references.updateToc +References|Table of Contents|Remove Table of Contents|references.removeToc +References|Footnotes|Insert Footnote|references.footnote +References|Footnotes|Insert Endnote|references.endnote +References|Footnotes|Next Footnote|references.nextFootnote +References|Footnotes|Show Notes|references.notes +References|Footnotes|Footnote Options|references.noteOptions +References|Research|Researcher|references.researcher +References|Citations|Insert Citation|references.citation +References|Citations|Manage Sources|references.sources +References|Citations|Style|references.citationStyle +References|Citations|Bibliography|references.bibliography +References|Captions|Insert Caption|references.caption +References|Captions|Insert Table of Figures|references.tableOfFigures +References|Captions|Update Table of Figures|references.updateFigures +References|Captions|Cross-reference|insert.crossReference +References|Index|Mark Entry|references.markEntry +References|Index|Insert Index|references.index +References|Index|Update Index|references.updateIndex +References|Table of Authorities|Mark Citation|references.markCitation +References|Table of Authorities|Insert Table of Authorities|references.tableOfAuthorities +References|Fields|Update Fields|references.updateFields +Mailings|Create|Envelopes|mailings.envelopes +Mailings|Create|Labels|mailings.labels +Mailings|Start Mail Merge|Start Mail Merge|mailings.start +Mailings|Start Mail Merge|Select Recipients|mailings.recipients +Mailings|Start Mail Merge|Edit Recipient List|mailings.editRecipients +Mailings|Write & Insert Fields|Highlight Merge Fields|mailings.highlightFields +Mailings|Write & Insert Fields|Address Block|mailings.addressBlock +Mailings|Write & Insert Fields|Greeting Line|mailings.greetingLine +Mailings|Write & Insert Fields|Insert Merge Field|mailings.insertField +Mailings|Write & Insert Fields|Rules|mailings.rules +Mailings|Write & Insert Fields|Match Fields|mailings.matchFields +Mailings|Preview Results|Preview Results|mailings.preview +Mailings|Preview Results|Next Record|mailings.next +Mailings|Preview Results|Previous Record|mailings.previous +Mailings|Preview Results|Find Recipient|mailings.findRecipient +Mailings|Preview Results|Check for Errors|mailings.checkErrors +Mailings|Finish|Finish & Merge|mailings.finish +Review|Proofing|Spelling & Grammar|review.spelling +Review|Proofing|Thesaurus|review.thesaurus +Review|Proofing|Word Count|review.wordCount +Review|Speech|Read Aloud|review.readAloud +Review|Accessibility|Check Accessibility|file.accessibility +Review|Language|Translate|review.translate +Review|Language|Language|review.language +Review|Comments|New Comment|review.newComment +Review|Comments|Delete|review.deleteComment +Review|Comments|Previous|review.previousComment +Review|Comments|Next|review.nextComment +Review|Comments|Show Comments|review.comments +Review|Comments|Resolve|review.resolveComment +Review|Comments|Reply|review.reply +Review|Tracking|Track Changes|review.trackChanges +Review|Tracking|Display for Review|review.markup +Review|Tracking|Show Markup|review.showMarkup +Review|Tracking|Reviewing Pane|review.changes +Review|Changes|Accept|review.accept +Review|Changes|Reject|review.reject +Review|Changes|Accept All|review.acceptAll +Review|Changes|Reject All|review.rejectAll +Review|Changes|Previous Change|review.previousChange +Review|Changes|Next Change|review.nextChange +Review|Compare|Compare|review.compare +Review|Compare|Combine|review.combine +Review|Protect|Block Authors|review.blockAuthors +Review|Protect|Restrict Editing|review.restrict +Review|Ink|Hide Ink|review.hideInk +View|Views|Read Mode|view.readMode +View|Views|Print Layout|view.printLayout +View|Views|Web Layout|view.webLayout +View|Views|Outline|view.outline +View|Views|Draft|view.draft +View|Immersive|Focus|view.focus +View|Immersive|Immersive Reader|view.immersive +View|Page Movement|Vertical|view.vertical +View|Page Movement|Side to Side|view.sideToSide +View|Show|Ruler|view.ruler +View|Show|Gridlines|view.gridlines +View|Show|Navigation Pane|view.navigationPane +View|Zoom|Zoom|view.zoom +View|Zoom|100%|view.zoom100 +View|Zoom|One Page|view.onePage +View|Zoom|Multiple Pages|view.multiplePages +View|Zoom|Page Width|view.pageWidth +View|Zoom|Zoom In|view.zoomIn +View|Zoom|Zoom Out|view.zoomOut +View|Dark Mode|Switch Modes|view.darkMode +View|Window|New Window|view.newWindow +View|Window|Arrange All|view.arrangeAll +View|Window|Split|view.split +View|Window|View Side by Side|view.sideBySide +View|Window|Synchronous Scrolling|view.syncScroll +View|Window|Switch Windows|view.switchWindows +View|Macros|Macros|tools.macros +View|Macros|Record Macro|tools.recordMacro +View|SharePoint|Properties|file.properties +Table Design|Table Style Options|Table Style Options|table.look +Table Design|Table Styles|Table Styles|table.style +Table Design|Table Styles|Shading|table.shading +Table Design|Borders|Borders|table.borders +Table Design|Borders|Border Painter|table.borderPainter +Table Layout|Table|Select Table|table.selectTable +Table Layout|Table|Select Row|table.selectRow +Table Layout|Table|Select Cell|table.selectCell +Table Layout|Table|View Gridlines|view.gridlines +Table Layout|Table|Properties|table.properties +Table Layout|Draw|Draw Table|table.draw +Table Layout|Draw|Eraser|table.eraser +Table Layout|Rows & Columns|Delete Cells|table.deleteCells +Table Layout|Rows & Columns|Delete Rows|table.deleteRow +Table Layout|Rows & Columns|Delete Columns|table.deleteColumn +Table Layout|Rows & Columns|Delete Table|table.deleteTable +Table Layout|Rows & Columns|Insert Above|table.insertRowAbove +Table Layout|Rows & Columns|Insert Below|table.insertRowBelow +Table Layout|Rows & Columns|Insert Left|table.insertColumnLeft +Table Layout|Rows & Columns|Insert Right|table.insertColumnRight +Table Layout|Merge|Merge Cells|table.merge +Table Layout|Merge|Split Cells|table.split +Table Layout|Merge|Split Table|table.splitTable +Table Layout|Cell Size|AutoFit|table.autofit +Table Layout|Cell Size|Row Height|table.rowHeight +Table Layout|Cell Size|Column Width|table.columnWidth +Table Layout|Cell Size|Distribute Rows|table.distributeRows +Table Layout|Cell Size|Distribute Columns|table.distributeColumns +Table Layout|Alignment|Alignment|table.cellAlign +Table Layout|Alignment|Text Direction|table.textDirection +Table Layout|Alignment|Cell Margins|table.cellMargins +Table Layout|Data|Sort|table.sort +Table Layout|Data|Repeat Header Rows|table.repeatHeader +Table Layout|Data|Convert to Text|table.toText +Table Layout|Data|Formula|table.formula +Picture Format|Adjust|Remove Background|picture.removeBackground +Picture Format|Adjust|Corrections|picture.corrections +Picture Format|Adjust|Color|picture.color +Picture Format|Adjust|Artistic Effects|picture.effects +Picture Format|Adjust|Transparency|picture.transparency +Picture Format|Adjust|Compress Pictures|picture.compress +Picture Format|Adjust|Change Picture|picture.change +Picture Format|Adjust|Reset Picture|picture.reset +Picture Format|Picture Styles|Picture Styles|picture.style +Picture Format|Picture Styles|Picture Border|picture.border +Picture Format|Accessibility|Alt Text|picture.altText +Picture Format|Size|Crop|picture.crop +Picture Format|Size|Size|picture.size +Shape Format|Shape Styles|Shape Fill|shape.fill +Shape Format|Shape Styles|Shape Outline|shape.outline +Shape Format|Shape Styles|Shape Effects|shape.effects +Shape Format|Text|Text Direction|shape.textDirection +Shape Format|Text|Align Text|shape.alignText +Shape Format|Text|Create Link|shape.link +Header & Footer|Navigation|Go to Header|insert.editHeader +Header & Footer|Navigation|Go to Footer|insert.editFooter +Header & Footer|Navigation|Previous Section|hf.previous +Header & Footer|Navigation|Next Section|hf.next +Header & Footer|Position|Header from Top|hf.position +Editing|Keyboard|Type Text|text.insert +Editing|Keyboard|New Paragraph|text.newParagraph +Editing|Keyboard|Line Break|text.lineBreak +Editing|Keyboard|Column Break|text.columnBreak +Editing|Keyboard|Tab|text.tab +Editing|Keyboard|Backspace|text.backspace +Editing|Keyboard|Delete|text.delete +Editing|Keyboard|Delete Previous Word|text.deleteWordBack +Editing|Keyboard|Delete Next Word|text.deleteWordForward +Editing|Navigation|Word Left|caret.wordLeft +Editing|Navigation|Word Right|caret.wordRight +Editing|Navigation|Line Start|caret.home +Editing|Navigation|Line End|caret.end +Editing|Navigation|Paragraph Up|caret.paraUp +Editing|Navigation|Paragraph Down|caret.paraDown +Editing|Navigation|Document Start|caret.docStart +Editing|Navigation|Document End|caret.docEnd +Editing|Navigation|Page Up|caret.pageUp +Editing|Navigation|Page Down|caret.pageDown +Editing|Selection|Select Word|select.word +Editing|Selection|Select Sentence|select.sentence +Editing|Selection|Select Paragraph|select.paragraph +Editing|Selection|Extend Selection (F8)|select.extend +Editing|Selection|Column Selection|select.column +Tools|Proofing|AutoCorrect Options|tools.autocorrect +Tools|Proofing|Set Proofing Language|review.language +Tools|Customize|Customize Ribbon|tools.customizeRibbon +Tools|Customize|Customize Keyboard|tools.customizeKeyboard +Tools|Protection|Encrypt with Password|file.encrypt +Tools|Templates|Templates and Add-ins|tools.templates +"; + +/// Catalog entries: (tab, group, feature, id). +pub fn entries() -> Vec<(&'static str, &'static str, &'static str, &'static str)> { + CATALOG + .lines() + .filter_map(|l| { + let mut it = l.split('|'); + Some((it.next()?, it.next()?, it.next()?, it.next()?)) + }) + .collect() +} + +/// Parity summary: per tab and overall, with the missing features. +pub fn parity(reg: &Registry) -> Value { + let e = entries(); + let mut tabs: Vec<(&str, usize, usize, Vec)> = Vec::new(); + for (tab, group, label, id) in &e { + let have = reg.get(id).is_some(); + let t = match tabs.iter_mut().find(|t| t.0 == *tab) { + Some(t) => t, + None => { + tabs.push((tab, 0, 0, Vec::new())); + let n = tabs.len() - 1; + match tabs.get_mut(n) { + Some(t) => t, + None => continue, + } + } + }; + t.2 += 1; + if have { + t.1 += 1; + } else { + t.3.push(format!("{group} › {label} (`{id}`)")); + } + } + let live = tabs.iter().map(|t| t.1).sum::(); + let total = e.len(); + json!({ + "live": live, + "total": total, + "percent": if total == 0 { 0.0 } else { (live as f64 * 1000.0 / total as f64).round() / 10.0 }, + "tabs": tabs.iter().map(|t| json!({"tab": t.0, "live": t.1, "total": t.2, "missing": t.3})).collect::>(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_parses_and_ids_unique_per_feature() { + let e = entries(); + assert!(e.len() > 300); + assert!(e.iter().all(|x| x.3.contains('.'))); + } + + #[test] + fn parity_floor() { + let reg = crate::cmd::registry(); + let p = parity(®); + let pct = p["percent"].as_f64().unwrap(); + // The floor only ever rises. + assert!(pct >= 60.0, "parity {pct}%"); + } +} diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs new file mode 100644 index 000000000..64a24ba34 --- /dev/null +++ b/crates/engine/src/cmd/caret.rs @@ -0,0 +1,284 @@ +//! Caret movement and selection. + +use serde_json::Value; +use wordcraft_doc::{Pos, StoryRef}; + +use super::{parse_pos, sel_result}; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +const EXTEND: &str = r#"{"extend"?: bool}"#; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("caret.left", "Left", "Navigation", |s, v| lr(s, v, true)).key("Left").params(EXTEND).pure(), + CommandSpec::new("caret.right", "Right", "Navigation", |s, v| lr(s, v, false)).key("Right").params(EXTEND).pure(), + CommandSpec::new("caret.up", "Up", "Navigation", |s, v| vert(s, v, -1)).key("Up").params(EXTEND).pure(), + CommandSpec::new("caret.down", "Down", "Navigation", |s, v| vert(s, v, 1)).key("Down").params(EXTEND).pure(), + CommandSpec::new("caret.wordLeft", "Word Left", "Navigation", |s, v| mv(s, v, word_left)).key("Mod+Left / Alt+Left").params(EXTEND).pure(), + CommandSpec::new("caret.wordRight", "Word Right", "Navigation", |s, v| mv(s, v, word_right)).key("Mod+Right / Alt+Right").params(EXTEND).pure(), + CommandSpec::new("caret.home", "Line Start", "Navigation", |s, v| line(s, v, true)).key("Home").params(EXTEND).pure(), + CommandSpec::new("caret.end", "Line End", "Navigation", |s, v| line(s, v, false)).key("End").params(EXTEND).pure(), + CommandSpec::new("caret.paraUp", "Paragraph Up", "Navigation", |s, v| mv(s, v, para_up)).key("Mod+Up / Alt+Up").params(EXTEND).pure(), + CommandSpec::new("caret.paraDown", "Paragraph Down", "Navigation", |s, v| mv(s, v, para_down)).key("Mod+Down / Alt+Down").params(EXTEND).pure(), + CommandSpec::new("caret.docStart", "Document Start", "Navigation", |s, v| mv(s, v, |s, _| s.doc.start_of(s.sel.focus.story))).key("Mod+Home").params(EXTEND).pure(), + CommandSpec::new("caret.docEnd", "Document End", "Navigation", |s, v| mv(s, v, |s, _| s.doc.end_of(s.sel.focus.story))).key("Mod+End").params(EXTEND).pure(), + CommandSpec::new("caret.pageUp", "Page Up", "Navigation", |s, v| page(s, v, -1)).key("PageUp").params(EXTEND).pure(), + CommandSpec::new("caret.pageDown", "Page Down", "Navigation", |s, v| page(s, v, 1)).key("PageDown").params(EXTEND).pure(), + CommandSpec::new("caret.set", "Set Caret", "Navigation", set).params(r#"{"pos": Pos, "extend"?: bool} | {"page": n, "x": pt, "y": pt}"#).pure(), + CommandSpec::new("select.all", "Select All", "Home › Editing › Select", select_all).key("Mod+A").pure(), + CommandSpec::new("select.range", "Select Range", "Navigation", select_range).params(r#"{"anchor": Pos, "focus": Pos}"#).pure(), + CommandSpec::new("select.word", "Select Word", "Navigation", select_word).pure(), + CommandSpec::new("select.sentence", "Select Sentence", "Navigation", select_sentence).pure(), + CommandSpec::new("select.paragraph", "Select Paragraph", "Navigation", select_paragraph).pure(), + CommandSpec::new("select.line", "Select Line", "Navigation", select_line).pure(), + CommandSpec::new("select.text", "Select Text", "Navigation", select_text).params(r#"{"text": string, "occurrence"?: n}"#).pure(), + CommandSpec::new("select.collapse", "Collapse Selection", "Navigation", |s, v| { + let to_end = p::bool(v, "end").unwrap_or(false); + let (a, b) = s.sel.ordered(); + s.sel = Selection::caret(if to_end { b } else { a }); + sel_result(s) + }) + .key("Escape") + .pure(), + ] +} + +fn extend(v: &Value) -> bool { + p::bool(v, "extend").unwrap_or(false) +} + +fn apply(s: &mut Session, to: Pos, ext: bool) { + if ext { + s.sel.focus = to; + } else { + s.sel = Selection::caret(to); + } + s.pending = None; +} + +fn mv(s: &mut Session, v: &Value, f: fn(&mut Session, &Pos) -> Pos) -> CmdResult { + let ext = extend(v); + s.goal_x = None; + let from = s.sel.focus.clone(); + let to = f(s, &from); + apply(s, to, ext); + sel_result(s) +} + +/// Left/Right: with a selection and no Shift, collapse to its edge. +fn lr(s: &mut Session, v: &Value, is_left: bool) -> CmdResult { + if !extend(v) && !s.sel.is_collapsed() { + let (a, b) = s.sel.ordered(); + s.sel = Selection::caret(if is_left { a } else { b }); + s.goal_x = None; + return sel_result(s); + } + mv(s, v, if is_left { left } else { right }) +} + +fn left(s: &mut Session, p: &Pos) -> Pos { + if let Some(para) = s.doc.para_at(p) + && p.off > 0 + { + return Pos { off: para.prev_boundary(p.off), ..p.clone() }; + } + match s.doc.prev_para(p.story, &p.path) { + Some(q) => { + let off = s.doc.para(p.story, &q).map(|x| x.len()).unwrap_or(0); + Pos { story: p.story, path: q, off } + } + None => p.clone(), + } +} + +fn right(s: &mut Session, p: &Pos) -> Pos { + if let Some(para) = s.doc.para_at(p) + && p.off < para.len() + { + return Pos { off: para.next_boundary(p.off), ..p.clone() }; + } + match s.doc.next_para(p.story, &p.path) { + Some(q) => Pos { story: p.story, path: q, off: 0 }, + None => p.clone(), + } +} + +fn word_left(s: &mut Session, p: &Pos) -> Pos { + match s.doc.para_at(p) { + Some(para) if p.off > 0 => Pos { off: para.word_start(p.off), ..p.clone() }, + _ => left(s, p), + } +} + +fn word_right(s: &mut Session, p: &Pos) -> Pos { + match s.doc.para_at(p) { + Some(para) if p.off < para.len() => Pos { off: para.word_end(p.off), ..p.clone() }, + _ => right(s, p), + } +} + +fn para_up(s: &mut Session, p: &Pos) -> Pos { + if p.off > 0 { + return Pos { off: 0, ..p.clone() }; + } + match s.doc.prev_para(p.story, &p.path) { + Some(q) => Pos { story: p.story, path: q, off: 0 }, + None => p.clone(), + } +} + +fn para_down(s: &mut Session, p: &Pos) -> Pos { + match s.doc.next_para(p.story, &p.path) { + Some(q) => Pos { story: p.story, path: q, off: 0 }, + None => { + let off = s.doc.para_at(p).map(|x| x.len()).unwrap_or(0); + Pos { off, ..p.clone() } + } + } +} + +fn vert(s: &mut Session, v: &Value, dir: i32) -> CmdResult { + let ext = extend(v); + let l = s.layout(); + let from = s.sel.focus.clone(); + let to = match l.vertical(&from, s.goal_x, dir, s.page_hint) { + Some((p, gx)) => { + s.goal_x = Some(gx); + p + } + None => { + // Top/bottom of the story: go to its start/end. + if dir < 0 { s.doc.start_of(from.story) } else { s.doc.end_of(from.story) } + } + }; + if let Some(c) = l.caret_on(&to, s.page_hint) { + s.page_hint = c.page; + } + let gx = s.goal_x; + apply(s, to, ext); + s.goal_x = gx; + sel_result(s) +} + +fn line(s: &mut Session, v: &Value, home: bool) -> CmdResult { + let ext = extend(v); + let l = s.layout(); + let from = s.sel.focus.clone(); + let to = match l.line_bounds(&from, s.page_hint) { + Some((a, b)) => { + if home { + a + } else { + b + } + } + None => from, + }; + s.goal_x = None; + apply(s, to, ext); + sel_result(s) +} + +fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult { + let ext = extend(v); + let l = s.layout(); + let from = s.sel.focus.clone(); + let Some(c) = l.caret_on(&from, s.page_hint) else { return sel_result(s) }; + let np = if dir < 0 { c.page.saturating_sub(1) } else { (c.page + 1).min(l.pages.len().saturating_sub(1)) }; + let to = l.hit(np, c.x, c.top + 1.0, from.story).unwrap_or(from); + s.page_hint = np; + apply(s, to, ext); + sel_result(s) +} + +fn set(s: &mut Session, v: &Value) -> CmdResult { + let ext = extend(v); + let to = if let Some(pv) = v.get("pos") { + parse_pos(pv).ok_or_else(|| CmdError::Params("bad `pos`".into()))? + } else if let (Some(pg), Some(x), Some(y)) = (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) { + let story = super::story_param(s, v); + let l = s.layout(); + s.page_hint = pg as usize; + l.hit(pg as usize, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))? + } else { + return Err(CmdError::Params("`pos` or `page`/`x`/`y` required".into())); + }; + let to = s.doc.clamp(&to); + s.goal_x = None; + apply(s, to, ext); + sel_result(s) +} + +fn select_all(s: &mut Session, _: &Value) -> CmdResult { + let st = s.sel.focus.story; + s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) }; + sel_result(s) +} + +fn select_range(s: &mut Session, v: &Value) -> CmdResult { + let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?; + let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?; + s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) }; + sel_result(s) +} + +fn select_word(s: &mut Session, _: &Value) -> CmdResult { + let f = s.sel.focus.clone(); + if let Some(p) = s.doc.para_at(&f) { + let (a, b) = p.word_at(f.off); + s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } }; + } + sel_result(s) +} + +fn select_sentence(s: &mut Session, _: &Value) -> CmdResult { + let f = s.sel.focus.clone(); + if let Some(p) = s.doc.para_at(&f) { + let (a, b) = p.sentence_at(f.off); + s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } }; + } + sel_result(s) +} + +fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult { + let f = s.sel.focus.clone(); + let len = s.doc.para_at(&f).map(|p| p.len()).unwrap_or(0); + // Word includes the paragraph mark: extend to the start of the next paragraph when there is one. + let end = match s.doc.next_para(f.story, &f.path) { + Some(q) if q.parent() == f.path.parent() => Pos { story: f.story, path: q, off: 0 }, + _ => Pos { off: len, ..f.clone() }, + }; + s.sel = Selection { anchor: Pos { off: 0, ..f }, focus: end }; + sel_result(s) +} + +fn select_line(s: &mut Session, _: &Value) -> CmdResult { + let l = s.layout(); + if let Some((a, b)) = l.line_bounds(&s.sel.focus, s.page_hint) { + s.sel = Selection { anchor: a, focus: b }; + } + sel_result(s) +} + +/// Select the n-th occurrence of `text` in the body (agents: "select the word X"). +fn select_text(s: &mut Session, v: &Value) -> CmdResult { + let needle = p::req_str(v, "text")?; + let n = p::u64(v, "occurrence").unwrap_or(1).max(1) as usize; + if needle.is_empty() { + return Err(CmdError::Params("`text` is empty".into())); + } + let mut count = 0; + let story = super::story_param(s, v); + for path in s.doc.para_paths(story) { + let Some(p) = s.doc.para(story, &path) else { continue }; + for (i, _) in p.text.match_indices(needle) { + count += 1; + if count == n { + s.sel = Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } }; + return sel_result(s); + } + } + } + let _ = StoryRef::Body; + Err(CmdError::Failed(format!("`{needle}` not found"))) +} diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs new file mode 100644 index 000000000..0c01b1f8e --- /dev/null +++ b/crates/engine/src/cmd/design.rs @@ -0,0 +1,183 @@ +//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders. + +use serde_json::{Value, json}; +use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor}; +use wordcraft_doc::{Watermark, styles}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// Our own themes: (name, heading font, body font, accent colours). +pub const THEMES: &[(&str, &str, &str, [Rgb; 6])] = &[ + ("Craft", styles::HEADING_FONT, styles::BODY_FONT, [Rgb(0x15, 0x60, 0x82), Rgb(0xE9, 0x71, 0x32), Rgb(0x19, 0x6B, 0x24), Rgb(0x0F, 0x9E, 0xD5), Rgb(0xA0, 0x2B, 0x93), Rgb(0x4E, 0xA7, 0x2E)]), + ("Studio", "Georgia", "Georgia", [Rgb(0x8C, 0x2F, 0x39), Rgb(0xC9, 0x8B, 0x2E), Rgb(0x3E, 0x5C, 0x76), Rgb(0x6B, 0x8F, 0x71), Rgb(0x9A, 0x6F, 0xB0), Rgb(0x4A, 0x4A, 0x4A)]), + ("Gallery", "Helvetica Neue", "Helvetica Neue", [Rgb(0x22, 0x22, 0x22), Rgb(0xE6, 0x39, 0x46), Rgb(0x45, 0x7B, 0x9D), Rgb(0x1D, 0x35, 0x57), Rgb(0xA8, 0xDA, 0xDC), Rgb(0xF4, 0xA2, 0x61)]), + ("Atelier", "Palatino", "Palatino", [Rgb(0x6D, 0x59, 0x7A), Rgb(0xB5, 0x65, 0x76), Rgb(0xE5, 0x6B, 0x6F), Rgb(0xEA, 0xAC, 0x8B), Rgb(0x35, 0x50, 0x70), Rgb(0x8E, 0x9A, 0xAF)]), + ("Darkroom", "Futura", "Avenir Next", [Rgb(0x26, 0x46, 0x53), Rgb(0x2A, 0x9D, 0x8F), Rgb(0xE9, 0xC4, 0x6A), Rgb(0xF4, 0xA2, 0x61), Rgb(0xE7, 0x6F, 0x51), Rgb(0x5E, 0x54, 0x8E)]), + ("Sketchbook", "Gill Sans", "Gill Sans", [Rgb(0x58, 0x81, 0x57), Rgb(0x3A, 0x5A, 0x40), Rgb(0xA3, 0xB1, 0x8A), Rgb(0xDA, 0xD7, 0xCD), Rgb(0x34, 0x4E, 0x41), Rgb(0xBC, 0x6C, 0x25)]), +]; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("design.theme", "Themes", "Design › Document Formatting", theme).params(r#"{"name": string}"#), + CommandSpec::new("design.themeFonts", "Fonts", "Design › Document Formatting", |s, v| { + let heading = p::req_str(v, "heading")?.to_string(); + let body = p::str(v, "body").unwrap_or(&heading).to_string(); + set_fonts(s, &heading, &body); + sel_result(s) + }) + .params(r#"{"heading": string, "body"?: string}"#), + CommandSpec::new("design.themeColors", "Colors", "Design › Document Formatting", |s, v| { + let name = p::req_str(v, "name")?; + let t = THEMES.iter().find(|t| t.0.eq_ignore_ascii_case(name)).ok_or_else(|| CmdError::Params(format!("unknown colours `{name}`")))?; + set_accent(s, t.3[0]); + sel_result(s) + }), + CommandSpec::new("design.styleSet", "Style Set", "Design › Document Formatting", style_set).params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#), + CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing) + .params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#), + CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark).params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#), + CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| { + s.doc.settings.page_color = p::str(v, "color").and_then(Rgb::parse); + sel_result(s) + }) + .params(r#"{"color": "RRGGBB" | null}"#), + CommandSpec::new("design.pageBorders", "Page Borders", "Design › Page Background", |s, v| { + let kind = p::str(v, "kind").unwrap_or("box"); + let w = p::f32(v, "width").unwrap_or(1.0).clamp(0.25, 6.0); + let color = p::str(v, "color").and_then(Rgb::parse); + let b = Border { style: BorderStyle::Single, width: w, color, space: 24.0 }; + let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; + s.doc.section_mut(block).page_borders = if kind == "none" { None } else { Some(Borders::box_(b)) }; + sel_result(s) + }) + .params(r#"{"kind": "box|none", "width"?: pt, "color"?: "RRGGBB"}"#), + CommandSpec::new("design.setDefault", "Set as Default", "Design › Document Formatting", |s, _| { + s.status = "These settings will be used for new documents.".into(); + sel_result(s) + }) + .pure(), + CommandSpec::new("design.themes", "Theme List", "Design › Document Formatting", |_, _| Ok(json!(THEMES.iter().map(|t| t.0).collect::>()))).pure(), + ] +} + +fn set_fonts(s: &mut Session, heading: &str, body: &str) { + s.doc.settings.major_font = heading.into(); + s.doc.settings.minor_font = body.into(); + s.doc.styles.default_chr.font = Some(body.into()); + for st in &mut s.doc.styles.styles { + if let Some(f) = &st.chr.font + && (f == styles::HEADING_FONT || st.id.starts_with("Heading") || st.id == "Title") + { + st.chr.font = Some(heading.into()); + } else if st.chr.font.is_some() && st.chr.font.as_deref() != Some(heading) { + st.chr.font = Some(body.into()); + } + } +} + +fn set_accent(s: &mut Session, accent: Rgb) { + for st in &mut s.doc.styles.styles { + if st.id.starts_with("Heading") && st.id.len() == 8 && st.id.as_bytes().get(7).is_some_and(|d| *d <= b'5') || st.id == "IntenseQuote" || st.id == "IntenseEmphasis" || st.id == "IntenseReference" { + st.chr.color = Some(TextColor::Rgb(accent)); + if let Some(b) = st.para.borders.as_mut() { + for e in [&mut b.top, &mut b.bottom].into_iter().flatten() { + e.color = Some(accent); + } + } + } + } + if let Some(c) = s.doc.settings.theme_colors.get_mut(4) { + *c = accent; + } +} + +fn theme(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?; + let t = THEMES.iter().find(|t| t.0.eq_ignore_ascii_case(name)).ok_or_else(|| CmdError::Params(format!("unknown theme `{name}`")))?; + set_fonts(s, t.1, t.2); + set_accent(s, t.3[0]); + s.doc.settings.theme_name = t.0.into(); + let mut colors = s.doc.settings.theme_colors.clone(); + for (i, c) in t.3.iter().enumerate() { + if let Some(x) = colors.get_mut(4 + i) { + *x = *c; + } + } + s.doc.settings.theme_colors = colors; + sel_result(s) +} + +fn style_set(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?; + let accent = s.doc.settings.theme_colors.get(4).copied().unwrap_or(styles::HEADING_BLUE); + let mut fresh = wordcraft_doc::StyleSheet::builtin(); + let line = Border { style: BorderStyle::Single, width: 0.75, color: Some(accent), space: 1.0 }; + for st in &mut fresh.styles { + let heading = st.id.starts_with("Heading") || st.id == "Title"; + match name { + "basic" if heading => { + st.chr.color = Some(TextColor::Rgb(Rgb(0x26, 0x26, 0x26))); + st.chr.bold = Some(true); + } + "lines" if st.id == "Heading1" || st.id == "Title" => st.para.borders = Some(Borders { bottom: Some(line), ..Default::default() }), + "shaded" if st.id == "Heading1" => { + st.para.shading = Some(accent); + st.chr.color = Some(TextColor::Rgb(Rgb::WHITE)); + } + "casual" if heading => st.chr.italic = Some(true), + "centered" if heading => st.para.align = Some(wordcraft_doc::Align::Center), + "minimalist" if heading => { + st.chr.caps = Some(true); + st.chr.spacing = Some(1.0); + st.chr.color = Some(TextColor::Rgb(Rgb(0x40, 0x40, 0x40))); + } + "title" if st.id == "Title" => st.chr.size = Some(36.0), + _ => {} + } + } + // Keep custom styles and fonts. + let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect(); + fresh.styles.extend(customs); + fresh.default_chr.font = s.doc.styles.default_chr.font.clone(); + s.doc.styles = fresh; + let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone()); + set_fonts(s, &h, &b); + set_accent(s, accent); + sel_result(s) +} + +fn para_spacing(s: &mut Session, v: &Value) -> CmdResult { + let (before, after, line) = match p::req_str(v, "value")? { + "none" => (0.0, 0.0, 1.0), + "compact" => (0.0, 4.0, 1.0), + "tight" => (0.0, 6.0, 1.15), + "open" => (0.0, 10.0, 1.15), + "relaxed" => (0.0, 6.0, 1.5), + "double" => (0.0, 8.0, 2.0), + _ => (0.0, 8.0, 1.15), + }; + s.doc.styles.default_para.space_before = Some(before); + s.doc.styles.default_para.space_after = Some(after); + s.doc.styles.default_para.line_spacing = Some(LineSpacing::Multiple(line)); + sel_result(s) +} + +fn watermark(s: &mut Session, v: &Value) -> CmdResult { + if p::bool(v, "remove").unwrap_or(false) { + s.doc.settings.watermark = None; + return sel_result(s); + } + let mut wm = s.doc.settings.watermark.clone().unwrap_or_default(); + if let Some(t) = p::str(v, "text") { + wm.text = t.chars().take(200).collect(); + } + if let Some(d) = p::bool(v, "diagonal") { + wm.diagonal = d; + } + if let Some(c) = p::str(v, "color").and_then(Rgb::parse) { + wm.color = c; + } + s.doc.settings.watermark = Some(Watermark { ..wm }); + sel_result(s) +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs new file mode 100644 index 000000000..e4434e7c6 --- /dev/null +++ b/crates/engine/src/cmd/edit.rs @@ -0,0 +1,299 @@ +//! Undo, clipboard, find & replace, Go To, Format Painter. + +use serde_json::{Value, json}; +use wordcraft_doc::edit::Fragment; +use wordcraft_doc::{Block, Pos, StoryRef}; + +use super::{delete_selection, pos_json, sel_result}; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +fn has_sel(s: &Session) -> Option<&'static str> { + if s.sel.is_collapsed() { Some("nothing is selected") } else { None } +} + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| { + s.undo(); + sel_result(s) + }) + .key("Mod+Z") + .pure(), + CommandSpec::new("edit.redo", "Redo", "Quick Access Toolbar", |s, _| { + s.redo(); + sel_result(s) + }) + .key("Mod+Y / Mod+Shift+Z") + .pure(), + CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel), + CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C").pure(), + CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string}"#), + CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#).key("Mod+Shift+Alt+V"), + CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#), + CommandSpec::new("edit.find", "Find", "Home › Editing", find).key("Mod+F").params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#).pure(), + CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3").pure(), + CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1)).key("Mod+Shift+G / Shift+F3").pure(), + CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#), + CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all).params(r#"{"text": string, "with": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#), + CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto).key("Mod+Alt+G / F5").params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#).pure(), + CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(), + CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| { + painter_pick(s); + sel_result(s) + }) + .key("Mod+Shift+C") + .pure(), + CommandSpec::new("edit.pasteFormat", "Paste Formatting", "Home › Clipboard", paste_format).key("Mod+Shift+V"), + ] +} + +fn copy(s: &mut Session, _: &Value) -> CmdResult { + let (a, b) = s.sel.ordered(); + if a == b { + return Ok(json!({"text": ""})); + } + let f = s.doc.copy_range(&a, &b); + s.clipboard_text = f.plain_text(); + s.clipboard = Some(f); + Ok(json!({"text": s.clipboard_text})) +} + +fn cut(s: &mut Session, v: &Value) -> CmdResult { + let r = copy(s, v)?; + delete_selection(s)?; + Ok(r) +} + +fn paste(s: &mut Session, v: &Value) -> CmdResult { + // An explicit text param (from the system clipboard) wins unless it matches our own copy. + let frag = match (p::str(v, "text"), &s.clipboard) { + (Some(t), Some(f)) if f.plain_text() == t.replace("\r\n", "\n") => f.clone(), + (Some(t), _) => Fragment::from_text(&t.replace("\r\n", "\n")), + (None, Some(f)) => f.clone(), + (None, None) => return Err(CmdError::Failed("the clipboard is empty".into())), + }; + if let Some(images) = v.get("image").and_then(Value::as_str) { + let _ = images; + } + let at = delete_selection(s)?; + let mut frag = frag; + if s.doc.settings.track_changes { + let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert); + for b in &mut frag.blocks { + if let Block::Para(p) = b { + for r in &mut p.runs { + r.props.ins = Some(rid); + } + } + } + } + let end = s.doc.insert_fragment(&at, &frag)?; + s.sel = Selection::caret(end); + sel_result(s) +} + +fn paste_text(s: &mut Session, v: &Value) -> CmdResult { + let text = match p::str(v, "text") { + Some(t) => t.to_string(), + None => s.clipboard.as_ref().map(Fragment::plain_text).unwrap_or_default(), + }; + super::type_text(s, &text.replace("\r\n", "\r").replace('\n', "\r"))?; + sel_result(s) +} + +/// All matches of the find state in the current story. +fn search(s: &Session, story: StoryRef) -> Result, CmdError> { + let f = &s.find; + if f.query.is_empty() { + return Ok(Vec::new()); + } + let pat = if f.regex { f.query.clone() } else { regex::escape(&f.query) }; + let pat = if f.whole_word { format!(r"\b{pat}\b") } else { pat }; + let re = regex::RegexBuilder::new(&pat).case_insensitive(!f.match_case).size_limit(1 << 20).build().map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?; + let mut out = Vec::new(); + for path in s.doc.para_paths(story) { + let Some(p) = s.doc.para(story, &path) else { continue }; + for m in re.find_iter(&p.text) { + if m.start() == m.end() { + continue; + } + out.push((Pos { story, path: path.clone(), off: m.start() }, Pos { story, path: path.clone(), off: m.end() })); + if out.len() >= 100_000 { + return Ok(out); + } + } + } + Ok(out) +} + +fn read_opts(s: &mut Session, v: &Value) { + if let Some(t) = p::str(v, "text") { + s.find.query = t.to_string(); + } + if let Some(b) = p::bool(v, "matchCase") { + s.find.match_case = b; + } + if let Some(b) = p::bool(v, "wholeWord") { + s.find.whole_word = b; + } + if let Some(b) = p::bool(v, "regex") { + s.find.regex = b; + } + if let Some(t) = p::str(v, "with") { + s.find.replace = t.to_string(); + } +} + +fn find(s: &mut Session, v: &Value) -> CmdResult { + read_opts(s, v); + if p::str(v, "text").is_none() { + s.view.nav_pane = true; + s.ui_requests.push(json!({"open": "find"})); + return Ok(json!({"count": s.find.results.len()})); + } + let story = s.sel.focus.story; + s.find.results = search(s, story)?; + // Select the first match after the caret. + let caret = s.sel.focus.clone(); + let idx = s.find.results.iter().position(|(a, _)| *a >= caret).unwrap_or(0); + s.find.current = idx; + if let Some((a, b)) = s.find.results.get(idx).cloned() { + s.sel = Selection { anchor: a, focus: b }; + } + Ok(json!({ + "count": s.find.results.len(), + "matches": s.find.results.iter().take(200).map(|(a, b)| json!({"start": pos_json(a), "end": pos_json(b)})).collect::>(), + })) +} + +fn step(s: &mut Session, dir: i64) -> CmdResult { + let story = s.sel.focus.story; + s.find.results = search(s, story)?; + let n = s.find.results.len(); + if n == 0 { + return Err(CmdError::Failed("no matches".into())); + } + let caret = if dir > 0 { s.sel.ordered().1 } else { s.sel.ordered().0 }; + let idx = if dir > 0 { + s.find.results.iter().position(|(a, _)| *a >= caret).unwrap_or(0) + } else { + s.find.results.iter().rposition(|(_, b)| *b <= caret).unwrap_or(n - 1) + }; + s.find.current = idx; + if let Some((a, b)) = s.find.results.get(idx).cloned() { + s.sel = Selection { anchor: a, focus: b }; + } + Ok(json!({"index": idx, "count": n})) +} + +fn replace(s: &mut Session, v: &Value) -> CmdResult { + read_opts(s, v); + let story = s.sel.focus.story; + let results = search(s, story)?; + let (a, b) = s.sel.ordered(); + // Replace the current match if selected, then move to the next. + if results.iter().any(|(x, y)| *x == a && *y == b) { + let with = s.find.replace.clone(); + super::type_text(s, &with)?; + } + let _ = step(s, 1); + Ok(json!({"remaining": search(s, story)?.len()})) +} + +fn replace_all(s: &mut Session, v: &Value) -> CmdResult { + read_opts(s, v); + let story = s.sel.focus.story; + let results = search(s, story)?; + let with = s.find.replace.clone(); + let n = results.len(); + // Replace from the end so earlier positions stay valid. + for (a, b) in results.into_iter().rev() { + let props = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone()).unwrap_or_default(); + s.doc.delete_range(&a, &b)?; + s.doc.insert_text(&a, &with, &props)?; + } + s.status = format!("All done. We made {n} replacements."); + Ok(json!({"replaced": n})) +} + +fn goto(s: &mut Session, v: &Value) -> CmdResult { + if let Some(pg) = p::u64(v, "page") { + let l = s.layout(); + let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1)); + let target = l.pages.get(idx).and_then(|page| { + page.items.iter().find_map(|it| match it { + wordcraft_layout::Placed::Lines { story, path, para, l0, .. } if *story == StoryRef::Body => { + para.lines.get(*l0).map(|ln| Pos { story: *story, path: path.clone(), off: ln.start }) + } + _ => None, + }) + }); + if let Some(t) = target { + s.sel = Selection::caret(t); + s.page_hint = idx; + } + s.ui_requests.push(json!({"scrollTo": "caret"})); + return sel_result(s); + } + if let Some(name) = p::str(v, "bookmark") { + let bm = s.doc.bookmarks().into_iter().find(|(n, _)| n == name).ok_or_else(|| CmdError::Failed(format!("no bookmark `{name}`")))?; + s.sel = Selection::caret(bm.1); + return sel_result(s); + } + if let Some(n) = p::u64(v, "paragraph") { + let paths = s.doc.para_paths(StoryRef::Body); + if let Some(path) = paths.get((n.max(1) - 1) as usize) { + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: path.clone(), off: 0 }); + } + return sel_result(s); + } + s.ui_requests.push(json!({"open": "goto"})); + sel_result(s) +} + +fn painter_pick(s: &mut Session) { + let f = s.sel.ordered().0; + let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone()).unwrap_or_default(); + let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default(); + s.painter = Some((chr, para, false)); +} + +fn painter(s: &mut Session, v: &Value) -> CmdResult { + if s.painter.is_some() { + s.painter = None; + return Ok(json!({"active": false})); + } + painter_pick(s); + if let Some((_, _, sticky)) = s.painter.as_mut() { + *sticky = p::bool(v, "sticky").unwrap_or(false); + } + Ok(json!({"active": true})) +} + +/// Apply the format painter's formatting to the selection. +fn paste_format(s: &mut Session, _: &Value) -> CmdResult { + let Some((chr, para, sticky)) = s.painter.clone() else { return Err(CmdError::Failed("no formatting copied".into())) }; + let (a, b) = s.sel.ordered(); + if a == b { + // No selection: apply paragraph formatting (and word formatting like Word). + let f = s.sel.focus.clone(); + if let Some(pp) = s.doc.para_at(&f) { + let (x, y) = pp.word_at(f.off); + s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?; + } + s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?; + } else { + s.doc.format_range(&a, &b, &|c| { + let link = c.link.clone(); + *c = c.cleared().overlaid(&chr); + c.link = link; + })?; + if a.path != b.path || a.off == 0 { + s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?; + } + } + if !sticky { + s.painter = None; + } + sel_result(s) +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs new file mode 100644 index 000000000..0d88a1b3a --- /dev/null +++ b/crates/engine/src/cmd/file.rs @@ -0,0 +1,254 @@ +//! File tab (Backstage): new, open, save, export, print, properties; document inspection. + +use serde_json::{Value, json}; +use wordcraft_doc::{Block, Document, StoryRef}; + +use super::{pos_json, sel_result}; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(), + CommandSpec::new("file.open", "Open", "File", open).key("Mod+O").params(r#"{"path": string}"#).pure(), + CommandSpec::new("file.save", "Save", "File", save).key("Mod+S").params(r#"{"path"?: string}"#).pure(), + CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12 / Mod+Shift+S").params(r#"{"path": string}"#).pure(), + CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| { + let path = p::req_str(v, "path")?; + let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") }; + crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?; + Ok(json!({"path": path})) + }) + .params(r#"{"path": string}"#) + .pure(), + CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png).params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt}"#).pure(), + CommandSpec::new("file.print", "Print", "File", |s, _| { + s.ui_requests.push(json!({"open": "print"})); + sel_result(s) + }) + .key("Mod+P") + .pure(), + CommandSpec::new("file.close", "Close", "File", |s, _| { + s.ui_requests.push(json!({"close": true})); + sel_result(s) + }) + .key("Mod+W") + .pure(), + CommandSpec::new("file.properties", "Properties", "File › Info", properties).params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?}"#), + CommandSpec::new("file.info", "Info", "File", info).pure(), + CommandSpec::new("file.options", "Options", "File", |s, _| { + s.ui_requests.push(json!({"open": "options"})); + sel_result(s) + }) + .pure(), + CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", |s, v| { + s.author = p::req_str(v, "name")?.to_string(); + sel_result(s) + }) + .pure(), + CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(), + CommandSpec::new("document.text", "Document Text", "Agents", |s, v| { + let story = super::story_param(s, v); + Ok(json!({"text": s.doc.plain_text(story)})) + }) + .pure(), + CommandSpec::new("document.paragraph", "Paragraph Details", "Agents", paragraph).params(r#"{"path": [n], "story"?: Story}"#).pure(), + CommandSpec::new("document.selection", "Selection", "Agents", |s, _| { + let mut r = sel_result(s)?; + if let Some(o) = r.as_object_mut() { + o.insert("text".into(), json!(s.selected_text())); + o.insert("format".into(), super::format::state(s)); + } + Ok(r) + }) + .pure(), + CommandSpec::new("document.layout", "Layout Summary", "Agents", layout_summary).pure(), + CommandSpec::new("document.setText", "Replace Document Text", "Agents", |s, v| { + let text = p::req_str(v, "text")?; + let d = Document::from_text(text); + s.doc.body = d.body; + s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body)); + sel_result(s) + }) + .params(r#"{"text": string}"#), + ] +} + +fn new(s: &mut Session, v: &Value) -> CmdResult { + let doc = match p::str(v, "template").unwrap_or("blank") { + "sample" => crate::sample::sample_document(), + "letter" => crate::sample::letter(), + "resume" => crate::sample::resume(), + "report" => crate::sample::report(), + _ => Document::new(), + }; + s.set_document(doc); + s.path = None; + sel_result(s) +} + +fn open(s: &mut Session, v: &Value) -> CmdResult { + let Some(path) = p::str(v, "path") else { + s.ui_requests.push(json!({"open": "openFile"})); + return sel_result(s); + }; + let doc = if let Some(data) = p::str(v, "data") { + let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?; + crate::io::open_bytes(path, &bytes).map_err(CmdError::Failed)? + } else { + crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)? + }; + s.set_document(doc); + s.path = Some(path.into()); + Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count()})) +} + +fn save(s: &mut Session, v: &Value) -> CmdResult { + let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) { + Some(p) => p, + None => { + s.ui_requests.push(json!({"open": "saveAs"})); + return Ok(json!({"saved": false})); + } + }; + s.doc.core.modified = super::now_iso(); + if s.doc.core.created.is_empty() { + s.doc.core.created = s.doc.core.modified.clone(); + } + s.doc.core.last_modified_by = s.author.clone(); + s.doc.core.revision = s.doc.core.revision.saturating_add(1); + crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?; + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + if ["docx", "odt", "rtf", "json"].contains(&ext.as_str()) { + s.path = Some(path.clone()); + s.dirty = false; + } + Ok(json!({"saved": true, "path": path.to_string_lossy()})) +} + +fn save_as(s: &mut Session, v: &Value) -> CmdResult { + if p::str(v, "path").is_none() { + s.ui_requests.push(json!({"open": "saveAs"})); + return Ok(json!({"saved": false})); + } + save(s, v) +} + +fn export_png(s: &mut Session, v: &Value) -> CmdResult { + let path = p::req_str(v, "path")?; + let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1; + let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0); + let l = s.layout(); + let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?; + let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default()); + let png = img.to_png(); + #[cfg(not(target_arch = "wasm32"))] + std::fs::write(path, &png).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?; + Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()})) +} + +fn properties(s: &mut Session, v: &Value) -> CmdResult { + let c = &mut s.doc.core; + let set = |dst: &mut String, k: &str| { + if let Some(x) = p::str(v, k) { + *dst = x.to_string(); + } + }; + set(&mut c.title, "title"); + set(&mut c.subject, "subject"); + set(&mut c.creator, "author"); + set(&mut c.keywords, "keywords"); + set(&mut c.description, "comments"); + set(&mut c.category, "category"); + Ok(serde_json::to_value(&s.doc.core).unwrap_or(Value::Null)) +} + +fn info(s: &mut Session, _: &Value) -> CmdResult { + let l = s.layout(); + Ok(json!({ + "path": s.path.as_ref().map(|p| p.to_string_lossy().to_string()), + "dirty": s.dirty, + "pages": l.pages.len(), + "words": s.doc.word_count(), + "paragraphs": s.doc.paragraph_count(), + "properties": s.doc.core, + "trackChanges": s.doc.settings.track_changes, + "comments": s.doc.comments.len(), + "sections": s.doc.sections().len(), + "layoutMs": l.ms, + })) +} + +/// Structure for agents: blocks with text and formatting summary, selection, stats. +fn inspect(s: &mut Session, v: &Value) -> CmdResult { + let with_text = p::bool(v, "text").unwrap_or(true); + let mut blocks = Vec::new(); + for (i, b) in s.doc.body.iter().enumerate().take(2000) { + match &**b { + Block::Para(p) => { + let style = p.props.style.clone().unwrap_or_else(|| "Normal".into()); + let mut o = json!({"index": i, "type": "paragraph", "style": style, "len": p.len()}); + if with_text { + o["text"] = json!(p.plain_text()); + } + if let Some(n) = p.props.numbering.filter(|n| n.num != 0) { + o["list"] = json!({"num": n.num, "level": n.level}); + } + if !p.props.is_empty() { + o["props"] = serde_json::to_value(&p.props).unwrap_or(Value::Null); + } + let runs: Vec = p.run_ranges().filter(|(_, c)| !c.is_empty()).map(|(r, c)| json!({"start": r.start, "end": r.end, "props": c})).take(50).collect(); + if !runs.is_empty() { + o["runs"] = json!(runs); + } + if !p.objects.is_empty() { + o["objects"] = serde_json::to_value(&p.objects).unwrap_or(Value::Null); + } + if p.section.is_some() { + o["sectionBreak"] = json!(true); + } + blocks.push(o); + } + Block::Table(t) => { + let cells: Vec> = t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::>().join("\n")).collect()).collect(); + blocks.push(json!({"index": i, "type": "table", "rows": t.rows.len(), "cols": t.cols(), "style": t.props.style, "cells": cells})); + } + } + } + let l = s.layout(); + let parts: Vec = s.doc.parts.iter().map(|(id, p)| json!({"id": id, "kind": p.kind, "text": s.doc.plain_text(StoryRef::Part(*id))})).collect(); + Ok(json!({ + "blocks": blocks, + "parts": parts, + "selection": {"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus), "text": s.selected_text()}, + "pages": l.pages.len(), + "words": s.doc.word_count(), + "sections": s.doc.sections().iter().map(|(end, sp)| json!({"endBlock": end, "props": sp})).collect::>(), + "comments": s.doc.comments.len(), + "trackChanges": s.doc.settings.track_changes, + "properties": s.doc.core, + "canUndo": s.can_undo(), + "canRedo": s.can_redo(), + })) +} + +fn paragraph(s: &mut Session, v: &Value) -> CmdResult { + let path: Vec = serde_json::from_value(v.get("path").cloned().unwrap_or(json!([0]))).map_err(|e| CmdError::Params(e.to_string()))?; + let story = super::story_param(s, v); + let p = s.doc.para(story, &wordcraft_doc::Path(path)).ok_or_else(|| CmdError::Params("no paragraph there".into()))?; + let resolved = s.doc.styles.resolve_para(&p.props); + Ok(json!({"paragraph": p, "resolved": resolved})) +} + +fn layout_summary(s: &mut Session, _: &Value) -> CmdResult { + let l = s.layout(); + let pages: Vec = l + .pages + .iter() + .map(|p| { + let lines: usize = p.items.iter().map(|it| if let wordcraft_layout::Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 }).sum(); + json!({"number": p.number, "w": p.w, "h": p.h, "section": p.section, "lines": lines, "firstBlock": p.first_block, "header": p.header_story, "footer": p.footer_story}) + }) + .collect(); + let caret = l.caret_on(&s.sel.focus, s.page_hint).map(|c| json!({"page": c.page, "x": c.x, "y": c.top, "h": c.height})); + Ok(json!({"pages": pages, "caret": caret, "ms": l.ms})) +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs new file mode 100644 index 000000000..b5b9e9b11 --- /dev/null +++ b/crates/engine/src/cmd/format.rs @@ -0,0 +1,395 @@ +//! Character formatting (Home › Font). + +use serde_json::{Value, json}; +use wordcraft_doc::Pos; +use wordcraft_doc::props::{CharProps, Highlight, Rgb, TextColor, Underline, VertAlign}; +use wordcraft_doc::resolve::ResolvedChar; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// Word's font size list. +pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0]; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| toggle(s, v, |r| r.bold, |c, on| c.bold = Some(on))).key("Mod+B").params(r#"{"value"?: bool}"#), + CommandSpec::new("format.italic", "Italic", "Home › Font", |s, v| toggle(s, v, |r| r.italic, |c, on| c.italic = Some(on))).key("Mod+I").params(r#"{"value"?: bool}"#), + CommandSpec::new("format.underline", "Underline", "Home › Font", underline).key("Mod+U").params(r#"{"value"?: bool, "style"?: "single|double|thick|dotted|dash|dotDash|dotDotDash|wave|words"}"#), + CommandSpec::new("format.doubleUnderline", "Double Underline", "Home › Font › Underline", |s, _| { + underline(s, &json!({"style": "double"})) + }) + .key("Mod+Shift+D"), + CommandSpec::new("format.wordUnderline", "Underline Words Only", "Home › Font › Underline", |s, _| underline(s, &json!({"style": "words"}))).key("Mod+Shift+W"), + CommandSpec::new("format.strikethrough", "Strikethrough", "Home › Font", |s, v| toggle(s, v, |r| r.strike, |c, on| c.strike = Some(on))).params(r#"{"value"?: bool}"#), + CommandSpec::new("format.doubleStrikethrough", "Double Strikethrough", "Home › Font › Font", |s, v| { + toggle(s, v, |r| r.double_strike, |c, on| c.double_strike = Some(on)) + }), + CommandSpec::new("format.subscript", "Subscript", "Home › Font", |s, v| { + toggle(s, v, |r| r.vert_align == VertAlign::Subscript, |c, on| c.vert_align = Some(if on { VertAlign::Subscript } else { VertAlign::Baseline })) + }) + .key("Mod+="), + CommandSpec::new("format.superscript", "Superscript", "Home › Font", |s, v| { + toggle(s, v, |r| r.vert_align == VertAlign::Superscript, |c, on| c.vert_align = Some(if on { VertAlign::Superscript } else { VertAlign::Baseline })) + }) + .key("Mod+Shift+="), + CommandSpec::new("format.allCaps", "All Caps", "Home › Font › Font", |s, v| toggle(s, v, |r| r.caps, |c, on| c.caps = Some(on))).key("Mod+Shift+A"), + CommandSpec::new("format.smallCaps", "Small Caps", "Home › Font › Font", |s, v| toggle(s, v, |r| r.small_caps, |c, on| c.small_caps = Some(on))).key("Mod+Shift+K"), + CommandSpec::new("format.hidden", "Hidden", "Home › Font › Font", |s, v| toggle(s, v, |r| r.hidden, |c, on| c.hidden = Some(on))).key("Mod+Shift+H"), + CommandSpec::new("format.outline", "Outline", "Home › Font › Text Effects", |s, v| toggle(s, v, |r| r.outline, |c, on| c.outline = Some(on))), + CommandSpec::new("format.shadow", "Shadow", "Home › Font › Text Effects", |s, v| toggle(s, v, |r| r.shadow, |c, on| c.shadow = Some(on))), + CommandSpec::new("format.emboss", "Emboss", "Home › Font › Font", |s, v| toggle(s, v, |r| r.emboss, |c, on| c.emboss = Some(on))), + CommandSpec::new("format.engrave", "Engrave", "Home › Font › Font", |s, v| toggle(s, v, |r| r.engrave, |c, on| c.engrave = Some(on))), + CommandSpec::new("format.font", "Font", "Home › Font", font).key("Mod+Shift+F").params(r#"{"name": string}"#), + CommandSpec::new("format.size", "Font Size", "Home › Font", size).key("Mod+Shift+P").params(r#"{"size": number}"#), + CommandSpec::new("format.growFont", "Increase Font Size", "Home › Font", |s, _| step_size(s, 1)).key("Mod+Shift+. / Mod+Shift+>"), + CommandSpec::new("format.shrinkFont", "Decrease Font Size", "Home › Font", |s, _| step_size(s, -1)).key("Mod+Shift+, / Mod+Shift+<"), + CommandSpec::new("format.growFont1", "Grow Font 1 Point", "Home › Font", |s, _| nudge_size(s, 1.0)).key("Mod+]"), + CommandSpec::new("format.shrinkFont1", "Shrink Font 1 Point", "Home › Font", |s, _| nudge_size(s, -1.0)).key("Mod+["), + CommandSpec::new("format.color", "Font Color", "Home › Font", color).params(r#"{"color": "RRGGBB" | "auto"}"#), + CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight).params(r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#), + CommandSpec::new("format.shading", "Character Shading", "Home › Font", |s, v| { + let c = p::str(v, "color").and_then(Rgb::parse); + apply(s, &|x| x.shading = c) + }) + .params(r#"{"color": "RRGGBB" | null}"#), + CommandSpec::new("format.changeCase", "Change Case", "Home › Font", change_case).key("Shift+F3").params(r#"{"mode"?: "sentence|lower|upper|title|toggle"}"#), + CommandSpec::new("format.clear", "Clear All Formatting", "Home › Font", clear).key("Mod+Space"), + CommandSpec::new("format.spacing", "Character Spacing", "Home › Font › Font › Advanced", |s, v| { + let x = p::req_f32(v, "points")?.clamp(-100.0, 100.0); + apply(s, &|c| c.spacing = Some(x)) + }) + .params(r#"{"points": number}"#), + CommandSpec::new("format.scale", "Character Scale", "Home › Font › Font › Advanced", |s, v| { + let x = p::req_f32(v, "percent")?.clamp(1.0, 600.0); + apply(s, &|c| c.scale = Some(x)) + }) + .params(r#"{"percent": number}"#), + CommandSpec::new("format.position", "Character Position", "Home › Font › Font › Advanced", |s, v| { + let x = p::req_f32(v, "points")?.clamp(-1584.0, 1584.0); + apply(s, &|c| c.position = Some(x)) + }) + .params(r#"{"points": number}"#), + CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#), + CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#), + CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| { + s.ui_requests.push(json!({"open": "font"})); + sel_result(s) + }) + .key("Mod+D") + .pure(), + CommandSpec::new("format.state", "Formatting at Selection", "Home › Font", |s, _| Ok(state(s))).pure(), + ] +} + +/// The selection to format: the selection, or the word around a collapsed caret (Word +/// formats the whole word when the caret is inside one). `None` = caret between words. +fn target(s: &Session) -> Option<(Pos, Pos)> { + let (a, b) = s.sel.ordered(); + if a != b { + return Some((a, b)); + } + let p = s.doc.para_at(&a)?; + let is_w = |c: char| c.is_alphanumeric() || c == '\'' || c == '_'; + let before = p.text.get(..a.off)?.chars().next_back(); + let after = p.text.get(a.off..)?.chars().next(); + if before.is_some_and(is_w) && after.is_some_and(is_w) { + let (x, y) = p.word_at(a.off); + // word_at includes trailing spaces; Word formats just the word. + let word_end = p.text.get(x..y).map(|w| x + w.trim_end().len()).unwrap_or(y); + return Some((Pos { off: x, ..a.clone() }, Pos { off: word_end, ..a })); + } + None +} + +/// Apply a change to the selection (or the pending caret formatting). +pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult { + match target(s) { + Some((a, b)) => { + s.doc.format_range(&a, &b, f)?; + } + None => { + let mut pend = s.typing_props(); + f(&mut pend); + s.pending = Some(pend); + // An empty paragraph's mark takes the formatting too (so it shows the new size). + let fpos = s.sel.focus.clone(); + if s.doc.para_at(&fpos).is_some_and(|p| p.is_empty()) + && let Ok(para) = s.doc.para_mut(fpos.story, &fpos.path) + { + f(&mut para.mark); + para.touch(); + } + } + } + sel_result(s) +} + +/// Resolved formatting of every character in the target (or the caret). +fn resolved(s: &Session) -> Vec { + let mut out = Vec::new(); + let Some((a, b)) = target(s) else { + let f = &s.sel.focus; + let style = s.doc.para_at(f).and_then(|p| p.props.style.clone()); + out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props())); + return out; + }; + for path in s.doc.paths_between(&a, &b) { + let Some(p) = s.doc.para(a.story, &path) else { continue }; + let from = if path == a.path { a.off } else { 0 }; + let to = if path == b.path { b.off } else { p.len() }; + for (r, c) in p.run_ranges() { + if r.end > from && r.start < to { + out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c)); + } + } + if out.len() > 10_000 { + break; + } + } + if out.is_empty() { + let style = s.doc.para_at(&a).and_then(|p| p.props.style.clone()); + out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props())); + } + out +} + +fn toggle(s: &mut Session, v: &Value, get: fn(&ResolvedChar) -> bool, set: fn(&mut CharProps, bool)) -> CmdResult { + let on = match p::bool(v, "value") { + Some(b) => b, + None => !resolved(s).iter().all(get), + }; + apply(s, &|c| set(c, on)) +} + +fn underline(s: &mut Session, v: &Value) -> CmdResult { + let style = p::str(v, "style").map(Underline::from_ooxml); + let on = match (p::bool(v, "value"), style) { + (Some(b), _) => b, + (None, Some(_)) => true, + (None, None) => !resolved(s).iter().all(|r| r.underline != Underline::None), + }; + let u = if on { style.unwrap_or(Underline::Single) } else { Underline::None }; + apply(s, &|c| c.underline = Some(u)) +} + +fn font(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?.trim().to_string(); + if name.is_empty() || name.len() > 128 { + return Err(CmdError::Params("bad font name".into())); + } + apply(s, &|c| c.font = Some(name.clone())) +} + +fn size(s: &mut Session, v: &Value) -> CmdResult { + let sz = p::req_f32(v, "size")?; + if !(1.0..=1638.0).contains(&sz) { + return Err(CmdError::Params("size must be between 1 and 1638".into())); + } + // Word rounds to half points. + let sz = (sz * 2.0).round() / 2.0; + apply(s, &|c| c.size = Some(sz)) +} + +fn current_size(s: &Session) -> f32 { + resolved(s).first().map(|r| r.size).unwrap_or(12.0) +} + +fn step_size(s: &mut Session, dir: i32) -> CmdResult { + let cur = current_size(s); + let next = if dir > 0 { + SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0) + } else { + SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0) + }; + let next = next.clamp(1.0, 1638.0); + // Each run steps from its own size when the selection mixes sizes. + apply(s, &|c| c.size = Some(next)) +} + +fn nudge_size(s: &mut Session, d: f32) -> CmdResult { + let next = (current_size(s) + d).clamp(1.0, 1638.0); + apply(s, &|c| c.size = Some(next)) +} + +fn color(s: &mut Session, v: &Value) -> CmdResult { + let c = p::req_str(v, "color")?; + let tc = if c.eq_ignore_ascii_case("auto") || c.eq_ignore_ascii_case("automatic") { + TextColor::Auto + } else { + TextColor::Rgb(Rgb::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?) + }; + apply(s, &|x| x.color = Some(tc)) +} + +fn highlight(s: &mut Session, v: &Value) -> CmdResult { + let c = p::str(v, "color").unwrap_or("yellow"); + let h = Highlight::ALL + .iter() + .copied() + .find(|h| h.ooxml().eq_ignore_ascii_case(c) || h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], "")) || format!("{h:?}").eq_ignore_ascii_case(c)) + .ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?; + apply(s, &|x| x.highlight = Some(h)) +} + +fn change_case(s: &mut Session, v: &Value) -> CmdResult { + let (a, b) = s.sel.ordered(); + let (a, b) = if a == b { target(s).unwrap_or((a, b)) } else { (a, b) }; + if a == b { + return sel_result(s); + } + let text = s.doc.copy_range(&a, &b).plain_text(); + let mode = p::str(v, "mode").map(str::to_string).unwrap_or_else(|| { + // Shift+F3 cycles: lower → UPPER → Title → lower. + if text.chars().any(char::is_lowercase) && text.chars().any(char::is_uppercase) && !is_title(&text) { + "lower".into() + } else if !text.chars().any(char::is_uppercase) { + "upper".into() + } else if text.chars().any(char::is_lowercase) { + "lower".into() + } else { + "title".into() + } + }); + for path in s.doc.paths_between(&a, &b) { + let (from, to, src) = { + let Some(p) = s.doc.para(a.story, &path) else { continue }; + let from = if path == a.path { a.off } else { 0 }; + let to = if path == b.path { b.off } else { p.len() }; + (from, to, p.text.get(from..to).unwrap_or("").to_string()) + }; + let new = convert(&src, &mode, from == 0); + if new.len() != src.len() { + // Lengths differ (ß → SS): replace text keeping the first run's formatting. + let para = s.doc.para_mut(a.story, &path)?; + let props = para.props_of_char(from).clone(); + para.delete(from, to)?; + para.insert_text(from, &new, &props)?; + } else { + let para = s.doc.para_mut(a.story, &path)?; + if let Some(slice) = para.text.get(from..to) + && slice.len() == new.len() + { + para.text.replace_range(from..to, &new); + para.touch(); + } + } + } + sel_result(s) +} + +fn is_title(t: &str) -> bool { + t.split_whitespace().all(|w| w.chars().next().is_some_and(char::is_uppercase)) +} + +fn convert(src: &str, mode: &str, para_start: bool) -> String { + match mode { + "upper" => src.to_uppercase(), + "lower" => src.to_lowercase(), + "toggle" => src.chars().map(|c| if c.is_uppercase() { c.to_lowercase().collect::() } else { c.to_uppercase().collect() }).collect(), + "title" => { + let mut out = String::with_capacity(src.len()); + let mut up = true; + for c in src.chars() { + if up && c.is_alphabetic() { + out.extend(c.to_uppercase()); + up = false; + } else { + out.extend(c.to_lowercase()); + } + if c.is_whitespace() { + up = true; + } + } + out + } + _ => { + // Sentence case. + let mut out = String::with_capacity(src.len()); + let mut up = para_start; + for c in src.chars() { + if up && c.is_alphabetic() { + out.extend(c.to_uppercase()); + up = false; + } else { + out.extend(c.to_lowercase()); + } + if matches!(c, '.' | '!' | '?') { + up = true; + } + } + out + } + } +} + +fn clear(s: &mut Session, _: &Value) -> CmdResult { + match target(s) { + Some((a, b)) => { + s.doc.format_range(&a, &b, &|c| *c = c.cleared())?; + // Clear Formatting also resets the paragraph to Normal. + s.doc.format_paragraphs(&a, &b, &|p| { + let num = p.numbering; + *p = Default::default(); + p.numbering = num.filter(|_| false); + })?; + } + None => { + s.pending = Some(CharProps::default()); + let f = s.sel.focus.clone(); + s.doc.format_paragraphs(&f, &f, &|p| *p = Default::default())?; + } + } + sel_result(s) +} + +fn char_style(s: &mut Session, v: &Value) -> CmdResult { + let st = p::req_str(v, "style")?; + let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{st}`")))?; + apply(s, &|c| c.style = Some(id.clone())) +} + +fn set(s: &mut Session, v: &Value) -> CmdResult { + let props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?; + apply(s, &|c| c.overlay(&props)) +} + +/// What the ribbon shows: the formatting at the selection (mixed values are null). +pub fn state(s: &Session) -> Value { + let all = resolved(s); + let first = all.first().cloned(); + let same = |f: &dyn Fn(&ResolvedChar) -> Value| -> Value { + let Some(r) = &first else { return Value::Null }; + let v0 = f(r); + if all.iter().all(|r| f(r) == v0) { v0 } else { Value::Null } + }; + let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into()); + let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone()); + json!({ + "font": same(&|r| json!(r.font)), + "size": same(&|r| json!(r.size)), + "bold": all.iter().all(|r| r.bold), + "italic": all.iter().all(|r| r.italic), + "underline": all.iter().all(|r| r.underline != Underline::None), + "strike": all.iter().all(|r| r.strike), + "subscript": all.iter().all(|r| r.vert_align == VertAlign::Subscript), + "superscript": all.iter().all(|r| r.vert_align == VertAlign::Superscript), + "color": same(&|r| json!(match r.color { TextColor::Auto => "auto".to_string(), TextColor::Rgb(c) => c.hex() })), + "highlight": same(&|r| json!(r.highlight.map(|c| c.hex()))), + "style": style, + "styleName": style_name, + }) +} + +#[cfg(test)] +mod tests { + use super::convert; + + #[test] + fn case_conversions() { + assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again"); + assert_eq!(convert("hello WORLD", "title", true), "Hello World"); + assert_eq!(convert("Hello", "toggle", true), "hELLO"); + assert_eq!(convert("straße", "upper", true), "STRASSE"); + } +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs new file mode 100644 index 000000000..486c6af47 --- /dev/null +++ b/crates/engine/src/cmd/insert.rs @@ -0,0 +1,554 @@ +//! Insert tab: pages, tables, pictures, shapes, links, bookmarks, headers/footers, text, symbols. + +use serde_json::{Value, json}; +use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap}; +use wordcraft_doc::props::{Align, CharProps, Rgb, TabAlign, TabStop, TextColor}; +use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block}; + +use super::{delete_selection, sel_result, split_para, type_text}; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| { + type_text(s, "\u{000C}")?; + sel_result(s) + }), + CommandSpec::new("insert.blankPage", "Blank Page", "Insert › Pages", |s, _| { + type_text(s, "\u{000C}")?; + let at = s.sel.focus.clone(); + let at = split_para(s, &at)?; + s.doc.insert_text(&at, "\u{000C}", &CharProps::default())?; + s.sel = Selection::caret(at); + sel_result(s) + }), + CommandSpec::new("insert.coverPage", "Cover Page", "Insert › Pages", cover_page).params(r#"{"title"?: string, "subtitle"?: string, "author"?: string}"#), + CommandSpec::new("insert.table", "Table", "Insert › Tables", table).params(r#"{"rows": n, "cols": n, "style"?: string}"#), + CommandSpec::new("insert.picture", "Pictures", "Insert › Illustrations", picture).params(r#"{"path"?: string, "data"?: base64, "width"?: pt, "alt"?: string}"#), + CommandSpec::new("insert.shape", "Shapes", "Insert › Illustrations", shape) + .params(r#"{"kind": "rectangle|roundedRectangle|ellipse|triangle|diamond|line|arrow|star|heart", "width"?: pt, "height"?: pt, "fill"?: "RRGGBB", "stroke"?: "RRGGBB"}"#), + CommandSpec::new("insert.textBox", "Text Box", "Insert › Text", text_box).params(r#"{"text"?: string, "width"?: pt, "height"?: pt}"#), + CommandSpec::new("insert.link", "Link", "Insert › Links", link).key("Mod+K").params(r#"{"url": string, "text"?: string}"#), + CommandSpec::new("insert.removeLink", "Remove Hyperlink", "Insert › Links", |s, _| super::format::apply(s, &|c| { + c.link = None; + if c.style.as_deref() == Some("Hyperlink") { + c.style = None; + } + })), + CommandSpec::new("insert.bookmark", "Bookmark", "Insert › Links", bookmark).params(r#"{"name": string}"#), + CommandSpec::new("insert.header", "Header", "Insert › Header & Footer", |s, v| header_footer(s, v, true)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|title"}"#), + CommandSpec::new("insert.footer", "Footer", "Insert › Header & Footer", |s, v| header_footer(s, v, false)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|pageNumber"}"#), + CommandSpec::new("insert.pageNumber", "Page Number", "Insert › Header & Footer", page_number).params(r#"{"position"?: "top|bottom|current", "align"?: "left|center|right", "format"?: "x of y"}"#), + CommandSpec::new("insert.editHeader", "Edit Header", "Insert › Header & Footer › Header", |s, _| edit_hf(s, true)).pure(), + CommandSpec::new("insert.editFooter", "Edit Footer", "Insert › Header & Footer › Footer", |s, _| edit_hf(s, false)).pure(), + CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| { + s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); + sel_result(s) + }) + .pure(), + CommandSpec::new("insert.removeHeader", "Remove Header", "Insert › Header & Footer › Header", |s, _| { + s.doc.last_section.headers = Default::default(); + for path in s.doc.para_paths(StoryRef::Body) { + if s.doc.para(StoryRef::Body, &path).is_some_and(|p| p.section.is_some()) + && let Ok(p) = s.doc.para_mut(StoryRef::Body, &path) + && let Some(sec) = p.section.as_mut() + { + sec.headers = Default::default(); + } + } + sel_result(s) + }), + CommandSpec::new("insert.removeFooter", "Remove Footer", "Insert › Header & Footer › Footer", |s, _| { + s.doc.last_section.footers = Default::default(); + sel_result(s) + }), + CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#), + CommandSpec::new("insert.symbol", "Symbol", "Insert › Symbols", |s, v| { + let c = p::req_str(v, "char")?; + type_text(s, c)?; + sel_result(s) + }) + .params(r#"{"char": string}"#), + CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", |s, v| { + let lin = p::str(v, "linear").unwrap_or("a^2+b^2=c^2").to_string(); + let props = s.typing_props(); + let at = delete_selection(s)?; + let end = s.doc.insert_object(&at, InlineObject::Equation { linear: lin, display: false }, &props)?; + s.sel = Selection::caret(end); + sel_result(s) + }) + .key("Alt+=") + .params(r#"{"linear"?: string}"#), + CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#), + CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", |s, v| { + let lines = p::u64(v, "lines").unwrap_or(3).min(10) as u8; + super::para::fmt(s, &|p| p.drop_cap = Some(lines)) + }) + .params(r#"{"lines"?: n (0 = none)}"#), + CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| { + super::para::fmt(s, &|p| { + p.borders = Some(wordcraft_doc::props::Borders { bottom: Some(wordcraft_doc::props::Border::single(1.5)), ..Default::default() }) + }) + }), + CommandSpec::new("insert.wordArt", "WordArt", "Insert › Text", |s, v| { + let text = p::str(v, "text").unwrap_or("Your text here").to_string(); + let props = CharProps { size: Some(36.0), bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), outline: Some(false), ..Default::default() }; + let at = delete_selection(s)?; + let end = s.doc.insert_text(&at, &text, &props)?; + s.sel = Selection { anchor: at, focus: end }; + sel_result(s) + }), + CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| { + let path = p::req_str(v, "path")?; + let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; + let frag = wordcraft_doc::edit::Fragment { blocks: doc.body.iter().map(|b| (**b).clone()).collect() }; + let at = delete_selection(s)?; + let end = s.doc.insert_fragment(&at, &frag)?; + s.sel = Selection::caret(end); + sel_result(s) + }) + .params(r#"{"path": string}"#), + ] +} + +/// Insert a block after the caret's paragraph (splitting it if the caret is mid-paragraph). +fn insert_block_at_caret(s: &mut Session, block: Block) -> Result { + let at = delete_selection(s)?; + let len = s.doc.para_at(&at).map(|p| p.len()).unwrap_or(0); + let insert_at = if at.off == 0 && len > 0 { + at.path.clone() + } else if at.off >= len && len == 0 { + at.path.clone() + } else { + let new = s.doc.split_paragraph(&at)?; + if at.off >= len { at.path.with_last(at.path.last() + 1) } else { new.path } + }; + s.doc.insert_block(at.story, &insert_at, block)?; + // Word keeps a paragraph after a table. + let next = insert_at.with_last(insert_at.last() + 1); + if s.doc.block(at.story, &next).is_none() { + s.doc.insert_block(at.story, &next, Block::Para(Paragraph::new()))?; + } + // An empty paragraph we inserted before? Remove if the table took its place at the start. + Ok(insert_at) +} + +fn table(s: &mut Session, v: &Value) -> CmdResult { + let rows = p::u64(v, "rows").unwrap_or(2).clamp(1, 1000) as usize; + let cols = p::u64(v, "cols").unwrap_or(2).clamp(1, 63) as usize; + let width = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0); + let mut t = Table::new(rows, cols, width); + if let Some(st) = p::str(v, "style") { + let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?; + t.props.style = Some(id); + } + let story = s.sel.focus.story; + let path = insert_block_at_caret(s, Block::Table(t))?; + let mut first = path.0.clone(); + first.extend([0, 0, 0]); + s.sel = Selection::caret(Pos { story, path: Path(first), off: 0 }); + Ok(json!({"table": path.0})) +} + +/// Minimal base64 (standard alphabet, padding optional, whitespace ignored). +pub fn base64_decode(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() * 3 / 4); + let mut buf = 0u32; + let mut n = 0; + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + b'=' | b' ' | b'\n' | b'\r' | b'\t' => continue, + _ => return None, + } as u32; + buf = (buf << 6) | v; + n += 6; + if n >= 8 { + n -= 8; + out.push((buf >> n) as u8); + buf &= (1 << n) - 1; + } + } + Some(out) +} + +pub fn base64_encode(b: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(b.len().div_ceil(3) * 4); + for ch in b.chunks(3) { + let n = (ch.first().copied().unwrap_or(0) as u32) << 16 | (ch.get(1).copied().unwrap_or(0) as u32) << 8 | ch.get(2).copied().unwrap_or(0) as u32; + for i in 0..4 { + if i <= ch.len() { + out.push(T[(n >> (18 - 6 * i) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +fn picture(s: &mut Session, v: &Value) -> CmdResult { + let bytes = if let Some(d) = p::str(v, "data") { + base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))? + } else if let Some(path) = p::str(v, "path") { + #[cfg(not(target_arch = "wasm32"))] + { + std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))? + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + return Err(CmdError::Failed("reading files by path isn't available on the web".into())); + } + } else { + s.ui_requests.push(json!({"open": "insertPicture"})); + return sel_result(s); + }; + if bytes.len() > 200 << 20 { + return Err(CmdError::Failed("image is larger than 200 MB".into())); + } + let (pw, ph) = wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP)".into()))?; + let ext = match bytes.get(..4) { + Some([0x89, b'P', b'N', b'G']) => "png", + Some([0xFF, 0xD8, ..]) => "jpeg", + Some([b'G', b'I', b'F', _]) => "gif", + Some([b'R', b'I', b'F', b'F']) => "webp", + Some([b'B', b'M', ..]) => "bmp", + _ => "png", + }; + let key = s.doc.add_media(bytes, ext); + // Natural size at 96 ppi, fitted to the text width. + let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0); + let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75); + if let Some(want) = p::f32(v, "width") { + let k = want.max(4.0) / w.max(1.0); + w *= k; + h *= k; + } + if w > max_w { + h *= max_w / w; + w = max_w; + } + let props = s.typing_props(); + let at = delete_selection(s)?; + let obj = InlineObject::Image { media: key.clone(), w, h, alt: p::str(v, "alt").unwrap_or("").to_string(), float: Float::default(), crop: [0.0; 4] }; + let end = s.doc.insert_object(&at, obj, &props)?; + s.sel = Selection { anchor: at, focus: end }; + Ok(json!({"media": key, "width": w, "height": h})) +} + +fn shape(s: &mut Session, v: &Value) -> CmdResult { + let kind: ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?; + let w = p::f32(v, "width").unwrap_or(108.0).clamp(4.0, 2000.0); + let h = p::f32(v, "height").unwrap_or(if kind == ShapeKind::Line { 1.0 } else { 72.0 }).clamp(1.0, 2000.0); + let fill = match p::str(v, "fill") { + Some(c) => Rgb::parse(c), + None if kind == ShapeKind::Line => None, + None => Some(Rgb(0x15, 0x60, 0x82)), + }; + let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A))); + let props = s.typing_props(); + let at = delete_selection(s)?; + let obj = InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None }; + let end = s.doc.insert_object(&at, obj, &props)?; + s.sel = Selection { anchor: at, focus: end }; + sel_result(s) +} + +fn text_box(s: &mut Session, v: &Value) -> CmdResult { + let text = p::str(v, "text").unwrap_or("").to_string(); + let w = p::f32(v, "width").unwrap_or(144.0).clamp(18.0, 2000.0); + let h = p::f32(v, "height").unwrap_or(72.0).clamp(18.0, 2000.0); + let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))]); + let props = s.typing_props(); + let at = delete_selection(s)?; + let obj = InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill: Some(Rgb::WHITE), stroke: Some(Rgb::BLACK), stroke_width: 0.75, float: Float::default(), story: Some(id) }; + let end = s.doc.insert_object(&at, obj, &props)?; + s.sel = Selection::caret(end); + Ok(json!({"story": id})) +} + +fn link(s: &mut Session, v: &Value) -> CmdResult { + let Some(url) = p::str(v, "url").map(str::to_string) else { + s.ui_requests.push(json!({"open": "link"})); + return sel_result(s); + }; + if url.is_empty() || url.len() > 4096 { + return Err(CmdError::Params("bad url".into())); + } + let style = Some("Hyperlink".to_string()); + if s.sel.is_collapsed() { + let text = p::str(v, "text").unwrap_or(&url).to_string(); + let mut props = s.typing_props(); + props.link = Some(url.clone()); + props.style = style; + let at = s.sel.focus.clone(); + let end = s.doc.insert_text(&at, &text, &props)?; + s.sel = Selection::caret(end); + s.pending = Some(CharProps::default()); + } else { + let (a, b) = s.sel.ordered(); + s.doc.format_range(&a, &b, &|c| { + c.link = Some(url.clone()); + c.style = style.clone(); + })?; + } + sel_result(s) +} + +fn bookmark(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?.trim().to_string(); + if name.is_empty() || name.contains(' ') || name.len() > 40 || name.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return Err(CmdError::Params("bookmark names start with a letter, have no spaces and at most 40 characters".into())); + } + // Replace an existing bookmark of the same name. + for (_, pos) in s.doc.bookmarks().into_iter().filter(|(n, _)| *n == name).rev() { + let para = s.doc.para_mut(pos.story, &pos.path)?; + para.delete(pos.off, pos.off + wordcraft_doc::para::OBJ.len_utf8())?; + let offs: Vec = para.object_offsets(); + if let Some(e) = offs.into_iter().find(|o| matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if *n == name)) { + para.delete(e, e + wordcraft_doc::para::OBJ.len_utf8())?; + } + } + let (a, b) = s.sel.ordered(); + let props = CharProps::default(); + let end = s.doc.insert_object(&b, InlineObject::BookmarkEnd { name: name.clone() }, &props)?; + s.doc.insert_object(&a, InlineObject::BookmarkStart { name: name.clone() }, &props)?; + let _ = end; + sel_result(s) +} + +fn hf_part(s: &mut Session, header: bool) -> u32 { + let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; + let sect = s.doc.section_mut(block).clone(); + let existing = if header { sect.headers.default } else { sect.footers.default }; + if let Some(id) = existing.filter(|id| s.doc.parts.contains_key(id)) { + return id; + } + let style = if header { "Header" } else { "Footer" }; + let id = s.doc.add_part(if header { PartKind::Header } else { PartKind::Footer }, vec![para_block(Paragraph::new().styled(style))]); + let sect = s.doc.section_mut(block); + if header { + sect.headers.default = Some(id); + } else { + sect.footers.default = Some(id); + } + id +} + +fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult { + let id = hf_part(s, header); + let style = if header { "Header" } else { "Footer" }; + if let Some(text) = p::str(v, "text") { + let blocks = text.split('\n').map(|l| para_block(Paragraph::with_text(l, CharProps::default()).styled(style))).collect(); + s.doc.set_story(StoryRef::Part(id), blocks)?; + } else if let Some(preset) = p::str(v, "preset") { + let mut para = Paragraph::new().styled(style); + match preset { + "blankThree" => { + para.insert_text(0, "[Type here]\t[Type here]\t[Type here]", &CharProps::default())?; + } + "title" => { + para.insert_text(0, if s.doc.core.title.is_empty() { "[Document title]" } else { &s.doc.core.title }, &CharProps::default())?; + para.props.align = Some(Align::Center); + } + "pageNumber" => { + para.props.align = Some(Align::Center); + para.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default())?; + } + _ => { + para.insert_text(0, "[Type here]", &CharProps::default())?; + } + } + s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?; + } + s.sel = Selection::caret(s.doc.start_of(StoryRef::Part(id))); + Ok(json!({"story": id})) +} + +fn edit_hf(s: &mut Session, header: bool) -> CmdResult { + let id = hf_part(s, header); + s.touch(); + s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id))); + Ok(json!({"story": id})) +} + +fn page_number(s: &mut Session, v: &Value) -> CmdResult { + let position = p::str(v, "position").unwrap_or("bottom"); + let align = match p::str(v, "align").unwrap_or("center") { + "left" => Align::Left, + "right" => Align::Right, + _ => Align::Center, + }; + let x_of_y = p::str(v, "format").is_some_and(|f| f.contains("of")); + let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }; + 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 }, &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 }, &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); + 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 days_since = { + // Days since epoch for weekday. + let secs = { + #[cfg(not(target_arch = "wasm32"))] + { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) + } + #[cfg(target_arch = "wasm32")] + { + 0u64 + } + }; + (secs / 86_400) as usize + }; + let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January"); + let day = DAYS.get(days_since % 7).copied().unwrap_or("Monday"); + let mut out = String::new(); + let chars: Vec = fmt.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars.get(i).copied().unwrap_or(' '); + let mut n = 1; + while chars.get(i + n) == Some(&c) { + n += 1; + } + match (c, n) { + ('y', 4..) => out.push_str(&format!("{y:04}")), + ('y', _) => out.push_str(&format!("{:02}", y % 100)), + ('M', 4..) => out.push_str(month), + ('M', 3) => out.push_str(month.get(..3).unwrap_or(month)), + ('M', 2) => out.push_str(&format!("{m:02}")), + ('M', 1) => out.push_str(&m.to_string()), + ('d', 4..) => out.push_str(day), + ('d', 3) => out.push_str(day.get(..3).unwrap_or(day)), + ('d', 2) => out.push_str(&format!("{d:02}")), + ('d', 1) => out.push_str(&d.to_string()), + ('H', 2..) => out.push_str(&format!("{hh:02}")), + ('H', _) => out.push_str(&hh.to_string()), + ('h', _) => out.push_str(&(if hh % 12 == 0 { 12 } else { hh % 12 }).to_string()), + ('m', 2..) => out.push_str(&format!("{mm:02}")), + ('m', _) => out.push_str(&mm.to_string()), + ('a' | 'A', _) => out.push_str(if hh < 12 { "AM" } else { "PM" }), + _ => { + for _ in 0..n { + out.push(c); + } + } + } + i += n; + } + out +} + +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 }, &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 }; + let start = s.doc.start_of(StoryRef::Body); + let at = s.doc.split_paragraph(&start)?; + let _ = at; + s.doc.insert_fragment(&start, &frag)?; + s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); + sel_result(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_round_trip() { + let data = b"hello world!?"; + assert_eq!(base64_decode(&base64_encode(data)).unwrap(), data); + assert!(base64_decode("!!!").is_none()); + } + + #[test] + fn date_pictures() { + let d = format_date("yyyy-MM-dd"); + assert_eq!(d.len(), 10); + assert!(format_date("MMMM d, yyyy").contains(", ")); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs new file mode 100644 index 000000000..2b02430db --- /dev/null +++ b/crates/engine/src/cmd/mod.rs @@ -0,0 +1,210 @@ +//! Command implementations, one module per area. Each module exposes `specs()`. + +pub mod caret; +pub mod design; +pub mod edit; +pub mod file; +pub mod format; +pub mod insert; +pub mod page; +pub mod para; +pub mod references; +pub mod review; +pub mod table; +pub mod text; +pub mod view; + +use serde_json::{Value, json}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Pos, Revision, RevisionKind, StoryRef}; + +use crate::{CmdError, CmdResult, Registry, Session}; + +/// The full registry. +pub fn registry() -> Registry { + let mut v = Vec::new(); + v.extend(text::specs()); + v.extend(caret::specs()); + v.extend(edit::specs()); + v.extend(format::specs()); + v.extend(para::specs()); + v.extend(view::specs()); + v.extend(insert::specs()); + v.extend(page::specs()); + v.extend(table::specs()); + v.extend(review::specs()); + v.extend(file::specs()); + v.extend(design::specs()); + v.extend(references::specs()); + Registry::new(v) +} + +/// Parse a position from JSON (`{"story":"body","path":[0],"off":3}` or `{"block":0,"off":3}`). +pub fn parse_pos(v: &Value) -> Option { + if let Ok(p) = serde_json::from_value::(v.clone()) { + return Some(p); + } + let block = v.get("block")?.as_u64()? as usize; + let off = v.get("off").and_then(Value::as_u64).unwrap_or(0) as usize; + Some(Pos::body(block, off)) +} + +pub fn pos_json(p: &Pos) -> Value { + serde_json::to_value(p).unwrap_or(Value::Null) +} + +/// The standard result: the selection after the command. +pub fn sel_result(s: &Session) -> CmdResult { + Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus)})) +} + +/// Delete the selection (respecting track changes); returns the collapsed position. +pub fn delete_selection(s: &mut Session) -> Result { + let (a, b) = s.sel.ordered(); + if a == b { + return Ok(a); + } + let at = if s.doc.settings.track_changes { track_delete(s, &a, &b)? } else { s.doc.delete_range(&a, &b)? }; + s.sel = crate::Selection::caret(at.clone()); + Ok(at) +} + +/// A revision record for the current author. +pub fn new_revision(s: &mut Session, kind: RevisionKind) -> u32 { + let date = now_iso(); + if let Some((i, _)) = s.doc.revisions.iter().enumerate().rev().find(|(_, r)| r.kind == kind && r.author == s.author && r.date == date) { + return i as u32; + } + s.doc.revisions.push(Revision { kind, author: s.author.clone(), date }); + (s.doc.revisions.len() - 1) as u32 +} + +/// Tracked deletion: own insertions are removed, other text is marked deleted. +fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result { + let rid = new_revision(s, RevisionKind::Delete); + let author = s.author.clone(); + // Remove text this author inserted (it never existed for the reader); mark the rest. + let paths = s.doc.paths_between(a, b); + for path in paths.iter().rev() { + let Some(p) = s.doc.para(a.story, path) else { continue }; + let from = if *path == a.path { a.off } else { 0 }; + let to = if *path == b.path { b.off } else { p.len() }; + let ranges: Vec<(usize, usize, bool)> = p + .run_ranges() + .filter(|(r, _)| r.end > from && r.start < to) + .map(|(r, c)| { + let own = c.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == author); + (r.start.max(from), r.end.min(to), own) + }) + .collect(); + let para = s.doc.para_mut(a.story, path)?; + for (x, y, own) in ranges.into_iter().rev() { + if own { + para.delete(x, y)?; + } else { + para.format(x, y, &|c| c.del = Some(rid))?; + } + } + } + Ok(a.clone()) +} + +/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes. +pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> { + let mut props = s.typing_props(); + delete_selection(s)?; + if s.doc.settings.track_changes { + props.ins = Some(new_revision(s, RevisionKind::Insert)); + props.del = None; + } else { + props.ins = None; + props.del = None; + } + let mut at = s.sel.focus.clone(); + let mut first = true; + for part in text.split(['\r', '\u{2029}']) { + if !first { + at = split_para(s, &at)?; + } + first = false; + let part = part.replace('\u{000B}', "\n"); + if !part.is_empty() { + at = s.doc.insert_text(&at, &part, &props)?; + } + } + s.sel = crate::Selection::caret(at); + s.goal_x = None; + Ok(()) +} + +/// Split the paragraph at `at` like Enter does: an empty list paragraph leaves the list, the +/// next paragraph gets the style's "next" style when Enter is at the end. +pub fn split_para(s: &mut Session, at: &Pos) -> Result { + let (at_end, style, empty_list) = match s.doc.para_at(at) { + Some(p) => (at.off >= p.len(), p.props.style.clone(), p.is_empty() && p.props.numbering.is_some()), + None => return Err(CmdError::Failed("no paragraph at caret".into())), + }; + if empty_list { + // Enter on an empty list item ends the list (Word behaviour). + let para = s.doc.para_mut(at.story, &at.path)?; + para.props.numbering = Some(wordcraft_doc::props::NumRef { num: 0, level: 0 }); + para.props.indent_left = None; + para.props.indent_first = None; + para.touch(); + return Ok(at.clone()); + } + let new = s.doc.split_paragraph(at)?; + if at_end && let Some(st) = style.as_deref() { + let next = s.doc.styles.get(st).and_then(|x| x.next.clone()); + if let Some(n) = next + && n != st + { + let p = s.doc.para_mut(new.story, &new.path)?; + p.props.style = Some(n); + p.props.numbering = None; + // A heading's character formatting doesn't carry into body text. + p.mark = CharProps::default(); + } + } + if s.doc.settings.track_changes { + let rid = new_revision(s, RevisionKind::Insert); + let p = s.doc.para_mut(at.story, &at.path)?; + p.mark.ins = Some(rid); + } + Ok(new) +} + +/// ISO-8601 timestamp (UTC, seconds). +pub fn now_iso() -> String { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let days = (secs / 86_400) as i64; + let (y, m, d) = civil_from_days(days); + let t = secs % 86_400; + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) + } + #[cfg(target_arch = "wasm32")] + { + "2026-01-01T00:00:00Z".to_string() + } +} + +/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm). +pub fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's. +pub fn story_param(s: &Session, v: &Value) -> StoryRef { + v.get("story").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or(s.sel.focus.story) +} diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs new file mode 100644 index 000000000..9f493ef19 --- /dev/null +++ b/crates/engine/src/cmd/page.rs @@ -0,0 +1,211 @@ +//! Layout tab: page setup (margins, orientation, size, columns), breaks, line numbers, hyphenation. + +use serde_json::{Value, json}; +use wordcraft_doc::section::{Columns, LineNumbering, SectionProps, SectionStart}; +use wordcraft_doc::{Block, Pos}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins) + .params(r#"{"preset"?: "normal|narrow|moderate|wide|mirrored|office2003", "top"?: pt, "bottom"?: pt, "left"?: pt, "right"?: pt, "gutter"?: pt}"#), + CommandSpec::new("layout.orientation", "Orientation", "Layout › Page Setup", |s, v| { + let land = match p::str(v, "value") { + Some("landscape") => true, + Some("portrait") => false, + _ => !sect(s).landscape, + }; + with_sect(s, |x| x.set_landscape(land)) + }) + .params(r#"{"value": "portrait|landscape"}"#), + CommandSpec::new("layout.size", "Size", "Layout › Page Setup", size).params(r#"{"name"?: "Letter|Legal|A4|…", "width"?: pt, "height"?: pt}"#), + CommandSpec::new("layout.columns", "Columns", "Layout › Page Setup", columns).params(r#"{"count": 1-12, "space"?: pt, "separator"?: bool, "preset"?: "left|right"}"#), + CommandSpec::new("layout.break", "Breaks", "Layout › Page Setup", breaks).params(r#"{"kind": "page|column|textWrapping|nextPage|continuous|evenPage|oddPage"}"#), + CommandSpec::new("layout.lineNumbers", "Line Numbers", "Layout › Page Setup", |s, v| { + let mode = p::str(v, "value").unwrap_or("continuous"); + with_sect(s, |x| { + x.line_numbers = match mode { + "none" => None, + "restartPage" => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Page, ..Default::default() }), + "restartSection" => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Section, ..Default::default() }), + _ => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Continuous, ..Default::default() }), + } + }) + }) + .params(r#"{"value": "none|continuous|restartPage|restartSection"}"#), + CommandSpec::new("layout.hyphenation", "Hyphenation", "Layout › Page Setup", |s, v| { + let on = p::bool(v, "value").unwrap_or(!s.doc.settings.auto_hyphenation); + s.doc.settings.auto_hyphenation = on; + Ok(json!({"value": on})) + }), + CommandSpec::new("layout.pageSetup", "Page Setup", "Layout › Page Setup", |s, v| { + if let Some(props) = v.get("section") { + let new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?; + return with_sect(s, |x| *x = new.clone()); + } + s.ui_requests.push(json!({"open": "pageSetup"})); + Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null)) + }) + .params(r#"{"section"?: SectionProps}"#), + CommandSpec::new("layout.verticalAlign", "Vertical Alignment", "Layout › Page Setup › Layout", |s, v| { + let va = match p::str(v, "value") { + Some("center") => wordcraft_doc::props::VAlign::Center, + Some("bottom") => wordcraft_doc::props::VAlign::Bottom, + _ => wordcraft_doc::props::VAlign::Top, + }; + with_sect(s, |x| x.valign = va) + }), + CommandSpec::new("layout.differentFirstPage", "Different First Page", "Header & Footer › Options", |s, v| { + let on = p::bool(v, "value").unwrap_or(!sect(s).title_page); + with_sect(s, |x| x.title_page = on) + }), + CommandSpec::new("layout.differentOddEven", "Different Odd & Even Pages", "Header & Footer › Options", |s, v| { + s.doc.settings.even_odd_headers = p::bool(v, "value").unwrap_or(!s.doc.settings.even_odd_headers); + sel_result(s) + }), + CommandSpec::new("layout.pageNumberFormat", "Format Page Numbers", "Insert › Header & Footer › Page Number", |s, v| { + let fmt = p::str(v, "format").map(wordcraft_doc::section::NumFormat::from_ooxml); + let start = p::u64(v, "start").map(|x| x.min(100_000) as u32); + with_sect(s, |x| { + if let Some(f) = fmt { + x.page_num_format = f; + } + x.page_num_start = start; + }) + }) + .params(r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n}"#), + CommandSpec::new("layout.section", "Section Properties", "Layout › Page Setup", |s, _| Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))).pure(), + ] +} + +fn block_of(s: &Session) -> usize { + s.sel.focus.path.0.first().copied().unwrap_or(0) as usize +} + +pub fn sect(s: &Session) -> SectionProps { + let i = s.doc.section_index_of(block_of(s)); + s.doc.sections().get(i).map(|(_, x)| (*x).clone()).unwrap_or_default() +} + +/// Apply to the section(s) of the selection. +fn with_sect(s: &mut Session, f: impl Fn(&mut SectionProps)) -> CmdResult { + let (a, b) = s.sel.ordered(); + let (i0, i1) = (a.path.0.first().copied().unwrap_or(0) as usize, b.path.0.first().copied().unwrap_or(0) as usize); + let ends: Vec = s.doc.sections().iter().map(|(e, _)| *e).collect(); + let first = ends.iter().position(|e| i0 <= *e).unwrap_or(0); + let last = ends.iter().position(|e| i1 <= *e).unwrap_or(first); + for k in first..=last { + let block = ends.get(k).copied().unwrap_or(0); + f(s.doc.section_mut(block)); + } + Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null)) +} + +fn margins(s: &mut Session, v: &Value) -> CmdResult { + let preset = p::str(v, "preset"); + let (t, b, l, r) = match preset { + Some("normal") => (72.0, 72.0, 72.0, 72.0), + Some("narrow") => (36.0, 36.0, 36.0, 36.0), + Some("moderate") => (72.0, 72.0, 54.0, 54.0), + Some("wide") => (72.0, 72.0, 144.0, 144.0), + Some("mirrored") => (72.0, 72.0, 90.0, 72.0), + Some("office2003") => (72.0, 72.0, 90.0, 90.0), + Some(x) => return Err(CmdError::Params(format!("unknown preset `{x}`"))), + None => { + let c = sect(s); + ( + p::f32(v, "top").unwrap_or(c.margin_top), + p::f32(v, "bottom").unwrap_or(c.margin_bottom), + p::f32(v, "left").unwrap_or(c.margin_left), + p::f32(v, "right").unwrap_or(c.margin_right), + ) + } + }; + let gutter = p::f32(v, "gutter"); + let cur = sect(s); + if l + r + gutter.unwrap_or(cur.gutter) > cur.page_w - 36.0 || t + b > cur.page_h - 36.0 { + return Err(CmdError::Params("margins leave no room for text".into())); + } + s.doc.settings.mirror_margins = preset == Some("mirrored"); + with_sect(s, |x| { + x.margin_top = t.max(0.0); + x.margin_bottom = b.max(0.0); + x.margin_left = l.max(0.0); + x.margin_right = r.max(0.0); + if let Some(g) = gutter { + x.gutter = g.max(0.0); + } + }) +} + +fn size(s: &mut Session, v: &Value) -> CmdResult { + let (w, h) = if let Some(n) = p::str(v, "name") { + let (_, w, h) = wordcraft_geom::PAPER_SIZES.iter().find(|(name, _, _)| name.eq_ignore_ascii_case(n)).ok_or_else(|| CmdError::Params(format!("unknown paper `{n}`")))?; + (*w, *h) + } else { + (p::req_f32(v, "width")?, p::req_f32(v, "height")?) + }; + if !(72.0..=1584.0).contains(&w) || !(72.0..=1584.0).contains(&h) { + return Err(CmdError::Params("page size must be 1\"–22\"".into())); + } + with_sect(s, |x| { + let (pw, ph) = if x.landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) }; + x.page_w = pw; + x.page_h = ph; + }) +} + +fn columns(s: &mut Session, v: &Value) -> CmdResult { + let preset = p::str(v, "preset"); + let count = p::u64(v, "count").unwrap_or(match preset { + Some("left") | Some("right") => 2, + _ => 1, + }); + if !(1..=12).contains(&count) { + return Err(CmdError::Params("columns must be 1–12".into())); + } + let space = p::f32(v, "space").unwrap_or(36.0).clamp(0.0, 300.0); + let sep = p::bool(v, "separator").unwrap_or(false); + let tw = sect(s).text_width(); + let widths = match preset { + Some("left") => vec![((tw - space) / 3.0, space), ((tw - space) * 2.0 / 3.0, 0.0)], + Some("right") => vec![((tw - space) * 2.0 / 3.0, space), ((tw - space) / 3.0, 0.0)], + _ => Vec::new(), + }; + with_sect(s, |x| x.columns = Columns { count: count as u32, space, separator: sep, widths: widths.clone() }) +} + +fn breaks(s: &mut Session, v: &Value) -> CmdResult { + let kind = p::str(v, "kind").unwrap_or("page"); + let start = match kind { + "page" => return super::text::specs().iter().find(|c| c.id == "text.pageBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s)), + "column" => return super::text::specs().iter().find(|c| c.id == "text.columnBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s)), + "textWrapping" => return super::text::specs().iter().find(|c| c.id == "text.lineBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s)), + "nextPage" => SectionStart::NextPage, + "continuous" => SectionStart::Continuous, + "evenPage" => SectionStart::EvenPage, + "oddPage" => SectionStart::OddPage, + x => return Err(CmdError::Params(format!("unknown break `{x}`"))), + }; + if s.sel.focus.story != wordcraft_doc::StoryRef::Body || s.sel.focus.path.depth() > 0 { + return Err(CmdError::Failed("section breaks go in the main text".into())); + } + // Section break: the paragraph before the caret ends a section with the current props; the + // new section (after) takes them too and starts as requested. + let at = super::delete_selection(s)?; + let cur = sect(s); + let new = s.doc.split_paragraph(&at)?; + let ended = at.path.clone(); + let para = s.doc.para_mut(at.story, &ended)?; + let mut props = cur; + // The start type belongs to the section that begins after the break. + let next_block = new.path.last(); + para.section = Some(Box::new(props.clone())); + props.start = start; + *s.doc.section_mut(next_block) = props; + s.sel = Selection::caret(Pos { off: 0, ..new }); + let _ = Block::Para; + sel_result(s) +} diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs new file mode 100644 index 000000000..7feffb1df --- /dev/null +++ b/crates/engine/src/cmd/para.rs @@ -0,0 +1,521 @@ +//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles). + +use serde_json::{Value, json}; +use wordcraft_doc::numbering::ListKind; +use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabStop}; +use wordcraft_doc::styles::{Style, StyleKind}; +use wordcraft_doc::{Block, Pos}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("para.alignLeft", "Align Left", "Home › Paragraph", |s, _| align(s, Align::Left)).key("Mod+L"), + CommandSpec::new("para.alignCenter", "Center", "Home › Paragraph", |s, _| align(s, Align::Center)).key("Mod+E"), + CommandSpec::new("para.alignRight", "Align Right", "Home › Paragraph", |s, _| align(s, Align::Right)).key("Mod+R"), + CommandSpec::new("para.justify", "Justify", "Home › Paragraph", |s, _| align(s, Align::Justify)).key("Mod+J"), + CommandSpec::new("para.distribute", "Distributed", "Home › Paragraph", |s, _| align(s, Align::Distribute)).key("Mod+Shift+J"), + CommandSpec::new("para.align", "Alignment", "Home › Paragraph", |s, v| { + let a = match p::req_str(v, "value")? { + "left" => Align::Left, + "center" => Align::Center, + "right" => Align::Right, + "justify" | "both" => Align::Justify, + "distribute" => Align::Distribute, + x => return Err(CmdError::Params(format!("unknown alignment `{x}`"))), + }; + align(s, a) + }) + .params(r#"{"value": "left|center|right|justify|distribute"}"#), + CommandSpec::new("para.indent", "Increase Indent", "Home › Paragraph", indent).key("Mod+M"), + CommandSpec::new("para.outdent", "Decrease Indent", "Home › Paragraph", outdent).key("Mod+Shift+M"), + CommandSpec::new("para.hangingIndent", "Hanging Indent", "Home › Paragraph › Paragraph", |s, _| { + fmt(s, &|p| { + let l = p.indent_left.unwrap_or(0.0) + 36.0; + p.indent_left = Some(l); + p.indent_first = Some(-36.0); + }) + }) + .key("Mod+T"), + CommandSpec::new("para.removeHanging", "Reduce Hanging Indent", "Home › Paragraph › Paragraph", |s, _| { + fmt(s, &|p| { + p.indent_left = Some((p.indent_left.unwrap_or(0.0) - 36.0).max(0.0)); + p.indent_first = Some(0.0); + }) + }) + .key("Mod+Shift+T"), + CommandSpec::new("para.indents", "Indents", "Layout › Paragraph", |s, v| { + let (l, r, f) = (p::f32(v, "left"), p::f32(v, "right"), p::f32(v, "firstLine")); + fmt(s, &|p| { + if let Some(x) = l { + p.indent_left = Some(x.clamp(-1584.0, 1584.0)); + } + if let Some(x) = r { + p.indent_right = Some(x.clamp(-1584.0, 1584.0)); + } + if let Some(x) = f { + p.indent_first = Some(x.clamp(-1584.0, 1584.0)); + } + }) + }) + .params(r#"{"left"?: pt, "right"?: pt, "firstLine"?: pt (negative = hanging)}"#), + CommandSpec::new("para.lineSpacing", "Line and Paragraph Spacing", "Home › Paragraph", line_spacing) + .params(r#"{"value": number (multiple) } | {"atLeast": pt} | {"exactly": pt}"#), + CommandSpec::new("para.single", "Single Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(1.0))).key("Mod+1"), + CommandSpec::new("para.double", "Double Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(2.0))).key("Mod+2"), + CommandSpec::new("para.oneAndHalf", "1.5 Line Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(1.5))).key("Mod+5"), + CommandSpec::new("para.spacing", "Paragraph Spacing", "Layout › Paragraph", |s, v| { + let (b, a) = (p::f32(v, "before"), p::f32(v, "after")); + fmt(s, &|p| { + if let Some(x) = b { + p.space_before = Some(x.clamp(0.0, 1584.0)); + } + if let Some(x) = a { + p.space_after = Some(x.clamp(0.0, 1584.0)); + } + }) + }) + .params(r#"{"before"?: pt, "after"?: pt}"#), + CommandSpec::new("para.addSpaceBefore", "Add Space Before Paragraph", "Home › Paragraph › Line Spacing", |s, _| { + let has = cur(s).space_before > 0.0; + fmt(s, &|p| p.space_before = Some(if has { 0.0 } else { 12.0 })) + }) + .key("Mod+0"), + CommandSpec::new("para.removeSpaceAfter", "Remove Space After Paragraph", "Home › Paragraph › Line Spacing", |s, _| { + let has = cur(s).space_after > 0.0; + fmt(s, &|p| p.space_after = Some(if has { 0.0 } else { 8.0 })) + }), + CommandSpec::new("para.style", "Apply Style", "Home › Styles", apply_style).params(r#"{"style": string (name or id)}"#).key("Mod+Shift+S"), + CommandSpec::new("para.normal", "Normal Style", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Normal"}))).key("Mod+Shift+N"), + CommandSpec::new("para.heading1", "Heading 1", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Heading1"}))).key("Mod+Alt+1"), + CommandSpec::new("para.heading2", "Heading 2", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Heading2"}))).key("Mod+Alt+2"), + CommandSpec::new("para.heading3", "Heading 3", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Heading3"}))).key("Mod+Alt+3"), + CommandSpec::new("para.bullets", "Bullets", "Home › Paragraph", |s, v| list(s, v, ListKind::Bullet)).params(r#"{"kind"?: "bullet" | single char, "off"?: bool}"#).key("Mod+Shift+L"), + CommandSpec::new("para.numbering", "Numbering", "Home › Paragraph", |s, v| list(s, v, ListKind::Numbered)) + .params(r#"{"kind"?: "numbered|numberedParen|upperLetter|lowerLetter|lowerRoman|outline", "off"?: bool}"#), + CommandSpec::new("para.multilevel", "Multilevel List", "Home › Paragraph", |s, v| list(s, v, ListKind::Legal)).params(r#"{"kind"?: "legal|outline"}"#), + CommandSpec::new("para.listLevel", "Change List Level", "Home › Paragraph › Bullets", |s, v| { + let lvl = p::u64(v, "level").unwrap_or(0).min(8) as u8; + fmt(s, &|p| { + if let Some(n) = p.numbering.as_mut() { + n.level = lvl; + } + }) + }) + .params(r#"{"level": 0-8}"#), + CommandSpec::new("para.restartNumbering", "Restart at 1", "Home › Paragraph › Numbering", restart_numbering), + CommandSpec::new("para.shading", "Shading", "Home › Paragraph", |s, v| { + let c = p::str(v, "color").and_then(Rgb::parse); + fmt(s, &|p| p.shading = c) + }) + .params(r#"{"color": "RRGGBB" | null}"#), + CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders) + .params(r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick"}"#), + CommandSpec::new("para.sort", "Sort", "Home › Paragraph", sort).params(r#"{"descending"?: bool}"#), + CommandSpec::new("para.keepNext", "Keep with Next", "Home › Paragraph › Line and Page Breaks", |s, v| tog(s, v, |p| p.keep_next, |p, b| p.keep_next = Some(b))), + CommandSpec::new("para.keepLines", "Keep Lines Together", "Home › Paragraph › Line and Page Breaks", |s, v| tog(s, v, |p| p.keep_lines, |p, b| p.keep_lines = Some(b))), + CommandSpec::new("para.pageBreakBefore", "Page Break Before", "Home › Paragraph › Line and Page Breaks", |s, v| { + tog(s, v, |p| p.page_break_before, |p, b| p.page_break_before = Some(b)) + }), + CommandSpec::new("para.widowControl", "Widow/Orphan Control", "Home › Paragraph › Line and Page Breaks", |s, v| { + tog(s, v, |p| p.widow_control, |p, b| p.widow_control = Some(b)) + }), + CommandSpec::new("para.tabs", "Tabs", "Home › Paragraph › Paragraph", |s, v| { + let tabs: Vec = serde_json::from_value(v.get("tabs").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?; + if tabs.len() > 64 { + return Err(CmdError::Params("at most 64 tab stops".into())); + } + fmt(s, &|p| p.tabs = Some(tabs.clone())) + }) + .params(r#"{"tabs": [{"pos": pt, "align": "left|center|right|decimal|bar", "leader": "none|dot|hyphen|underscore"}]}"#), + CommandSpec::new("para.outlineLevel", "Outline Level", "Home › Paragraph › Paragraph", |s, v| { + let l = p::u64(v, "level").map(|x| x.min(9) as u8); + fmt(s, &|p| p.outline_level = l) + }), + CommandSpec::new("para.set", "Set Paragraph Formatting", "Home › Paragraph › Paragraph", |s, v| { + let props: ParaProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?; + fmt(s, &|p| p.overlay(&props)) + }) + .params(r#"{"props": ParaProps}"#), + CommandSpec::new("para.dialog", "Paragraph Settings", "Home › Paragraph", |s, _| { + s.ui_requests.push(json!({"open": "paragraph"})); + sel_result(s) + }) + .pure(), + CommandSpec::new("styles.create", "Create a Style", "Home › Styles", create_style).params(r#"{"name": string, "basedOn"?: string, "fromSelection"?: bool}"#), + CommandSpec::new("styles.modify", "Modify Style", "Home › Styles", modify_style).params(r#"{"style": string, "chr"?: CharProps, "para"?: ParaProps, "name"?: string, "next"?: string}"#), + CommandSpec::new("styles.updateToMatch", "Update Style to Match Selection", "Home › Styles", update_to_match).params(r#"{"style"?: string}"#), + CommandSpec::new("styles.delete", "Delete Style", "Home › Styles", |s, v| { + let id = style_id(s, p::req_str(v, "style")?)?; + if s.doc.styles.get(&id).is_some_and(|st| st.builtin) { + return Err(CmdError::Failed("built-in styles can't be deleted".into())); + } + s.doc.styles.styles.retain(|st| st.id != id); + sel_result(s) + }), + CommandSpec::new("styles.list", "Styles", "Home › Styles", |s, _| { + Ok(Value::Array( + s.doc.styles.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "kind": st.kind, "quick": st.quick, "basedOn": st.based_on})).collect(), + )) + }) + .pure(), + CommandSpec::new("styles.pane", "Styles Pane", "Home › Styles", |s, _| { + s.view.styles_pane = !s.view.styles_pane; + sel_result(s) + }) + .key("Mod+Alt+Shift+S") + .pure(), + CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| { + let id = style_id(s, p::req_str(v, "style")?)?; + let on = p::bool(v, "value").unwrap_or(true); + if let Some(st) = s.doc.styles.get_mut(&id) { + st.quick = on; + } + sel_result(s) + }), + ] +} + +/// Resolved paragraph props at the caret. +fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara { + let props = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default(); + s.doc.styles.resolve_para(&props) +} + +pub fn fmt(s: &mut Session, f: &dyn Fn(&mut ParaProps)) -> CmdResult { + let (a, b) = s.sel.ordered(); + s.doc.format_paragraphs(&a, &b, f)?; + sel_result(s) +} + +fn align(s: &mut Session, a: Align) -> CmdResult { + // Clicking the active alignment again returns to left (Word). + let now = cur(s).align; + let target = if now == a && a != Align::Left { Align::Left } else { a }; + fmt(s, &|p| p.align = Some(target)) +} + +fn tog(s: &mut Session, v: &Value, get: fn(&ParaProps) -> Option, set: fn(&mut ParaProps, bool)) -> CmdResult { + let on = p::bool(v, "value").unwrap_or_else(|| { + let r = cur(s); + let props = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default(); + let _ = r; + !get(&props).unwrap_or(false) + }); + fmt(s, &|p| set(p, on)) +} + +pub fn indent(s: &mut Session, _: &Value) -> CmdResult { + let (a, b) = s.sel.ordered(); + let paths = s.doc.paths_between(&a, &b); + let tab = s.doc.settings.default_tab.max(1.0); + for path in paths { + let (num, left) = { + let Some(p) = s.doc.para(a.story, &path) else { continue }; + (p.props.numbering.filter(|n| n.num != 0), s.doc.styles.resolve_para(&p.props).indent_left) + }; + let para = s.doc.para_mut(a.story, &path)?; + match num { + Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }), + None => para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab), + } + para.touch(); + } + sel_result(s) +} + +pub fn outdent(s: &mut Session, _: &Value) -> CmdResult { + let (a, b) = s.sel.ordered(); + let paths = s.doc.paths_between(&a, &b); + let tab = s.doc.settings.default_tab.max(1.0); + for path in paths { + let (num, left) = { + let Some(p) = s.doc.para(a.story, &path) else { continue }; + (p.props.numbering.filter(|n| n.num != 0), s.doc.styles.resolve_para(&p.props).indent_left) + }; + let para = s.doc.para_mut(a.story, &path)?; + match num { + Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_sub(1) }), + None => para.props.indent_left = Some((((left / tab).ceil() - 1.0) * tab).max(0.0)), + } + para.touch(); + } + sel_result(s) +} + +fn ls(s: &mut Session, l: LineSpacing) -> CmdResult { + fmt(s, &|p| p.line_spacing = Some(l)) +} + +fn line_spacing(s: &mut Session, v: &Value) -> CmdResult { + let l = if let Some(x) = p::f32(v, "atLeast") { + LineSpacing::AtLeast(x.clamp(0.0, 1584.0)) + } else if let Some(x) = p::f32(v, "exactly") { + LineSpacing::Exactly(x.clamp(0.7, 1584.0)) + } else { + LineSpacing::Multiple(p::req_f32(v, "value")?.clamp(0.06, 132.0)) + }; + ls(s, l) +} + +fn style_id(s: &Session, name: &str) -> Result { + s.doc.styles.find(name).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{name}`"))) +} + +fn apply_style(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "style")?; + let id = style_id(s, name)?; + let kind = s.doc.styles.get(&id).map(|x| x.kind).unwrap_or_default(); + if kind == StyleKind::Character { + return super::format::apply(s, &|c| c.style = Some(id.clone())); + } + let (a, b) = s.sel.ordered(); + s.doc.format_paragraphs(&a, &b, &|p| { + p.style = Some(id.clone()); + // Applying a heading or Normal removes direct list numbering (except list styles). + if id.starts_with("Heading") || id == "Title" || id == "Normal" { + if p.numbering.is_some_and(|n| n.num != 0) && id == "Normal" { + p.numbering = None; + } + } + })?; + // A paragraph style resets direct character formatting that matches nothing (Word keeps + // direct formatting unless it covers most of the paragraph; we clear font/size/colour). + if a.path == b.path || a == b { + let paths = s.doc.paths_between(&a, &b); + for path in paths { + let para = s.doc.para_mut(a.story, &path)?; + let len = para.len(); + para.format(0, len, &|c| { + c.font = None; + c.size = None; + c.color = None; + })?; + para.mark.font = None; + para.mark.size = None; + para.mark.color = None; + } + } + s.pending = None; + sel_result(s) +} + +fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult { + let kind = p::str(v, "kind").and_then(ListKind::parse).unwrap_or(default); + let off = p::bool(v, "off").unwrap_or(false); + let (a, b) = s.sel.ordered(); + // Toggle: if every selected paragraph already has this kind of list, remove it. + let current: Vec> = s.doc.paths_between(&a, &b).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect(); + let same_kind = |n: &NumRef| { + s.doc.numbering.level(n.num, 0).is_some_and(|l| { + let want = wordcraft_doc::numbering::levels_for(kind); + want.first().is_some_and(|w| (w.format == wordcraft_doc::section::NumFormat::Bullet) == (l.format == wordcraft_doc::section::NumFormat::Bullet) && (p::str(v, "kind").is_none() || w.text == l.text)) + }) + }; + let remove = off || (!current.is_empty() && current.iter().all(|n| n.as_ref().is_some_and(same_kind))); + if remove { + return fmt(s, &|p| { + p.numbering = Some(NumRef { num: 0, level: 0 }); + p.indent_left = None; + p.indent_first = None; + if p.style.as_deref() == Some("ListParagraph") { + p.style = None; + } + }); + } + // Continue the list of the previous paragraph if it has the same kind; else a new list. + let prev_num = s.doc.prev_para(a.story, &a.path).and_then(|q| s.doc.para(a.story, &q).and_then(|x| x.props.numbering)).filter(|n| n.num != 0 && same_kind(n)); + let num = match prev_num { + Some(n) => n.num, + None => match kind { + ListKind::Bullet | ListKind::BulletChar(_) => s.doc.numbering.find_kind(kind).unwrap_or_else(|| s.doc.numbering.add_list(kind)), + _ => s.doc.numbering.add_list(kind), + }, + }; + fmt(s, &|p| { + let level = p.numbering.filter(|n| n.num != 0).map(|n| n.level).unwrap_or(0); + p.numbering = Some(NumRef { num, level }); + p.indent_left = None; + p.indent_first = None; + if p.style.is_none() || p.style.as_deref() == Some("Normal") { + p.style = Some("ListParagraph".into()); + } + }) +} + +fn restart_numbering(s: &mut Session, _: &Value) -> CmdResult { + let f = s.sel.focus.clone(); + let Some(n) = s.doc.para_at(&f).and_then(|p| p.props.numbering).filter(|n| n.num != 0) else { + return Err(CmdError::Failed("not in a numbered list".into())); + }; + let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?; + // This paragraph and the following ones of the same list use the new instance. + let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|p| *p >= f.path).collect(); + for path in paths { + let para = s.doc.para_mut(f.story, &path)?; + match para.props.numbering { + Some(x) if x.num == n.num => { + para.props.numbering = Some(NumRef { num: new, level: x.level }); + para.touch(); + } + _ => { + if para.props.numbering.is_none() && !para.is_empty() { + break; + } + } + } + } + sel_result(s) +} + +fn borders(s: &mut Session, v: &Value) -> CmdResult { + let kind = p::str(v, "kind").unwrap_or("bottom"); + let width = p::f32(v, "width").unwrap_or(0.5).clamp(0.25, 6.0); + let color = p::str(v, "color").and_then(Rgb::parse); + let style = p::str(v, "style").map(BorderStyle::from_ooxml).unwrap_or(BorderStyle::Single); + let b = Border { style, width, color, space: if kind == "left" || kind == "right" { 4.0 } else { 1.0 } }; + if kind == "horizontalLine" { + // A paragraph with a bottom border, like Word's Horizontal Line. + return fmt(s, &|p| p.borders = Some(Borders { bottom: Some(Border { width: 1.5, ..b }), ..Default::default() })); + } + fmt(s, &|p| { + let mut cur = p.borders.unwrap_or_default(); + match kind { + "none" => cur = Borders::default(), + "all" => cur = Borders::all(b), + "outside" => { + cur.top = Some(b); + cur.bottom = Some(b); + cur.left = Some(b); + cur.right = Some(b); + } + "inside" => cur.between = Some(b), + "top" => cur.top = if cur.top.is_some_and(|x| x.is_visible()) { None } else { Some(b) }, + "bottom" => cur.bottom = if cur.bottom.is_some_and(|x| x.is_visible()) { None } else { Some(b) }, + "left" => cur.left = if cur.left.is_some_and(|x| x.is_visible()) { None } else { Some(b) }, + "right" => cur.right = if cur.right.is_some_and(|x| x.is_visible()) { None } else { Some(b) }, + _ => {} + } + p.borders = if cur.any_visible() { Some(cur) } else { Some(Borders::default()) }; + }) +} + +/// Sort the selected paragraphs (same container) alphabetically. +fn sort(s: &mut Session, v: &Value) -> CmdResult { + let desc = p::bool(v, "descending").unwrap_or(false); + let (a, b) = s.sel.ordered(); + if a.path.parent() != b.path.parent() { + return Err(CmdError::Failed("select paragraphs in one place to sort".into())); + } + let (i0, i1) = (a.path.last(), b.path.last()); + let bl = s.doc.container_mut(a.story, &a.path)?; + let Some(slice) = bl.get_mut(i0..=i1) else { return Err(CmdError::Failed("bad range".into())) }; + if slice.iter().any(|b| !matches!(**b, Block::Para(_))) { + return Err(CmdError::Failed("sorting tables isn't supported here; use Table › Sort".into())); + } + let key = |b: &std::sync::Arc| b.as_para().map(|p| p.plain_text().to_lowercase()).unwrap_or_default(); + slice.sort_by(|x, y| { + let o = natural_cmp(&key(x), &key(y)); + if desc { o.reverse() } else { o } + }); + // Keep a section break on the last paragraph of the range where it was. + s.sel = crate::Selection { anchor: Pos { off: 0, ..a }, focus: b }; + sel_result(s) +} + +/// Compare strings with embedded numbers numerically ("item 2" < "item 10"). +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + let num = |s: &str| s.trim().split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse::().ok()); + match (num(a), num(b)) { + (Some(x), Some(y)) if x != y => x.total_cmp(&y), + _ => a.cmp(b), + } +} + +fn create_style(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?.trim().to_string(); + if name.is_empty() { + return Err(CmdError::Params("name is empty".into())); + } + if s.doc.styles.find(&name).is_some() { + return Err(CmdError::Failed(format!("a style named `{name}` already exists"))); + } + let id = s.doc.styles.new_id(&name); + let based = p::str(v, "basedOn").and_then(|b| s.doc.styles.find(b).map(|x| x.id.clone())).or(Some("Normal".into())); + let mut st = Style { id: id.clone(), name, kind: StyleKind::Paragraph, based_on: based, next: Some(id.clone()), quick: true, priority: Some(50), ..Default::default() }; + if p::bool(v, "fromSelection").unwrap_or(true) + && let Some(para) = s.doc.para_at(&s.sel.focus) + { + st.para = para.props.clone(); + st.para.style = None; + st.para.numbering = None; + st.chr = para.props_at(s.sel.focus.off).clone(); + st.chr.style = None; + st.chr.link = None; + st.chr.ins = None; + st.chr.del = None; + } + s.doc.styles.upsert(st); + apply_style(s, &json!({"style": id}))?; + Ok(json!({"id": id})) +} + +fn modify_style(s: &mut Session, v: &Value) -> CmdResult { + let id = style_id(s, p::req_str(v, "style")?)?; + let chr: Option = v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?; + let para: Option = v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?; + let next = p::str(v, "next").map(str::to_string); + let name = p::str(v, "name").map(str::to_string); + let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?; + if let Some(c) = chr { + st.chr.overlay(&c); + } + if let Some(pp) = para { + st.para.overlay(&pp); + } + if let Some(n) = next { + st.next = Some(n); + } + if let Some(n) = name.filter(|n| !n.trim().is_empty()) { + st.name = n; + } + // Every paragraph's layout depends on styles: bump all revisions. + touch_all(s); + sel_result(s) +} + +fn update_to_match(s: &mut Session, v: &Value) -> CmdResult { + let f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f).cloned() else { return sel_result(s) }; + let id = match p::str(v, "style") { + Some(n) => style_id(s, n)?, + None => para.props.style.clone().unwrap_or_else(|| "Normal".into()), + }; + let mut chr = para.props_at(f.off).clone(); + chr.style = None; + chr.link = None; + chr.ins = None; + chr.del = None; + let mut pp = para.props.clone(); + pp.style = None; + pp.numbering = None; + if let Some(st) = s.doc.styles.get_mut(&id) { + st.chr.overlay(&chr); + st.para.overlay(&pp); + } + // Remove the now-redundant direct formatting from the paragraph. + let p = s.doc.para_mut(f.story, &f.path)?; + let len = p.len(); + p.format(0, len, &|c| *c = c.cleared())?; + let style = p.props.style.clone(); + p.props = ParaProps { style, numbering: p.props.numbering, ..Default::default() }; + touch_all(s); + sel_result(s) +} + +/// Style edits re-lay out every paragraph: the layout cache is keyed on the style sheet, so +/// nothing needs touching here (kept as the one place to hook style-change side effects). +pub fn touch_all(s: &mut Session) { + s.dirty = true; +} diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs new file mode 100644 index 000000000..c65158a5c --- /dev/null +++ b/crates/engine/src/cmd/references.rs @@ -0,0 +1,273 @@ +//! References tab: table of contents, footnotes/endnotes, captions; field updates. + +use serde_json::{Value, json}; +use wordcraft_doc::para::{InlineObject, NoteKind}; +use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop}; +use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, para_block}; + +use super::{delete_selection, sel_result}; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("references.toc", "Table of Contents", "References › Table of Contents", insert_toc).params(r#"{"levels"?: 1-9, "title"?: string}"#), + CommandSpec::new("references.updateToc", "Update Table", "References › Table of Contents", |s, _| { + update_toc(s)?; + sel_result(s) + }), + CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| { + if let Some((start, end)) = toc_range(s) { + for i in (start..=end).rev() { + s.doc.remove_block(StoryRef::Body, &Path::top(i))?; + } + s.clamp_selection(); + } + sel_result(s) + }), + CommandSpec::new("references.addText", "Add Text", "References › Table of Contents", |s, v| { + let level = p::u64(v, "level").unwrap_or(0).min(9) as u8; + super::para::fmt(s, &|pp| pp.outline_level = if level == 0 { Some(9) } else { Some(level - 1) }) + }) + .params(r#"{"level": 0 (do not show) | 1-9}"#), + CommandSpec::new("references.footnote", "Insert Footnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Footnote)).key("Mod+Alt+F").params(r#"{"text"?: string}"#), + CommandSpec::new("references.endnote", "Insert Endnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Endnote)).key("Mod+Alt+D").params(r#"{"text"?: string}"#), + CommandSpec::new("references.nextFootnote", "Next Footnote", "References › Footnotes", |s, _| { + let caret = s.sel.focus.clone(); + let refs = note_refs(s); + if let Some(p) = refs.iter().find(|p| **p > caret).or(refs.first()) { + s.sel = Selection::caret(p.clone()); + } + sel_result(s) + }) + .pure(), + CommandSpec::new("references.notes", "Show Notes", "References › Footnotes", |s, _| { + let mut out = Vec::new(); + for (id, part) in &s.doc.parts { + if matches!(part.kind, PartKind::Footnote | PartKind::Endnote) { + out.push(json!({"id": id, "kind": part.kind, "text": s.doc.plain_text(StoryRef::Part(*id))})); + } + } + Ok(Value::Array(out)) + }) + .pure(), + CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption).params(r#"{"label"?: "Figure|Table|Equation", "text"?: string}"#), + CommandSpec::new("references.updateFields", "Update Field", "References", |s, _| { + update_fields(s)?; + sel_result(s) + }) + .key("F9"), + ] +} + +/// Headings (outline level < levels) in the body: (path, level, text). +fn headings(s: &Session, levels: u8) -> Vec<(Path, u8, String)> { + let mut v = Vec::new(); + for path in s.doc.para_paths(StoryRef::Body) { + if path.depth() > 0 { + continue; + } + let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + let style = p.props.style.as_deref().unwrap_or("Normal"); + if style.starts_with("TOC") { + continue; + } + let rp = s.doc.styles.resolve_para(&p.props); + if let Some(l) = rp.outline_level + && l < levels + { + let t = p.plain_text().replace(['\u{000C}', '\n', '\t'], " ").trim().to_string(); + if !t.is_empty() { + v.push((path, l, t)); + } + } + } + v +} + +/// The TOC block range in the body: the paragraph holding the TOC field and the TOC entries after it. +fn toc_range(s: &Session) -> Option<(usize, usize)> { + let start = s.doc.body.iter().position(|b| { + b.as_para().is_some_and(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, .. } if instr.trim_start().to_ascii_uppercase().starts_with("TOC")))) + })?; + let mut end = start; + while let Some(Block::Para(p)) = s.doc.body.get(end + 1).map(|b| &**b) { + if p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading") { + end += 1; + } else { + break; + } + } + Some((start, end)) +} + +fn toc_levels(s: &Session, start: usize) -> u8 { + s.doc + .body + .get(start) + .and_then(|b| b.as_para()) + .and_then(|p| { + p.objects.iter().find_map(|o| match o { + InlineObject::Field { instr, .. } => instr.split("\\o").nth(1).and_then(|r| r.trim().trim_matches('"').split('-').nth(1)).and_then(|n| n.trim_matches('"').parse::().ok()), + _ => None, + }) + }) + .unwrap_or(3) + .clamp(1, 9) +} + +fn insert_toc(s: &mut Session, v: &Value) -> CmdResult { + if toc_range(s).is_some() { + update_toc(s)?; + return sel_result(s); + } + let levels = p::u64(v, "levels").unwrap_or(3).clamp(1, 9) as u8; + let title = p::str(v, "title").unwrap_or("Contents").to_string(); + let at = delete_selection(s)?; + if at.story != StoryRef::Body || at.path.depth() > 0 { + return Err(CmdError::Failed("a table of contents goes in the main text".into())); + } + // Title paragraph carrying the TOC field marker. + let mut head = Paragraph::with_text(&title, CharProps::default()).styled("TOCHeading"); + head.insert_object(head.len(), InlineObject::Field { instr: format!("TOC \\o \"1-{levels}\" \\h \\z \\u"), result: String::new(), locked: false }, &CharProps::default())?; + let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() }; + s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?; + update_toc(s)?; + sel_result(s) +} + +/// Regenerate the TOC entries with page numbers (two layout passes: entries change pagination). +pub fn update_toc(s: &mut Session) -> Result<(), CmdError> { + let Some((start, end)) = toc_range(s) else { return Ok(()) }; + let levels = toc_levels(s, start); + for i in (start + 1..=end).rev() { + s.doc.remove_block(StoryRef::Body, &Path::top(i))?; + } + let width = super::page::sect(s).text_width(); + let entries = headings(s, levels); + for pass in 0..2 { + if pass == 1 { + // Replace the page numbers now that the entries exist. + for _ in 0..entries.len() { + s.doc.remove_block(StoryRef::Body, &Path::top(start + 1))?; + } + } + s.touch(); + let l = s.layout(); + let shift = if pass == 0 { 0 } else { entries.len() }; + for (k, (path, level, text)) in entries.iter().enumerate() { + let target = Path::top(path.last() + shift); + let page = l.caret(&Pos { story: StoryRef::Body, path: target, off: 0 }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1); + let mut para = Paragraph::with_text(&format!("{text}\t{page}"), CharProps::default()).styled(&format!("TOC{}", level + 1)); + para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader: TabLeader::Dot }]); + s.doc.insert_block(StoryRef::Body, &Path::top(start + 1 + k), Block::Para(para))?; + } + } + s.touch(); + Ok(()) +} + +fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult { + let text = p::str(v, "text").unwrap_or("").to_string(); + let (pk, style, refstyle) = match kind { + NoteKind::Footnote => (PartKind::Footnote, "FootnoteText", "FootnoteReference"), + NoteKind::Endnote => (PartKind::Endnote, "EndnoteText", "EndnoteReference"), + }; + let mut para = Paragraph::new().styled(style); + let id = s.doc.add_part(pk, Vec::new()); + para.insert_object(0, InlineObject::NoteRef { kind, id, custom: String::new() }, &CharProps { style: Some(refstyle.into()), ..Default::default() })?; + let len = para.len(); + para.insert_text(len, &format!(" {text}"), &CharProps::default())?; + s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?; + let at = delete_selection(s)?; + let end = s.doc.insert_object(&at, InlineObject::NoteRef { kind, id, custom: String::new() }, &CharProps { style: Some(refstyle.into()), ..Default::default() })?; + s.sel = Selection::caret(end); + Ok(json!({"id": id})) +} + +fn note_refs(s: &Session) -> Vec { + let mut v = Vec::new(); + for path in s.doc.para_paths(StoryRef::Body) { + let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + for o in p.object_offsets() { + if matches!(p.object_at(o), Some(InlineObject::NoteRef { .. })) { + v.push(Pos { story: StoryRef::Body, path: path.clone(), off: o }); + } + } + } + v +} + +fn caption(s: &mut Session, v: &Value) -> CmdResult { + let label = p::str(v, "label").unwrap_or("Figure").to_string(); + let text = p::str(v, "text").unwrap_or("").to_string(); + // Number = existing captions with this label before the caret + 1. + let caret = s.sel.focus.clone(); + let n = s + .doc + .para_paths(StoryRef::Body) + .into_iter() + .filter(|q| *q < caret.path) + .filter(|q| s.doc.para(StoryRef::Body, q).is_some_and(|p| p.props.style.as_deref() == Some("Caption") && p.text.starts_with(&label))) + .count() + + 1; + let f = s.sel.focus.clone(); + let len = s.doc.para_at(&f).map(|p| p.len()).unwrap_or(0); + let at = Pos { off: len, ..f }; + let new = s.doc.split_paragraph(&at)?; + let para = s.doc.para_mut(new.story, &new.path)?; + para.props = wordcraft_doc::ParaProps { style: Some("Caption".into()), ..Default::default() }; + para.mark = CharProps::default(); + let base = format!("{label} "); + para.insert_text(0, &base, &CharProps::default())?; + para.insert_object(base.len(), InlineObject::Field { instr: format!("SEQ {label} \\* ARABIC"), result: n.to_string(), locked: false }, &CharProps::default())?; + let end = para.len(); + if !text.is_empty() { + para.insert_text(end, &format!(": {text}"), &CharProps::default())?; + } + let off = para.len(); + s.sel = Selection::caret(Pos { off, ..new }); + sel_result(s) +} + +/// Update fields in the body: dates, SEQ numbering, TOC. +pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { + let mut seq: std::collections::HashMap = Default::default(); + for path in s.doc.para_paths(StoryRef::Body) { + let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) { + continue; + } + let mut updates: Vec<(usize, String)> = Vec::new(); + for (k, o) in p.objects.iter().enumerate() { + let InlineObject::Field { instr, locked: false, .. } = o else { continue }; + let name = wordcraft_layout::fields::field_name(instr); + match name.as_str() { + "DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => { + let pic = instr.split("\\@").nth(1).map(|x| x.trim().trim_matches('"').to_string()).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() }); + updates.push((k, super::insert::format_date(&pic))); + } + "SEQ" => { + let id = instr.split_whitespace().nth(1).unwrap_or("").to_string(); + let n = seq.entry(id).or_insert(0); + *n += 1; + updates.push((k, n.to_string())); + } + "TITLE" => updates.push((k, s.doc.core.title.clone())), + "AUTHOR" => updates.push((k, s.doc.core.creator.clone())), + "NUMWORDS" => updates.push((k, s.doc.word_count().to_string())), + _ => {} + } + } + if updates.is_empty() { + continue; + } + let para = s.doc.para_mut(StoryRef::Body, &path)?; + for (k, r) in updates { + if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) { + *result = r; + } + } + para.touch(); + } + update_toc(s) +} diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs new file mode 100644 index 000000000..e16b0fd62 --- /dev/null +++ b/crates/engine/src/cmd/review.rs @@ -0,0 +1,312 @@ +//! Review tab: comments, track changes, word count, spelling (via the proofing hook). + +use serde_json::{Value, json}; +use wordcraft_doc::para::InlineObject; +use wordcraft_doc::{Comment, Paragraph, PartKind, Pos, StoryRef, para_block}; + +use super::{now_iso, pos_json, sel_result}; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key("Mod+Alt+M").params(r#"{"text": string}"#), + CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#), + CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: n, "all"?: bool}"#), + CommandSpec::new("review.resolveComment", "Resolve", "Review › Comments", |s, v| { + let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("`id` required".into()))?; + let c = s.doc.comments.get_mut(&id).ok_or_else(|| CmdError::Params("no such comment".into()))?; + c.resolved = p::bool(v, "value").unwrap_or(!c.resolved); + sel_result(s) + }), + CommandSpec::new("review.nextComment", "Next", "Review › Comments", |s, _| nav_comment(s, 1)).pure(), + CommandSpec::new("review.previousComment", "Previous", "Review › Comments", |s, _| nav_comment(s, -1)).pure(), + CommandSpec::new("review.comments", "Show Comments", "Review › Comments", list_comments).pure(), + CommandSpec::new("review.trackChanges", "Track Changes", "Review › Tracking", |s, v| { + s.doc.settings.track_changes = p::bool(v, "value").unwrap_or(!s.doc.settings.track_changes); + Ok(json!({"value": s.doc.settings.track_changes})) + }) + .key("Mod+Shift+E"), + CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, _| resolve_all(s, true)), + CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, _| resolve_all(s, false)), + CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, _| resolve_sel(s, true)), + CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, _| resolve_sel(s, false)), + CommandSpec::new("review.nextChange", "Next Change", "Review › Changes", |s, _| nav_change(s, 1)).pure(), + CommandSpec::new("review.previousChange", "Previous Change", "Review › Changes", |s, _| nav_change(s, -1)).pure(), + CommandSpec::new("review.markup", "Display for Review", "Review › Tracking", |s, v| { + s.view.show_markup = p::str(v, "value").map(|m| m != "noMarkup" && m != "original").unwrap_or(!s.view.show_markup); + Ok(json!({"showMarkup": s.view.show_markup})) + }) + .pure(), + CommandSpec::new("review.wordCount", "Word Count", "Review › Proofing", word_count).pure(), + CommandSpec::new("review.changes", "Reviewing Pane", "Review › Tracking", list_changes).pure(), + ] +} + +fn new_comment(s: &mut Session, v: &Value) -> CmdResult { + let text = p::str(v, "text").unwrap_or("").to_string(); + let (a, b) = s.sel.ordered(); + let (a, b) = if a == b { + // Comment on the word at the caret. + match s.doc.para_at(&a) { + Some(para) if !para.is_empty() => { + let (x, y) = para.word_at(a.off); + let y = para.text.get(x..y).map(|w| x + w.trim_end().len()).unwrap_or(y); + (Pos { off: x, ..a.clone() }, Pos { off: y, ..a }) + } + _ => (a.clone(), a), + } + } else { + (a, b) + }; + let part = s.doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))]); + let id = s.doc.comments.keys().next_back().map(|k| k + 1).unwrap_or(0); + let initials: String = s.author.split_whitespace().filter_map(|w| w.chars().next()).collect(); + s.doc.comments.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: None, resolved: false, part }); + let props = Default::default(); + s.doc.insert_object(&b, InlineObject::CommentEnd { id }, &props)?; + s.doc.insert_object(&a, InlineObject::CommentStart { id }, &props)?; + s.view.comments_pane = true; + Ok(json!({"id": id, "story": part})) +} + +fn reply(s: &mut Session, v: &Value) -> CmdResult { + let parent = p::u64(v, "id").map(|x| x as u32).ok_or_else(|| CmdError::Params("`id` required".into()))?; + if !s.doc.comments.contains_key(&parent) { + return Err(CmdError::Params("no such comment".into())); + } + let text = p::req_str(v, "text")?; + let part = s.doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))]); + let id = s.doc.comments.keys().next_back().map(|k| k + 1).unwrap_or(0); + let initials: String = s.author.split_whitespace().filter_map(|w| w.chars().next()).collect(); + s.doc.comments.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: Some(parent), resolved: false, part }); + Ok(json!({"id": id})) +} + +/// Comment anchored around the caret. +fn comment_at_caret(s: &Session) -> Option { + let f = &s.sel.focus; + let p = s.doc.para_at(f)?; + let mut open: Vec = Vec::new(); + for off in p.object_offsets() { + if off > f.off { + break; + } + match p.object_at(off) { + Some(InlineObject::CommentStart { id }) => open.push(*id), + Some(InlineObject::CommentEnd { id }) => open.retain(|x| x != id), + _ => {} + } + } + open.last().copied().or_else(|| { + p.object_offsets().into_iter().find_map(|o| match p.object_at(o) { + Some(InlineObject::CommentStart { id }) | Some(InlineObject::CommentEnd { id }) => Some(*id), + _ => None, + }) + }) +} + +fn remove_anchors(s: &mut Session, ids: &[u32]) -> Result<(), CmdError> { + for story in [StoryRef::Body] { + for path in s.doc.para_paths(story) { + let offs: Vec = s + .doc + .para(story, &path) + .map(|p| { + p.object_offsets() + .into_iter() + .filter(|o| matches!(p.object_at(*o), Some(InlineObject::CommentStart { id }) | Some(InlineObject::CommentEnd { id }) if ids.contains(id))) + .collect() + }) + .unwrap_or_default(); + if offs.is_empty() { + continue; + } + let para = s.doc.para_mut(story, &path)?; + for o in offs.into_iter().rev() { + para.delete(o, o + wordcraft_doc::para::OBJ.len_utf8())?; + } + } + } + Ok(()) +} + +fn delete_comment(s: &mut Session, v: &Value) -> CmdResult { + let ids: Vec = if p::bool(v, "all").unwrap_or(false) { + s.doc.comments.keys().copied().collect() + } else { + let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("no comment here".into()))?; + // The comment and its replies. + let mut v = vec![id]; + v.extend(s.doc.comments.iter().filter(|(_, c)| c.parent == Some(id)).map(|(k, _)| *k)); + v + }; + remove_anchors(s, &ids)?; + for id in &ids { + if let Some(c) = s.doc.comments.remove(id) { + s.doc.parts.remove(&c.part); + } + } + s.clamp_selection(); + Ok(json!({"deleted": ids})) +} + +/// Comments in document order with their anchors. +pub fn comment_list(s: &Session) -> Vec<(u32, Option)> { + let mut found: Vec<(u32, Option)> = Vec::new(); + for path in s.doc.para_paths(StoryRef::Body) { + let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + for o in p.object_offsets() { + if let Some(InlineObject::CommentStart { id }) = p.object_at(o) { + found.push((*id, Some(Pos { story: StoryRef::Body, path: path.clone(), off: o }))); + } + } + } + for id in s.doc.comments.keys() { + if !found.iter().any(|(x, _)| x == id) { + found.push((*id, None)); + } + } + found +} + +fn list_comments(s: &mut Session, _: &Value) -> CmdResult { + let list = comment_list(s); + Ok(Value::Array( + list.iter() + .filter_map(|(id, pos)| { + let c = s.doc.comments.get(id)?; + Some(json!({ + "id": id, "author": c.author, "date": c.date, "resolved": c.resolved, "parent": c.parent, + "text": s.doc.plain_text(StoryRef::Part(c.part)), + "anchor": pos.as_ref().map(pos_json), + })) + }) + .collect(), + )) +} + +fn nav_comment(s: &mut Session, dir: i32) -> CmdResult { + let list: Vec = comment_list(s).into_iter().filter_map(|(_, p)| p).collect(); + let caret = s.sel.focus.clone(); + let t = if dir > 0 { list.iter().find(|p| **p > caret).or(list.first()) } else { list.iter().rev().find(|p| **p < caret).or(list.last()) }; + if let Some(p) = t { + s.sel = Selection::caret(p.clone()); + } + sel_result(s) +} + +/// Accept or reject a revision range in one paragraph. +fn resolve_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path, from: usize, to: usize, accept: bool) -> Result<(), CmdError> { + let para = s.doc.para_mut(story, path)?; + let ranges: Vec<(usize, usize, bool, bool)> = + para.run_ranges().filter(|(r, _)| r.end > from && r.start < to).map(|(r, c)| (r.start.max(from), r.end.min(to), c.ins.is_some(), c.del.is_some())).collect(); + for (a, b, ins, del) in ranges.into_iter().rev() { + if (del && accept) || (ins && !accept) { + para.delete(a, b)?; + } else if ins || del { + para.format(a, b, &|c| { + c.ins = None; + c.del = None; + })?; + } + } + if para.mark.ins.is_some() { + para.mark.ins = None; + } + Ok(()) +} + +fn resolve_all(s: &mut Session, accept: bool) -> CmdResult { + let stories: Vec = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect(); + for st in stories { + for path in s.doc.para_paths(st).into_iter().rev() { + let len = s.doc.para(st, &path).map(|p| p.len()).unwrap_or(0); + resolve_para(s, st, &path, 0, len, accept)?; + } + } + s.doc.revisions.clear(); + s.clamp_selection(); + sel_result(s) +} + +fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult { + let (a, b) = s.sel.ordered(); + let (a, b) = if a == b { + // The change at the caret: the run around it. + let found = s.doc.para_at(&a).and_then(|p| p.run_ranges().find(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some())).map(|(r, _)| r)); + match found { + Some(r) => (Pos { off: r.start, ..a.clone() }, Pos { off: r.end, ..a }), + None => return nav_change(s, 1), + } + } else { + (a, b) + }; + for path in s.doc.paths_between(&a, &b).into_iter().rev() { + let len = s.doc.para(a.story, &path).map(|p| p.len()).unwrap_or(0); + let from = if path == a.path { a.off } else { 0 }; + let to = if path == b.path { b.off } else { len }; + resolve_para(s, a.story, &path, from, to, accept)?; + } + s.sel = Selection::caret(a); + s.clamp_selection(); + sel_result(s) +} + +fn changes(s: &Session) -> Vec<(Pos, Pos, &'static str, Option)> { + let mut out = Vec::new(); + for path in s.doc.para_paths(StoryRef::Body) { + let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + for (r, c) in p.run_ranges() { + let kind = if c.ins.is_some() { + "insert" + } else if c.del.is_some() { + "delete" + } else { + continue; + }; + let mk = |off| Pos { story: StoryRef::Body, path: path.clone(), off }; + out.push((mk(r.start), mk(r.end), kind, c.ins.or(c.del))); + } + } + out +} + +fn nav_change(s: &mut Session, dir: i32) -> CmdResult { + let list = changes(s); + let caret = s.sel.ordered(); + let t = if dir > 0 { list.iter().find(|c| c.0 >= caret.1).or(list.first()) } else { list.iter().rev().find(|c| c.1 <= caret.0).or(list.last()) }; + if let Some((a, b, _, _)) = t { + s.sel = Selection { anchor: a.clone(), focus: b.clone() }; + } + sel_result(s) +} + +fn list_changes(s: &mut Session, _: &Value) -> CmdResult { + let list = changes(s); + Ok(Value::Array( + list.iter() + .map(|(a, b, kind, rid)| { + let rev = rid.and_then(|r| s.doc.revisions.get(r as usize)); + json!({ + "kind": kind, "start": pos_json(a), "end": pos_json(b), + "text": s.doc.copy_range(a, b).plain_text(), + "author": rev.map(|r| r.author.clone()), "date": rev.map(|r| r.date.clone()), + }) + }) + .collect(), + )) +} + +fn word_count(s: &mut Session, _: &Value) -> CmdResult { + let text = if s.sel.is_collapsed() { s.doc.plain_text(StoryRef::Body) } else { s.selected_text() }; + let words = wordcraft_doc::count_words(&text); + let chars = text.chars().filter(|c| *c != '\n').count(); + let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count(); + let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count(); + let lines: usize = { + let l = s.layout(); + l.pages.iter().flat_map(|p| p.items.iter()).map(|it| if let wordcraft_layout::Placed::Lines { l0, l1, story: StoryRef::Body, .. } = it { l1 - l0 } else { 0 }).sum() + }; + let pages = s.layout().pages.len(); + Ok(json!({"pages": pages, "words": words, "characters": chars_no_spaces, "charactersWithSpaces": chars, "paragraphs": paragraphs, "lines": lines})) +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs new file mode 100644 index 000000000..536ce8cfc --- /dev/null +++ b/crates/engine/src/cmd/table.rs @@ -0,0 +1,556 @@ +//! Table Design and Table Layout tabs. + +use serde_json::{Value, json}; +use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, Rgb, VAlign}; +use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef, Table, para_block}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +fn in_table(s: &Session) -> Option<&'static str> { + if s.sel.focus.path.cell().is_some() { None } else { Some("the cursor isn't in a table") } +} + +pub fn specs() -> Vec { + let t = |c: CommandSpec| c.when(in_table); + vec![ + t(CommandSpec::new("table.insertRowAbove", "Insert Above", "Table Layout › Rows & Columns", |s, _| row(s, false))), + t(CommandSpec::new("table.insertRowBelow", "Insert Below", "Table Layout › Rows & Columns", |s, _| row(s, true))), + t(CommandSpec::new("table.insertColumnLeft", "Insert Left", "Table Layout › Rows & Columns", |s, _| col(s, false))), + t(CommandSpec::new("table.insertColumnRight", "Insert Right", "Table Layout › Rows & Columns", |s, _| col(s, true))), + t(CommandSpec::new("table.deleteRow", "Delete Rows", "Table Layout › Rows & Columns › Delete", del_row)), + t(CommandSpec::new("table.deleteColumn", "Delete Columns", "Table Layout › Rows & Columns › Delete", del_col)), + t(CommandSpec::new("table.deleteTable", "Delete Table", "Table Layout › Rows & Columns › Delete", del_table)), + t(CommandSpec::new("table.deleteCells", "Delete Cells", "Table Layout › Rows & Columns › Delete", |s, _| { + let (tp, r, c) = cell(s)?; + let story = s.sel.focus.story; + let t = s.doc.table_mut(story, &tp)?; + if let Some(row) = t.rows.get_mut(r) + && row.cells.len() > 1 + && c < row.cells.len() + { + row.cells.remove(c); + } + fix_caret(s, &tp, r, 0) + })), + t(CommandSpec::new("table.merge", "Merge Cells", "Table Layout › Merge", merge)), + t(CommandSpec::new("table.split", "Split Cells", "Table Layout › Merge", |s, v| { + let n = p::u64(v, "columns").unwrap_or(2).clamp(1, 63) as usize; + let (tp, r, c) = cell(s)?; + s.doc.table_mut(s.sel.focus.story, &tp)?.split_cell(r, c, n); + fix_caret(s, &tp, r, c) + }) + .params(r#"{"columns"?: n}"#)), + t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)), + t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| { + let st = p::req_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}`")))?; + with_table(s, |t| t.props.style = Some(id.clone())) + }) + .params(r#"{"style": string}"#)), + t(CommandSpec::new("table.look", "Table Style Options", "Table Design › Table Style Options", |s, v| { + let set = |k: &str, cur: bool| p::bool(v, k).unwrap_or(cur); + with_table(s, |t| { + let l = &mut t.props.look; + l.header_row = set("headerRow", l.header_row); + l.total_row = set("totalRow", l.total_row); + l.banded_rows = set("bandedRows", l.banded_rows); + l.first_column = set("firstColumn", l.first_column); + l.last_column = set("lastColumn", l.last_column); + l.banded_columns = set("bandedColumns", l.banded_columns); + }) + }) + .params(r#"{"headerRow"?: bool, "totalRow"?: bool, "bandedRows"?: bool, "firstColumn"?: bool, "lastColumn"?: bool, "bandedColumns"?: bool}"#)), + t(CommandSpec::new("table.shading", "Shading", "Table Design › Table Styles", |s, v| { + let c = p::str(v, "color").and_then(Rgb::parse); + with_cells(s, |cl| cl.props.shading = c) + }) + .params(r#"{"color": "RRGGBB" | null}"#)), + t(CommandSpec::new("table.borders", "Borders", "Table Design › Borders", borders).params(r#"{"kind": "all|outside|inside|none|top|bottom|left|right", "width"?: pt, "color"?: "RRGGBB"}"#)), + t(CommandSpec::new("table.cellAlign", "Alignment", "Table Layout › Alignment", |s, v| { + let (h, va) = match p::str(v, "value").unwrap_or("topLeft") { + "topLeft" => (Align::Left, VAlign::Top), + "topCenter" => (Align::Center, VAlign::Top), + "topRight" => (Align::Right, VAlign::Top), + "centerLeft" => (Align::Left, VAlign::Center), + "center" => (Align::Center, VAlign::Center), + "centerRight" => (Align::Right, VAlign::Center), + "bottomLeft" => (Align::Left, VAlign::Bottom), + "bottomCenter" => (Align::Center, VAlign::Bottom), + "bottomRight" => (Align::Right, VAlign::Bottom), + x => return Err(CmdError::Params(format!("unknown alignment `{x}`"))), + }; + with_cells(s, |cl| { + cl.props.valign = va; + for b in &mut cl.blocks { + if let Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.align = Some(h); + p.touch(); + } + } + }) + }) + .params(r#"{"value": "topLeft|topCenter|…|bottomRight"}"#)), + t(CommandSpec::new("table.autofit", "AutoFit", "Table Layout › Cell Size", |s, v| { + let mode = p::str(v, "mode").unwrap_or("window"); + let tw = super::page::sect(s).text_width(); + with_table(s, |t| match mode { + "fixed" => t.props.fixed = true, + "contents" => { + t.props.fixed = false; + t.props.width = None; + t.props.width_pct = None; + } + _ => { + t.props.fixed = false; + t.props.width_pct = Some(100.0); + let n = t.grid.len().max(1) as f32; + t.grid.iter_mut().for_each(|g| *g = tw / n); + } + }) + }) + .params(r#"{"mode": "contents|window|fixed"}"#)), + t(CommandSpec::new("table.distributeColumns", "Distribute Columns", "Table Layout › Cell Size", |s, _| { + with_table(s, |t| { + let total: f32 = t.grid.iter().sum(); + let n = t.grid.len().max(1) as f32; + t.grid.iter_mut().for_each(|g| *g = total / n); + }) + })), + t(CommandSpec::new("table.distributeRows", "Distribute Rows", "Table Layout › Cell Size", |s, _| { + with_table(s, |t| { + let h = t.rows.iter().filter_map(|r| r.props.height).fold(0.0f32, f32::max).max(18.0); + for r in &mut t.rows { + r.props.height = Some(h); + } + }) + })), + t(CommandSpec::new("table.columnWidth", "Table Column Width", "Table Layout › Cell Size", |s, v| { + let w = p::req_f32(v, "width")?.clamp(6.0, 1584.0); + let (_, r, c) = cell(s)?; + with_table(s, |t| { + let g = t.grid_col(r, c); + if let Some(x) = t.grid.get_mut(g) { + *x = w; + } + }) + }) + .params(r#"{"width": pt}"#)), + t(CommandSpec::new("table.rowHeight", "Table Row Height", "Table Layout › Cell Size", |s, v| { + let h = p::req_f32(v, "height")?.clamp(1.0, 1584.0); + let (_, r, _) = cell(s)?; + with_table(s, |t| { + if let Some(row) = t.rows.get_mut(r) { + row.props.height = Some(h); + row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast; + } + }) + }) + .params(r#"{"height": pt}"#)), + t(CommandSpec::new("table.repeatHeader", "Repeat Header Rows", "Table Layout › Data", |s, v| { + let (_, r, _) = cell(s)?; + let on = p::bool(v, "value"); + with_table(s, |t| { + if let Some(row) = t.rows.get_mut(r) { + row.props.header = on.unwrap_or(!row.props.header); + } + }) + })), + t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table).params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)), + t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)), + t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)), + t(CommandSpec::new("table.selectTable", "Select Table", "Table Layout › Table › Select", |s, _| { + let (tp, _, _) = cell(s)?; + select_cells(s, &tp, None) + }) + .pure()), + t(CommandSpec::new("table.selectRow", "Select Row", "Table Layout › Table › Select", |s, _| { + let (tp, r, _) = cell(s)?; + select_cells(s, &tp, Some((r, None))) + }) + .pure()), + t(CommandSpec::new("table.selectCell", "Select Cell", "Table Layout › Table › Select", |s, _| { + let (tp, r, c) = cell(s)?; + select_cells(s, &tp, Some((r, Some(c)))) + }) + .pure()), + t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", |s, v| { + if let Some(a) = p::str(v, "align") { + let al = match a { + "center" => Align::Center, + "right" => Align::Right, + _ => Align::Left, + }; + return with_table(s, |t| t.props.align = Some(al)); + } + let (tp, _, _) = cell(s)?; + let t = s.doc.table(s.sel.focus.story, &tp).cloned().unwrap_or_default(); + Ok(serde_json::to_value(&t.props).unwrap_or(Value::Null)) + }) + .params(r#"{"align"?: "left|center|right"}"#)), + CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text).params(r#"{"separator"?: "tab|comma"}"#), + CommandSpec::new("table.quick", "Quick Tables", "Insert › Tables", quick_table).params(r#"{"kind"?: "calendar|tabular|matrix"}"#), + ] +} + +fn cell(s: &Session) -> Result<(Path, usize, usize), CmdError> { + s.sel.focus.path.cell().ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into())) +} + +fn with_table(s: &mut Session, f: impl Fn(&mut Table)) -> CmdResult { + let (tp, _, _) = cell(s)?; + let t = s.doc.table_mut(s.sel.focus.story, &tp)?; + f(t); + touch_cells(s, &tp)?; + sel_result(s) +} + +/// Selected cells: (row, cell) pairs covered by the selection (at least the caret's cell). +fn selected_cells(s: &Session) -> Vec<(usize, usize)> { + let (a, b) = s.sel.ordered(); + let (Some((ta, ra, ca)), Some((tb, rb, cb))) = (a.path.cell(), b.path.cell()) else { return Vec::new() }; + if ta != tb { + return vec![(rb, cb)]; + } + let (r0, r1) = (ra.min(rb), ra.max(rb)); + let (c0, c1) = (ca.min(cb), ca.max(cb)); + let mut v = Vec::new(); + for r in r0..=r1 { + for c in c0..=c1 { + v.push((r, c)); + } + } + v +} + +fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResult { + let (tp, _, _) = cell(s)?; + let cells = selected_cells(s); + let t = s.doc.table_mut(s.sel.focus.story, &tp)?; + for (r, c) in cells { + if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) { + f(cl); + } + } + touch_cells(s, &tp)?; + sel_result(s) +} + +/// Bump the revision of every paragraph in a table (table style changes alter their layout). +fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> { + let story = s.sel.focus.story; + let paths: Vec = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect(); + for p in paths { + s.doc.para_mut(story, &p)?.touch(); + } + Ok(()) +} + +fn fix_caret(s: &mut Session, tp: &Path, r: usize, c: usize) -> CmdResult { + let story = s.sel.focus.story; + let Some(t) = s.doc.table(story, tp) else { + s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: tp.clone(), off: 0 })); + return sel_result(s); + }; + let r = r.min(t.rows.len().saturating_sub(1)); + let c = c.min(t.rows.get(r).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0)); + let mut path = tp.0.clone(); + path.extend([r as u32, c as u32, 0]); + s.sel = Selection::caret(Pos { story, path: Path(path), off: 0 }); + sel_result(s) +} + +fn row(s: &mut Session, below: bool) -> CmdResult { + let (tp, r, c) = cell(s)?; + let cells = selected_cells(s); + let n = cells.iter().map(|x| x.0).collect::>().len().max(1); + let t = s.doc.table_mut(s.sel.focus.story, &tp)?; + for _ in 0..n { + t.insert_row(if below { r + 1 } else { r }, r); + } + fix_caret(s, &tp, if below { r + 1 } else { r }, c) +} + +fn col(s: &mut Session, right: bool) -> CmdResult { + let (tp, r, c) = cell(s)?; + let t = s.doc.table_mut(s.sel.focus.story, &tp)?; + let g = t.grid_col(r, c); + let span = t.rows.get(r).and_then(|x| x.cells.get(c)).map(|x| x.span()).unwrap_or(1); + t.insert_col(if right { g + span } else { g }); + // Keep the table within its width: shrink all columns proportionally. + let total: f32 = t.grid.iter().sum(); + let before: f32 = total - t.grid.get(if right { g + span } else { g }).copied().unwrap_or(0.0); + if before > 0.0 { + let k = before / total; + t.grid.iter_mut().for_each(|w| *w *= k); + } + fix_caret(s, &tp, r, if right { c + 1 } else { c }) +} + +fn del_row(s: &mut Session, _: &Value) -> CmdResult { + let (tp, r, _) = cell(s)?; + let rows: std::collections::BTreeSet = selected_cells(s).into_iter().map(|x| x.0).collect(); + let story = s.sel.focus.story; + let t = s.doc.table_mut(story, &tp)?; + if rows.len() >= t.rows.len() { + return del_table(s, &Value::Null); + } + for rr in rows.iter().rev() { + t.delete_row(*rr); + } + fix_caret(s, &tp, r, 0) +} + +fn del_col(s: &mut Session, _: &Value) -> CmdResult { + let (tp, r, c) = cell(s)?; + let story = s.sel.focus.story; + let t = s.doc.table_mut(story, &tp)?; + if t.grid.len() <= 1 { + return del_table(s, &Value::Null); + } + let g = t.grid_col(r, c); + t.delete_col(g); + fix_caret(s, &tp, r, c.saturating_sub(1)) +} + +fn del_table(s: &mut Session, _: &Value) -> CmdResult { + let (tp, _, _) = cell(s)?; + let story = s.sel.focus.story; + s.doc.remove_block(story, &tp)?; + s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: tp, off: 0 })); + sel_result(s) +} + +fn merge(s: &mut Session, _: &Value) -> CmdResult { + let (tp, _, _) = cell(s)?; + let cells = selected_cells(s); + if cells.len() < 2 { + return Err(CmdError::Failed("select two or more cells to merge".into())); + } + let story = s.sel.focus.story; + let (r0, r1) = (cells.iter().map(|x| x.0).min().unwrap_or(0), cells.iter().map(|x| x.0).max().unwrap_or(0)); + let t = s.doc.table_mut(story, &tp)?; + let c0 = cells.iter().map(|x| x.1).min().unwrap_or(0); + let c1 = cells.iter().map(|x| x.1).max().unwrap_or(0); + let g0 = t.grid_col(r0, c0); + let g1 = t.grid_col(r0, c1) + t.rows.get(r0).and_then(|r| r.cells.get(c1)).map(|c| c.span()).unwrap_or(1) - 1; + t.merge(r0, r1, g0, g1); + fix_caret(s, &tp, r0, c0) +} + +fn split_table(s: &mut Session, _: &Value) -> CmdResult { + let (tp, r, _) = cell(s)?; + if r == 0 { + return Err(CmdError::Failed("can't split at the first row".into())); + } + let story = s.sel.focus.story; + let t = s.doc.table_mut(story, &tp)?; + let tail_rows = t.rows.split_off(r); + let tail = Table { props: t.props.clone(), grid: t.grid.clone(), rows: tail_rows }; + let after = tp.with_last(tp.last() + 1); + s.doc.insert_block(story, &after, Block::Para(Paragraph::new()))?; + s.doc.insert_block(story, &after.with_last(after.last() + 1), Block::Table(tail))?; + s.sel = Selection::caret(Pos { story, path: after, off: 0 }); + sel_result(s) +} + +fn borders(s: &mut Session, v: &Value) -> CmdResult { + let kind = p::str(v, "kind").unwrap_or("all"); + let w = p::f32(v, "width").unwrap_or(0.5).clamp(0.25, 6.0); + let color = p::str(v, "color").and_then(Rgb::parse); + let b = Border { style: BorderStyle::Single, width: w, color, space: 0.0 }; + let none = Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 }; + with_table(s, |t| { + let mut cur = t.props.borders.unwrap_or_default(); + match kind { + "all" => cur = Borders::all(b), + "none" => cur = Borders::all(none), + "outside" => { + cur.top = Some(b); + cur.bottom = Some(b); + cur.left = Some(b); + cur.right = Some(b); + } + "inside" => { + cur.between = Some(b); + cur.inside_v = Some(b); + } + "top" => cur.top = Some(b), + "bottom" => cur.bottom = Some(b), + "left" => cur.left = Some(b), + "right" => cur.right = Some(b), + _ => {} + } + t.props.borders = Some(cur); + for r in &mut t.rows { + for c in &mut r.cells { + c.props.borders = None; + } + } + }) +} + +fn cell_text(c: &wordcraft_doc::Cell) -> String { + c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::>().join("\n") +} + +fn sort_table(s: &mut Session, v: &Value) -> CmdResult { + let col = p::u64(v, "column").unwrap_or(0) as usize; + let desc = p::bool(v, "descending").unwrap_or(false); + let header = p::bool(v, "header"); + with_table(s, |t| { + let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))); + let Some(body) = t.rows.get_mut(skip..) else { return }; + body.sort_by(|a, b| { + let ka = a.cells.get(col).map(cell_text).unwrap_or_default(); + let kb = b.cells.get(col).map(cell_text).unwrap_or_default(); + let o = match (ka.trim().replace(',', "").parse::(), kb.trim().replace(',', "").parse::()) { + (Ok(x), Ok(y)) => x.total_cmp(&y), + _ => ka.to_lowercase().cmp(&kb.to_lowercase()), + }; + if desc { o.reverse() } else { o } + }); + }) +} + +fn to_text(s: &mut Session, v: &Value) -> CmdResult { + let (tp, _, _) = cell(s)?; + let sep = match p::str(v, "separator").unwrap_or("tab") { + "comma" => ",", + "paragraph" => "\n", + _ => "\t", + }; + let story = s.sel.focus.story; + let t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?; + s.doc.remove_block(story, &tp)?; + let mut i = tp.last(); + for row in &t.rows { + let text: Vec = row.cells.iter().map(cell_text).collect(); + for line in text.join(sep).split('\n') { + s.doc.insert_block(story, &tp.with_last(i), Block::Para(Paragraph::with_text(line, Default::default())))?; + i += 1; + } + } + s.sel = Selection::caret(Pos { story, path: tp, off: 0 }); + sel_result(s) +} + +/// `=SUM(ABOVE)`, `=SUM(LEFT)`, `=AVERAGE(ABOVE)`, `=COUNT(...)`, `=MAX`, `=MIN`, `=PRODUCT`. +fn formula(s: &mut Session, v: &Value) -> CmdResult { + let (tp, r, c) = cell(s)?; + let f = p::str(v, "formula").unwrap_or("=SUM(ABOVE)").to_ascii_uppercase(); + let t = s.doc.table(s.sel.focus.story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?; + let nums: Vec = if f.contains("LEFT") { + t.rows.get(r).map(|row| row.cells.iter().take(c).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect()).unwrap_or_default() + } else { + t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect() + }; + let val = if f.starts_with("=AVERAGE") { + if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 } + } else if f.starts_with("=COUNT") { + nums.len() as f64 + } else if f.starts_with("=MAX") { + nums.iter().copied().fold(f64::MIN, f64::max) + } else if f.starts_with("=MIN") { + nums.iter().copied().fold(f64::MAX, f64::min) + } else if f.starts_with("=PRODUCT") { + nums.iter().product() + } else { + nums.iter().sum() + }; + let text = if val.fract() == 0.0 && val.abs() < 1e15 { format!("{}", val as i64) } else { format!("{val:.2}") }; + let props = s.typing_props(); + let at = s.sel.focus.clone(); + let end = s.doc.insert_object(&at, wordcraft_doc::InlineObject::Field { instr: f.clone(), result: text.clone(), locked: false }, &props)?; + s.sel = Selection::caret(end); + Ok(json!({"result": text})) +} + +fn select_cells(s: &mut Session, tp: &Path, which: Option<(usize, Option)>) -> CmdResult { + let story = s.sel.focus.story; + let Some(t) = s.doc.table(story, tp) else { return sel_result(s) }; + let (r0, r1, c0, c1) = match which { + None => (0, t.rows.len().saturating_sub(1), 0, usize::MAX), + Some((r, None)) => (r, r, 0, usize::MAX), + Some((r, Some(c))) => (r, r, c, c), + }; + let last_c = t.rows.get(r1).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0).min(c1); + let mut a = tp.0.clone(); + a.extend([r0 as u32, c0 as u32, 0]); + let mut b = tp.0.clone(); + b.extend([r1 as u32, last_c as u32]); + let end_path = s.doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&b)).last().unwrap_or_else(|| { + let mut x = b.clone(); + x.push(0); + Path(x) + }); + let off = s.doc.para(story, &end_path).map(|x| x.len()).unwrap_or(0); + s.sel = Selection { anchor: Pos { story, path: Path(a), off: 0 }, focus: Pos { story, path: end_path, off } }; + sel_result(s) +} + +fn from_text(s: &mut Session, v: &Value) -> CmdResult { + let sep = if p::str(v, "separator") == Some("comma") { ',' } else { '\t' }; + let (a, b) = s.sel.ordered(); + if a.path.parent() != b.path.parent() || a.story != b.story { + return Err(CmdError::Failed("select paragraphs to convert".into())); + } + let lines: Vec = (a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).map(|p| p.plain_text())).collect(); + let cols = lines.iter().map(|l| l.split(sep).count()).max().unwrap_or(1).clamp(1, 63); + let width = super::page::sect(s).text_width(); + let mut t = Table::new(lines.len().max(1), cols, width); + for (r, l) in lines.iter().enumerate() { + for (c, txt) in l.split(sep).enumerate() { + if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) { + cl.blocks = vec![para_block(Paragraph::with_text(txt.trim(), Default::default()))]; + } + } + } + for i in (a.path.last()..=b.path.last()).rev() { + s.doc.remove_block(a.story, &a.path.with_last(i))?; + } + s.doc.insert_block(a.story, &a.path, Block::Table(t))?; + let mut first = a.path.0.clone(); + first.extend([0, 0, 0]); + s.sel = Selection::caret(Pos { story: a.story, path: Path(first), off: 0 }); + sel_result(s) +} + +fn quick_table(s: &mut Session, v: &Value) -> CmdResult { + let kind = p::str(v, "kind").unwrap_or("tabular"); + let data: Vec> = match kind { + "calendar" => { + let mut rows = vec![vec!["M", "T", "W", "T", "F", "S", "S"]]; + let days = ["", "", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "", "", ""]; + for ch in days.chunks(7) { + rows.push(ch.to_vec()); + } + rows + } + "matrix" => vec![vec!["", "Column A", "Column B", "Column C"], vec!["Row 1", "1", "2", "3"], vec!["Row 2", "4", "5", "6"], vec!["Row 3", "7", "8", "9"]], + _ => vec![vec!["Item", "Needed"], vec!["Books", "1"], vec!["Magazines", "3"], vec!["Notebooks", "1"], vec!["Paper pads", "1"], vec!["Pens", "3"], vec!["Pencils", "2"]], + }; + let cols = data.iter().map(Vec::len).max().unwrap_or(1); + let width = super::page::sect(s).text_width(); + let mut t = Table::new(data.len(), cols, width); + t.props.style = Some(if kind == "calendar" { "GridTable1Light" } else { "GridTable4AccentBlue" }.into()); + for (r, row) in data.iter().enumerate() { + for (c, txt) in row.iter().enumerate() { + if let Some(cl) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) { + cl.blocks = vec![para_block(Paragraph::with_text(txt, Default::default()))]; + } + } + } + if let Some(r) = t.rows.first_mut() { + r.props.header = true; + } + let story = s.sel.focus.story; + let at = super::delete_selection(s)?; + let new = s.doc.split_paragraph(&at)?; + s.doc.insert_block(story, &new.path, Block::Table(t))?; + let mut first = new.path.0.clone(); + first.extend([0, 0, 0]); + s.sel = Selection::caret(Pos { story, path: Path(first), off: 0 }); + let _ = StoryRef::Body; + sel_result(s) +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs new file mode 100644 index 000000000..6f77cda37 --- /dev/null +++ b/crates/engine/src/cmd/text.rs @@ -0,0 +1,321 @@ +//! Typing and deleting. + +use serde_json::Value; +use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, PAGE_BREAK, SOFT_HYPHEN}; +use wordcraft_doc::props::NumRef; +use wordcraft_doc::{Block, ListKind, Pos}; + +use super::{delete_selection, sel_result, split_para, type_text}; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string}"#), + CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"), + CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"), + CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"), + CommandSpec::new("text.columnBreak", "Column Break", "Layout › Breaks", |s, _| ins_char(s, COLUMN_BREAK)).key("Mod+Shift+Enter"), + CommandSpec::new("text.tab", "Tab", "Editing", tab).key("Tab"), + CommandSpec::new("text.backTab", "Shift+Tab", "Editing", back_tab).key("Shift+Tab"), + CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"), + CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"), + CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"), + CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"), + CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"), + CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"), + CommandSpec::new("text.optionalHyphen", "Optional Hyphen", "Insert › Symbols", |s, _| ins_char(s, SOFT_HYPHEN)).key("Mod+-"), + ] +} + +/// Smart quotes and AutoFormat-as-you-type (lists from "* " / "1. ", dashes). +fn autoformat(s: &mut Session, text: &str) -> Option { + if text.chars().count() != 1 { + return None; + } + let c = text.chars().next()?; + let f = &s.sel.focus; + let para = s.doc.para_at(f)?; + let before = para.text.get(..f.off)?; + let prev = before.chars().next_back(); + let opening = prev.is_none_or(|p| p.is_whitespace() || "([{<\u{2014}\u{2013}".contains(p)); + match c { + '"' => Some(if opening { "\u{201C}" } else { "\u{201D}" }.into()), + '\'' => Some(if opening { "\u{2018}" } else { "\u{2019}" }.into()), + _ => None, + } +} + +fn insert(s: &mut Session, v: &Value) -> CmdResult { + let text = p::req_str(v, "text")?; + let raw = p::bool(v, "raw").unwrap_or(false); + let t = if raw { None } else { autoformat(s, text) }; + type_text(s, t.as_deref().unwrap_or(text))?; + if !raw && text == " " { + list_autoformat(s)?; + dash_autoformat(s)?; + } + sel_result(s) +} + +/// "* " / "- " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph. +fn list_autoformat(s: &mut Session) -> Result<(), CmdError> { + let f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return Ok(()) }; + if para.props.numbering.is_some_and(|n| n.num != 0) || f.off != para.len() { + return Ok(()); + } + let kind = match para.text.as_str() { + "* " | "- " | "> " => ListKind::Bullet, + "1. " => ListKind::Numbered, + "1) " => ListKind::NumberedParen, + "a) " => ListKind::LowerLetter, + "A. " => ListKind::UpperLetter, + "i. " => ListKind::LowerRoman, + _ => return Ok(()), + }; + let num = s.doc.numbering.find_kind(kind).unwrap_or_else(|| s.doc.numbering.add_list(kind)); + let para = s.doc.para_mut(f.story, &f.path)?; + let len = para.len(); + para.delete(0, len)?; + para.props.numbering = Some(NumRef { num, level: 0 }); + para.props.style = Some("ListParagraph".into()); + s.sel = Selection::caret(Pos { off: 0, ..f }); + Ok(()) +} + +/// "word -- word" → en dash, "word--word" → em dash (on the space after). +fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { + let f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return Ok(()) }; + let Some(before) = para.text.get(..f.off) else { return Ok(()) }; + if before.ends_with(' ') + && let Some(i) = before.trim_end().rfind(" - ") + && !before.trim_end()[i + 3..].contains(' ') + { + let p = s.doc.para_mut(f.story, &f.path)?; + let props = p.props_at(i + 1).clone(); + p.delete(i + 1, i + 2)?; + p.insert_text(i + 1, "\u{2013}", &props)?; + s.sel = Selection::caret(Pos { off: f.off + 2, ..f }); + } else if before.ends_with(' ') + && let Some(i) = before.trim_end().rfind("--") + && i > 0 + && before.trim_end().get(i - 1..i).is_some_and(|c| c != " ") + { + let p = s.doc.para_mut(f.story, &f.path)?; + let props = p.props_at(i).clone(); + p.delete(i, i + 2)?; + p.insert_text(i, "\u{2014}", &props)?; + s.sel = Selection::caret(Pos { off: f.off + 1, ..f }); + } + Ok(()) +} + +fn ins_char(s: &mut Session, c: char) -> CmdResult { + let mut b = [0u8; 4]; + type_text(s, c.encode_utf8(&mut b))?; + sel_result(s) +} + +fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { + let at = delete_selection(s)?; + let new = split_para(s, &at)?; + s.sel = Selection::caret(new); + s.goal_x = None; + sel_result(s) +} + +/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab. +fn tab(s: &mut Session, _: &Value) -> CmdResult { + if let Some((tpath, r, c)) = s.sel.focus.path.cell() { + let story = s.sel.focus.story; + let Some(t) = s.doc.table(story, &tpath) else { return ins_char(s, '\t') }; + let row_len = t.rows.get(r).map(|x| x.cells.len()).unwrap_or(0); + let (nr, nc) = if c + 1 < row_len { (r, c + 1) } else { (r + 1, 0) }; + if nr >= t.rows.len() { + let t = s.doc.table_mut(story, &tpath)?; + t.insert_row(nr, r); + } + let mut path = tpath.0.clone(); + path.extend([nr as u32, nc as u32, 0]); + let start = Pos { story, path: wordcraft_doc::Path(path), off: 0 }; + // Select the cell's content like Word. + let end_path = s.doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&start.path.0[..start.path.0.len() - 1])).last(); + let end = end_path.map(|q| { + let off = s.doc.para(story, &q).map(|x| x.len()).unwrap_or(0); + Pos { story, path: q, off } + }); + s.sel = Selection { anchor: start.clone(), focus: end.unwrap_or(start) }; + return sel_result(s); + } + let f = s.sel.focus.clone(); + if s.sel.is_collapsed() + && f.off == 0 + && let Some(n) = s.doc.para_at(&f).and_then(|x| x.props.numbering) + && n.num != 0 + { + let para = s.doc.para_mut(f.story, &f.path)?; + para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }); + para.touch(); + return sel_result(s); + } + if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path { + // Multiple paragraphs selected: increase indent. + return super::para::indent(s, &Value::Null); + } + ins_char(s, '\t') +} + +fn back_tab(s: &mut Session, _: &Value) -> CmdResult { + if let Some((tpath, r, c)) = s.sel.focus.path.cell() { + let story = s.sel.focus.story; + let (nr, nc) = if c > 0 { + (r, c - 1) + } else if r > 0 { + (r - 1, s.doc.table(story, &tpath).and_then(|t| t.rows.get(r - 1)).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0)) + } else { + return sel_result(s); + }; + let mut path = tpath.0.clone(); + path.extend([nr as u32, nc as u32, 0]); + s.sel = Selection::caret(Pos { story, path: wordcraft_doc::Path(path), off: 0 }); + return sel_result(s); + } + let f = s.sel.focus.clone(); + if let Some(n) = s.doc.para_at(&f).and_then(|x| x.props.numbering).filter(|n| n.num != 0) { + let para = s.doc.para_mut(f.story, &f.path)?; + para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_sub(1) }); + para.touch(); + return sel_result(s); + } + super::para::outdent(s, &Value::Null) +} + +fn backspace(s: &mut Session, _: &Value) -> CmdResult { + s.goal_x = None; + s.pending = None; + if !s.sel.is_collapsed() { + delete_selection(s)?; + return sel_result(s); + } + let f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return sel_result(s) }; + if f.off > 0 { + let a = Pos { off: para.prev_boundary(f.off), ..f.clone() }; + s.sel = Selection { anchor: a, focus: f }; + delete_selection(s)?; + return sel_result(s); + } + // At the start of a paragraph. + if para.props.numbering.is_some_and(|n| n.num != 0) { + let p = s.doc.para_mut(f.story, &f.path)?; + p.props.numbering = Some(NumRef { num: 0, level: 0 }); + p.touch(); + return sel_result(s); + } + if para.props.indent_first.is_some_and(|x| x > 0.0) { + let p = s.doc.para_mut(f.story, &f.path)?; + p.props.indent_first = Some(0.0); + p.touch(); + return sel_result(s); + } + let i = f.path.last(); + if i == 0 { + return sel_result(s); // start of a story or cell + } + let prev_path = f.path.with_last(i - 1); + match s.doc.block(f.story, &prev_path) { + Some(Block::Para(prev)) => { + let a = Pos { story: f.story, path: prev_path, off: prev.len() }; + // An empty paragraph before a non-empty one takes the latter's formatting (Word). + if s.doc.settings.track_changes { + s.sel = Selection::caret(a); + return sel_result(s); + } + let at = s.doc.delete_range(&a, &f)?; + s.sel = Selection::caret(at); + } + Some(Block::Table(_)) => { + // Move into the table's last cell. + let last = s.doc.para_paths(f.story).into_iter().filter(|q| q.0.first() == prev_path.0.first() && q.0.len() > 1).last(); + if let Some(q) = last { + let off = s.doc.para(f.story, &q).map(|x| x.len()).unwrap_or(0); + if s.doc.para_at(&f).is_some_and(|x| x.is_empty()) && s.doc.container(f.story, &f.path).is_some_and(|c| c.len() > i + 1) { + s.doc.remove_block(f.story, &f.path)?; + } + s.sel = Selection::caret(Pos { story: f.story, path: q, off }); + } + } + None => {} + } + sel_result(s) +} + +fn delete(s: &mut Session, _: &Value) -> CmdResult { + s.goal_x = None; + s.pending = None; + if !s.sel.is_collapsed() { + delete_selection(s)?; + return sel_result(s); + } + let f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return sel_result(s) }; + if f.off < para.len() { + let b = Pos { off: para.next_boundary(f.off), ..f.clone() }; + s.sel = Selection { anchor: f, focus: b }; + delete_selection(s)?; + return sel_result(s); + } + // At the end: join with the next paragraph in the same container. + let next = f.path.with_last(f.path.last() + 1); + if let Some(Block::Para(_)) = s.doc.block(f.story, &next) { + if s.doc.settings.track_changes { + return sel_result(s); + } + let b = Pos { story: f.story, path: next, off: 0 }; + let at = s.doc.delete_range(&f, &b)?; + s.sel = Selection::caret(at); + } else if let Some(Block::Table(_)) = s.doc.block(f.story, &next) { + // An empty paragraph before a table is removed; else nothing. + if para.is_empty() && f.path.last() > 0 { + s.doc.remove_block(f.story, &f.path)?; + let mut path = next.0.clone(); + if let Some(l) = path.last_mut() { + *l -= 1; + } + path.extend([0, 0, 0]); + s.sel = Selection::caret(Pos { story: f.story, path: wordcraft_doc::Path(path), off: 0 }); + } + } + sel_result(s) +} + +fn delete_word_back(s: &mut Session, v: &Value) -> CmdResult { + if !s.sel.is_collapsed() { + return backspace(s, v); + } + let f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return sel_result(s) }; + if f.off == 0 { + return backspace(s, v); + } + let a = Pos { off: para.word_start(f.off), ..f.clone() }; + s.sel = Selection { anchor: a, focus: f }; + delete_selection(s)?; + sel_result(s) +} + +fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult { + if !s.sel.is_collapsed() { + return delete(s, v); + } + let f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return sel_result(s) }; + if f.off >= para.len() { + return delete(s, v); + } + let b = Pos { off: para.word_end(f.off), ..f.clone() }; + s.sel = Selection { anchor: f, focus: b }; + delete_selection(s)?; + sel_result(s) +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs new file mode 100644 index 000000000..d0076b3bc --- /dev/null +++ b/crates/engine/src/cmd/view.rs @@ -0,0 +1,99 @@ +//! View tab and status bar: views, zoom, show/hide, panes. + +use serde_json::{Value, json}; +use wordcraft_layout::ViewMode; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +fn toggle(s: &mut Session, v: &Value, f: fn(&mut crate::ViewState) -> &mut bool) -> CmdResult { + let cur = *f(&mut s.view); + *f(&mut s.view) = p::bool(v, "value").unwrap_or(!cur); + Ok(json!({"value": *f(&mut s.view)})) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("view.marks", "Show/Hide ¶", "Home › Paragraph", |s, v| { + let r = toggle(s, v, |x| &mut x.marks); + s.relayout(); + r + }) + .key("Mod+Shift+8 / Mod+8") + .pure(), + CommandSpec::new("view.printLayout", "Print Layout", "View › Views", |s, _| mode(s, ViewMode::Print, false)).pure(), + CommandSpec::new("view.webLayout", "Web Layout", "View › Views", |s, _| mode(s, ViewMode::Web, false)).pure(), + CommandSpec::new("view.draft", "Draft", "View › Views", |s, _| mode(s, ViewMode::Draft, false)).pure(), + CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| { + mode(s, ViewMode::Draft, false)?; + s.view.nav_pane = true; + sel_result(s) + }) + .pure(), + CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(), + CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(), + CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(), + CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)).pure(), + CommandSpec::new("view.navigationPane", "Navigation Pane", "View › Show", |s, v| toggle(s, v, |x| &mut x.nav_pane)).pure(), + CommandSpec::new("view.zoom", "Zoom", "View › Zoom", zoom).params(r#"{"value": percent (10-500) | "pageWidth" | "onePage" | "multiplePages"}"#).pure(), + CommandSpec::new("view.zoom100", "100%", "View › Zoom", |s, _| zoom(s, &json!({"value": 100}))).pure(), + CommandSpec::new("view.zoomIn", "Zoom In", "Status Bar", |s, _| { + let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0); + zoom(s, &json!({"value": z * 100.0})) + }) + .key("Mod+Shift+=") + .pure(), + CommandSpec::new("view.zoomOut", "Zoom Out", "Status Bar", |s, _| { + let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1); + zoom(s, &json!({"value": z * 100.0})) + }) + .key("Mod+-") + .pure(), + CommandSpec::new("view.onePage", "One Page", "View › Zoom", |s, _| zoom(s, &json!({"value": "onePage"}))).pure(), + CommandSpec::new("view.multiplePages", "Multiple Pages", "View › Zoom", |s, _| zoom(s, &json!({"value": "multiplePages"}))).pure(), + CommandSpec::new("view.pageWidth", "Page Width", "View › Zoom", |s, _| zoom(s, &json!({"value": "pageWidth"}))).pure(), + CommandSpec::new("view.darkMode", "Switch Modes", "View › Dark Mode", |s, v| toggle(s, v, |x| &mut x.dark_mode)).pure(), + CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(), + CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(), + CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| { + s.ui_requests.push(json!({"open": "newWindow"})); + sel_result(s) + }) + .pure(), + CommandSpec::new("view.split", "Split", "View › Window", |s, _| { + s.ui_requests.push(json!({"toggle": "split"})); + sel_result(s) + }) + .pure(), + CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string()))).pure(), + ] +} + +fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult { + s.view.mode = m; + s.view.read_mode = read; + s.relayout(); + Ok(json!({"mode": m, "readMode": read})) +} + +fn zoom(s: &mut Session, v: &Value) -> CmdResult { + match v.get("value") { + Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => { + s.view.fit = f.clone(); + s.view.multi_page = f == "multiplePages"; + } + Some(x) => { + let pct = x.as_f64().ok_or_else(|| CmdError::Params("zoom percent or fit mode".into()))? as f32; + if !(10.0..=500.0).contains(&pct) { + return Err(CmdError::Params("zoom must be 10–500%".into())); + } + s.view.zoom = pct / 100.0; + s.view.fit.clear(); + s.view.multi_page = false; + } + None => { + s.ui_requests.push(json!({"open": "zoom"})); + } + } + Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit})) +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs new file mode 100644 index 000000000..fa9ef4086 --- /dev/null +++ b/crates/engine/src/io.rs @@ -0,0 +1,86 @@ +//! File format dispatch by extension. Format crates plug in here. + +use wordcraft_doc::Document; + +/// Formats WordCraft opens. +pub const OPEN_EXTS: &[&str] = &["docx", "docm", "dotx", "txt", "md", "markdown", "html", "htm", "rtf", "odt", "wcraft.json", "json"]; +/// Formats WordCraft saves (Save As). +pub const SAVE_EXTS: &[&str] = &["docx", "pdf", "txt", "md", "html", "rtf", "odt", "png", "json"]; + +fn ext_of(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + lower.rsplit('.').next().unwrap_or("").to_string() +} + +/// Parse a document from bytes; `name` gives the format by extension. +pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { + let ext = ext_of(name); + let mut doc = match ext.as_str() { + "txt" | "text" | "" => Document::from_text(&decode_text(bytes)), + "json" => serde_json::from_slice::(bytes).map_err(|e| format!("{name}: {e}"))?, + other => match crate::io_ext::open(other, bytes) { + Some(r) => r?, + None => return Err(format!("{name}: unsupported format `.{other}`")), + }, + }; + doc.ensure_nonempty(); + Ok(doc) +} + +/// Serialise a document; `name` gives the format by extension. +pub fn save_bytes(name: &str, doc: &Document) -> Result, String> { + let ext = ext_of(name); + match ext.as_str() { + "txt" | "text" => Ok(doc.plain_text(wordcraft_doc::StoryRef::Body).replace('\n', "\r\n").into_bytes()), + "json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()), + other => match crate::io_ext::save(other, doc) { + Some(r) => r, + None => Err(format!("{name}: can't save as `.{other}`")), + }, + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn open_path(path: &std::path::Path) -> Result { + let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if meta.len() > 2 << 30 { + return Err(format!("{}: file is larger than 2 GB", path.display())); + } + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + open_bytes(&path.to_string_lossy(), &bytes) +} + +#[cfg(target_arch = "wasm32")] +pub fn open_path(path: &std::path::Path) -> Result { + Err(format!("{}: files are opened through the browser on the web", path.display())) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> { + let bytes = save_bytes(&path.to_string_lossy(), doc)?; + // Write atomically: temp file next to the target, then rename. + let tmp = path.with_extension(format!("{}.tmp", ext_of(&path.to_string_lossy()))); + std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?; + std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display())) +} + +#[cfg(target_arch = "wasm32")] +pub fn save_path(path: &std::path::Path, _doc: &Document) -> Result<(), String> { + Err(format!("{}: files are saved through the browser on the web", path.display())) +} + +/// UTF-8 (with or without BOM), UTF-16 with BOM, else Latin-1. +pub fn decode_text(b: &[u8]) -> String { + if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8_lossy(rest).into_owned(); + } + if b.len() >= 2 && (b[0] == 0xFF && b[1] == 0xFE || b[0] == 0xFE && b[1] == 0xFF) { + let le = b[0] == 0xFF; + let units: Vec = b[2..].chunks_exact(2).map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect(); + return String::from_utf16_lossy(&units); + } + match std::str::from_utf8(b) { + Ok(s) => s.to_string(), + Err(_) => b.iter().map(|c| *c as char).collect(), + } +} diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs new file mode 100644 index 000000000..0c0a6b35e --- /dev/null +++ b/crates/engine/src/io_ext.rs @@ -0,0 +1,26 @@ +//! Format crates registered with the I/O dispatcher. + +use wordcraft_doc::Document; + +/// Open a format other than plain text / JSON. +pub fn open(ext: &str, _bytes: &[u8]) -> Option> { + match ext { + _ => None, + } +} + +/// Save to a format other than plain text / JSON. +pub fn save(ext: &str, doc: &Document) -> Option, String>> { + match ext { + "png" => Some(render_png(doc, 0, 2.0)), + _ => None, + } +} + +/// Render page `page` as PNG at `scale` px/pt. +pub fn render_png(doc: &Document, page: usize, scale: f32) -> Result, String> { + let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &Default::default()); + let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; + let img = wordcraft_render::render_page(doc, p, scale, &Default::default()); + Ok(img.to_png()) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 000000000..458f538de --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,155 @@ +//! WordCraft engine: the editing session and its commands. +//! +//! **Everything is a command.** Each user-visible action is a [`CommandSpec`] with a stable id +//! (`format.bold`, `insert.table`), a label, the ribbon/menu location, a default shortcut, a +//! params description and a `run` function. Menus, ribbon, shortcuts, the command palette, the +//! CLI, the JSON control channel and the MCP server all call [`Session::run`]. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod catalog; +pub mod cmd; +pub mod io; +mod io_ext; +pub mod sample; +mod session; + +use std::collections::HashMap; + +use serde_json::Value; + +pub use session::{FindState, Selection, Session, ViewState}; +pub use wordcraft_doc as doc; +pub use wordcraft_layout as layout; +pub use wordcraft_render as render; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum CmdError { + #[error("unknown command `{0}`")] + Unknown(String), + #[error("bad parameters: {0}")] + Params(String), + #[error("not available: {0}")] + Disabled(String), + #[error("{0}")] + Failed(String), +} + +impl From for CmdError { + fn from(e: wordcraft_doc::DocError) -> Self { + CmdError::Failed(e.to_string()) + } +} + +pub type CmdResult = Result; + +/// A registered command. +#[derive(Clone)] +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Where it lives in the UI: `Home › Font`, `Insert › Tables`, `File`… + pub location: &'static str, + /// Default shortcut in a portable form (`Mod+B`, `Mod+Shift+>`, `F7`). `Mod` = Ctrl, or ⌘ on macOS. + pub shortcut: &'static str, + /// Parameter description for agents (`{"size": number}`). + pub params: &'static str, + pub mutates: bool, + /// `None` = enabled; `Some(reason)` = disabled. + pub enabled: fn(&Session) -> Option<&'static str>, + pub run: fn(&mut Session, &Value) -> CmdResult, +} + +fn always(_: &Session) -> Option<&'static str> { + None +} + +impl CommandSpec { + pub const fn new(id: &'static str, label: &'static str, location: &'static str, run: fn(&mut Session, &Value) -> CmdResult) -> Self { + CommandSpec { id, label, location, shortcut: "", params: "{}", mutates: true, enabled: always, run } + } + pub const fn key(mut self, s: &'static str) -> Self { + self.shortcut = s; + self + } + pub const fn params(mut self, p: &'static str) -> Self { + self.params = p; + self + } + /// Doesn't change the document (navigation, view, export). + pub const fn pure(mut self) -> Self { + self.mutates = false; + self + } + pub const fn when(mut self, f: fn(&Session) -> Option<&'static str>) -> Self { + self.enabled = f; + self + } +} + +/// All commands by id. +pub struct Registry { + specs: Vec, + by_id: HashMap<&'static str, usize>, +} + +impl Registry { + pub fn new(specs: Vec) -> Self { + let mut by_id = HashMap::new(); + for (i, s) in specs.iter().enumerate() { + by_id.insert(s.id, i); + } + Registry { specs, by_id } + } + pub fn get(&self, id: &str) -> Option<&CommandSpec> { + self.by_id.get(id).and_then(|i| self.specs.get(*i)) + } + pub fn all(&self) -> &[CommandSpec] { + &self.specs + } + /// Command list as JSON (for agents: `commands` / `list_commands`). + pub fn describe(&self) -> Value { + Value::Array( + self.specs + .iter() + .map(|s| serde_json::json!({"id": s.id, "label": s.label, "location": s.location, "shortcut": s.shortcut, "params": s.params, "mutates": s.mutates})) + .collect(), + ) + } + /// The command bound to a shortcut, if any. + pub fn by_shortcut(&self, key: &str) -> Option<&CommandSpec> { + self.specs.iter().find(|s| !s.shortcut.is_empty() && s.shortcut.split(" / ").any(|k| k.eq_ignore_ascii_case(key))) + } +} + +/// Parameter helpers. +pub mod p { + use serde_json::Value; + + use crate::CmdError; + + pub fn str<'a>(v: &'a Value, k: &str) -> Option<&'a str> { + v.get(k).and_then(Value::as_str) + } + pub fn req_str<'a>(v: &'a Value, k: &str) -> Result<&'a str, CmdError> { + str(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (string) is required"))) + } + pub fn f32(v: &Value, k: &str) -> Option { + v.get(k).and_then(Value::as_f64).map(|x| x as f32).filter(|x| x.is_finite()) + } + pub fn req_f32(v: &Value, k: &str) -> Result { + f32(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (number) is required"))) + } + pub fn u64(v: &Value, k: &str) -> Option { + v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64))) + } + pub fn bool(v: &Value, k: &str) -> Option { + v.get(k).and_then(Value::as_bool) + } + /// The `value` param, or the first positional-ish string. + pub fn value<'a>(v: &'a Value) -> Option<&'a Value> { + v.get("value") + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs new file mode 100644 index 000000000..046bed94b --- /dev/null +++ b/crates/engine/src/sample.rs @@ -0,0 +1,195 @@ +//! Built-in sample documents and templates (original text, generated in code). + +use wordcraft_doc::para::InlineObject; +use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor}; +use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block}; + +fn para(text: &str) -> Paragraph { + Paragraph::with_text(text, CharProps::default()) +} + +fn styled(text: &str, style: &str) -> Paragraph { + para(text).styled(style) +} + +/// Append text with formatting to a paragraph. +fn add(p: &mut Paragraph, text: &str, props: CharProps) { + let at = p.len(); + let _ = p.insert_text(at, text, &props); +} + +fn bold() -> CharProps { + CharProps { bold: Some(true), ..Default::default() } +} +fn italic() -> CharProps { + CharProps { italic: Some(true), ..Default::default() } +} + +/// The showcase document: a short studio handbook with headings, lists, a table, a quote, +/// a footer with page numbers. +pub fn sample_document() -> Document { + let mut d = Document::new(); + d.core.title = "The Open Studio Handbook".into(); + d.core.creator = "WordCraft".into(); + let mut b: Vec = Vec::new(); + b.push(Block::Para(styled("The Open Studio Handbook", "Title"))); + b.push(Block::Para(styled("How a small community of makers shares space, tools and time", "Subtitle"))); + let mut intro = para("Every Thursday evening the old print shop on Alder Street fills with people who make things. "); + add(&mut intro, "Painters", bold()); + add(&mut intro, " set up by the north windows, ", CharProps::default()); + add(&mut intro, "letterpress", italic()); + add(&mut intro, " regulars claim the back room, and somebody always brings bread. This handbook collects what we have learned about running a shared studio: the routines that keep it pleasant, the tools we pool, and the small agreements that let thirty strangers become a crew.", CharProps::default()); + b.push(Block::Para(intro)); + b.push(Block::Para(styled("Why share a studio?", "Heading1"))); + b.push(Block::Para(para("Rent is the obvious reason, but it is not the best one. A shared room puts your work next to work you would never have seen otherwise. A ceramicist explains glaze chemistry to a comic artist; a photographer borrows a weaver's loom for a still life. The cross-talk is the point."))); + let mut quote = styled("A studio is a place where unfinished things are welcome.", "Quote"); + quote.props.space_before = Some(12.0); + b.push(Block::Para(quote)); + b.push(Block::Para(styled("What we agree on", "Heading2"))); + let mut num = d.numbering.clone(); + let bullets = num.add_list(ListKind::Bullet); + let steps = num.add_list(ListKind::Numbered); + d.numbering = num; + for (lead, rest) in [ + ("Clean as you go. ", "Leave a table the way you would like to find it."), + ("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."), + ("Ask before you borrow. ", "Even from the shelf, a quick note helps everyone keep track."), + ("Quiet hours ", "run from 9 to 11 on weekday mornings."), + ] { + let mut p = Paragraph::new().styled("ListParagraph"); + add(&mut p, lead, bold()); + add(&mut p, rest, CharProps::default()); + p.props.numbering = Some(NumRef { num: bullets, level: 0 }); + b.push(Block::Para(p)); + } + b.push(Block::Para(styled("Shared equipment", "Heading2"))); + b.push(Block::Para(para("The table below lists what the membership owns together and who keeps each item in working order."))); + let rows = [ + ["Equipment", "Location", "Steward", "Booking"], + ["Etching press", "Back room", "Mara O.", "Sign-up sheet"], + ["Kiln (cone 10)", "Yard shed", "Theo P.", "48 h notice"], + ["Large-format printer", "Office", "Ines R.", "Calendar"], + ["Light table", "North windows", "Anyone", "First come"], + ]; + let mut t = Table::new(rows.len(), 4, 468.0); + t.props.style = Some("GridTable4AccentBlue".into()); + for (r, row) in rows.iter().enumerate() { + for (c, txt) in row.iter().enumerate() { + if let Some(cell) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) { + cell.blocks = vec![para_block(para(txt))]; + } + } + } + if let Some(r) = t.rows.first_mut() { + r.props.header = true; + } + b.push(Block::Table(t)); + b.push(Block::Para(styled("Opening the studio", "Heading2"))); + for s in ["Switch on the main breaker by the door.", "Open both skylights unless it is raining.", "Start the kettle. This step is not optional.", "Check the booking sheet for the kiln and press."] { + let mut p = para(s).styled("ListParagraph"); + p.props.numbering = Some(NumRef { num: steps, level: 0 }); + b.push(Block::Para(p)); + } + b.push(Block::Para(styled("Membership", "Heading1"))); + let mut m = para("Membership is "); + add(&mut m, "pay what you can", CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), ..Default::default() }); + add(&mut m, ", reviewed every season. New members shadow a regular for their first two visits, and everyone takes one cleaning shift a month. If you have never made anything before, you are exactly who we hoped would show up.", CharProps::default()); + b.push(Block::Para(m)); + let mut h = para("Questions go to the front desk, or to the notice board by the sink. "); + add(&mut h, "Visit the community board", CharProps { style: Some("Hyperlink".into()), link: Some("https://getartcraft.com/".into()), ..Default::default() }); + add(&mut h, " for events.", CharProps::default()); + b.push(Block::Para(h)); + d.body = b.into_iter().map(std::sync::Arc::new).collect(); + // Footer: centred page number. + let mut f = Paragraph::new().styled("Footer"); + f.props.align = Some(Align::Center); + let _ = f.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default()); + let fid = d.add_part(PartKind::Footer, vec![para_block(f)]); + d.last_section.footers.default = Some(fid); + let mut hd = styled("The Open Studio Handbook\t\tSpring edition", "Header"); + hd.props.borders = Some(Borders { bottom: Some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0xBF, 0xBF, 0xBF)), space: 4.0 }), ..Default::default() }); + let hid = d.add_part(PartKind::Header, vec![para_block(hd)]); + d.last_section.headers.default = Some(hid); + d +} + +/// A block-style letter. +pub fn letter() -> Document { + let mut d = Document::new(); + let lines: Vec = vec![ + styled("Your Name", "Title"), + para("123 Your Street · City, ST 00000 · you@example.com"), + para(""), + para(&crate::cmd::insert::format_date("MMMM d, yyyy")), + para(""), + para("Recipient Name"), + para("Title, Company"), + para("Street Address"), + para(""), + para("Dear Recipient,"), + para("Start with a sentence that says why you are writing. Keep paragraphs short and specific, and close with what you would like to happen next."), + para("Thank you for your time."), + para("Sincerely,"), + para(""), + para("Your Name"), + ]; + d.body = lines.into_iter().map(para_block).collect(); + d +} + +/// A one-page résumé. +pub fn resume() -> Document { + let mut d = Document::new(); + let mut b: Vec = vec![Block::Para(styled("Alex Rivera", "Title")), Block::Para(para("Illustrator · alex@example.com · portfolio.example.com"))]; + for (head, items) in [ + ("Experience", vec![("Lead Illustrator, Harbor Books", "2021 – present"), ("Freelance Illustrator", "2016 – 2021")]), + ("Education", vec![("BFA Illustration, Coastal College of Art", "2016")]), + ("Skills", vec![("Ink, gouache, digital painting, book layout, lettering", "")]), + ] { + b.push(Block::Para(styled(head, "Heading1"))); + for (what, when) in items { + let mut p = para(what); + p.props.tabs = Some(vec![TabStop { pos: 468.0, align: TabAlign::Right, leader: TabLeader::None }]); + if !when.is_empty() { + add(&mut p, &format!("\t{when}"), italic()); + } + b.push(Block::Para(p)); + } + } + d.body = b.into_iter().map(std::sync::Arc::new).collect(); + d +} + +/// A report with a cover heading and sections. +pub fn report() -> Document { + let mut d = Document::new(); + let b: Vec = vec![ + Block::Para(styled("Report Title", "Title")), + Block::Para(styled("Subtitle or date", "Subtitle")), + Block::Para(styled("Summary", "Heading1")), + Block::Para(para("Summarise the findings in two or three sentences.")), + Block::Para(styled("Background", "Heading1")), + Block::Para(para("Describe the context and the question the report answers.")), + Block::Para(styled("Findings", "Heading1")), + Block::Para(styled("First finding", "Heading2")), + Block::Para(para("Explain the evidence.")), + Block::Para(styled("Recommendations", "Heading1")), + Block::Para(para("List what should happen next.")), + ]; + d.body = b.into_iter().map(std::sync::Arc::new).collect(); + d +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn samples_are_valid() { + for d in [sample_document(), letter(), resume(), report()] { + assert!(d.word_count() > 5); + let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default()); + assert!(!l.pages.is_empty()); + } + } +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 000000000..72e7db5d0 --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,343 @@ +//! The editing session: document, selection, undo/redo, layout, view state. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use wordcraft_doc::edit::Fragment; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Path, Pos, StoryRef}; +use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode}; + +use crate::{CmdError, Registry}; + +/// A selection: anchor (where it started) and focus (where the caret is). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Selection { + pub anchor: Pos, + pub focus: Pos, +} + +impl Selection { + pub fn caret(p: Pos) -> Self { + Selection { anchor: p.clone(), focus: p } + } + pub fn is_collapsed(&self) -> bool { + self.anchor == self.focus + } + /// (start, end) in document order. + pub fn ordered(&self) -> (Pos, Pos) { + if self.anchor <= self.focus { (self.anchor.clone(), self.focus.clone()) } else { (self.focus.clone(), self.anchor.clone()) } + } +} + +/// View state that commands can change (ribbon View tab, status bar). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ViewState { + pub zoom: f32, + pub mode: ViewMode, + pub read_mode: bool, + pub focus_mode: bool, + pub marks: bool, + pub ruler: bool, + pub gridlines: bool, + pub nav_pane: bool, + pub styles_pane: bool, + pub comments_pane: bool, + pub multi_page: bool, + /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. + pub fit: String, + pub web_width: f32, + pub dark_mode: bool, + pub show_markup: bool, + pub track_changes_pane: bool, +} + +impl Default for ViewState { + fn default() -> Self { + ViewState { + zoom: 1.0, + mode: ViewMode::Print, + read_mode: false, + focus_mode: false, + marks: false, + ruler: true, + gridlines: false, + nav_pane: false, + styles_pane: false, + comments_pane: false, + multi_page: false, + fit: String::new(), + web_width: 800.0, + dark_mode: false, + show_markup: true, + track_changes_pane: false, + } + } +} + +/// Find/replace state. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FindState { + pub query: String, + pub replace: String, + pub match_case: bool, + pub whole_word: bool, + pub regex: bool, + /// Matches from the last search: (start, end). + #[serde(skip)] + pub results: Vec<(Pos, Pos)>, + pub current: usize, +} + +struct Undo { + label: String, + doc: Document, + sel: Selection, +} + +/// An editing session. +pub struct Session { + pub doc: Document, + pub sel: Selection, + pub view: ViewState, + /// Formatting picked with a collapsed caret, applied to the next typed text. + pub pending: Option, + pub path: Option, + pub dirty: bool, + pub clipboard: Option, + /// Plain text mirror of the clipboard (for the system clipboard). + pub clipboard_text: String, + pub find: FindState, + /// Page x the caret tries to keep on Up/Down. + pub goal_x: Option, + /// Page the caret was last on (headers/footers repeat on many pages). + pub page_hint: usize, + pub registry: Arc, + /// Author name for comments and tracked changes. + pub author: String, + /// Format painter: copied formatting waiting to be applied (and whether it stays on). + pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>, + /// Last message for the status bar / agents. + pub status: String, + history: Vec, + redo: Vec, + /// Typing is coalesced into one undo step until something else happens. + typing_open: bool, + rev: u64, + cache: LayoutCache, + layout: Option<(u64, f32, ViewMode, Arc)>, + /// Requests from commands to the UI (open a dialog, scroll…), drained by the front end. + pub ui_requests: Vec, +} + +/// Maximum undo depth. +const MAX_UNDO: usize = 500; + +impl Session { + pub fn new(doc: Document) -> Self { + let start = doc.start_of(StoryRef::Body); + Session { + doc, + sel: Selection::caret(start), + view: ViewState::default(), + pending: None, + path: None, + dirty: false, + clipboard: None, + clipboard_text: String::new(), + find: FindState::default(), + goal_x: None, + page_hint: 0, + registry: Arc::new(crate::cmd::registry()), + author: "WordCraft User".into(), + painter: None, + status: String::new(), + history: Vec::new(), + redo: Vec::new(), + typing_open: false, + rev: 1, + cache: LayoutCache::new(), + layout: None, + ui_requests: Vec::new(), + } + } + + /// Document revision (bumped by every change). + pub fn rev(&self) -> u64 { + self.rev + } + pub fn touch(&mut self) { + self.rev = self.rev.wrapping_add(1); + self.dirty = true; + } + + /// The current layout (recomputed when the document or view changed). + pub fn layout(&mut self) -> Arc { + let ww = self.view.web_width; + if let Some((r, w, m, l)) = &self.layout + && *r == self.rev + && (*w == ww || self.view.mode == ViewMode::Print) + && *m == self.view.mode + { + return l.clone(); + } + let opts = LayoutOptions { view: self.view.mode, web_width: ww, show_hidden: self.view.marks }; + let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); + self.layout = Some((self.rev, ww, self.view.mode, l.clone())); + l + } + /// Invalidate the cached layout (fonts changed etc.). + pub fn relayout(&mut self) { + self.layout = None; + self.cache.clear(); + } + + /// Snapshot for undo before a change. + pub fn checkpoint(&mut self, label: &str) { + if label == "Typing" && self.typing_open { + return; + } + self.typing_open = label == "Typing"; + self.history.push(Undo { label: label.to_string(), doc: self.doc.clone(), sel: self.sel.clone() }); + if self.history.len() > MAX_UNDO { + self.history.remove(0); + } + self.redo.clear(); + } + /// Close an open typing group (caret moved, other command). + pub fn close_typing(&mut self) { + self.typing_open = false; + } + pub fn can_undo(&self) -> bool { + !self.history.is_empty() + } + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + pub fn undo_label(&self) -> Option<&str> { + self.history.last().map(|u| u.label.as_str()) + } + pub fn redo_label(&self) -> Option<&str> { + self.redo.last().map(|u| u.label.as_str()) + } + pub fn undo_labels(&self) -> Vec { + self.history.iter().rev().map(|u| u.label.clone()).collect() + } + pub fn undo(&mut self) -> bool { + self.typing_open = false; + let Some(u) = self.history.pop() else { return false }; + let cur = Undo { label: u.label.clone(), doc: std::mem::replace(&mut self.doc, u.doc), sel: std::mem::replace(&mut self.sel, u.sel) }; + self.redo.push(cur); + self.touch(); + true + } + pub fn redo(&mut self) -> bool { + self.typing_open = false; + let Some(u) = self.redo.pop() else { return false }; + let cur = Undo { label: u.label.clone(), doc: std::mem::replace(&mut self.doc, u.doc), sel: std::mem::replace(&mut self.sel, u.sel) }; + self.history.push(cur); + self.touch(); + true + } + /// Drop history (after open / new). + pub fn reset_history(&mut self) { + self.history.clear(); + self.redo.clear(); + self.typing_open = false; + } + + /// Replace the document (open/new). + pub fn set_document(&mut self, doc: Document) { + self.doc = doc; + self.doc.ensure_nonempty(); + self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); + self.pending = None; + self.reset_history(); + self.touch(); + self.dirty = false; + self.relayout(); + } + + /// Make the selection valid for the current document. + pub fn clamp_selection(&mut self) { + self.sel.anchor = self.doc.clamp(&self.sel.anchor); + self.sel.focus = self.doc.clamp(&self.sel.focus); + } + + /// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and + /// control channel). Mutating commands are undoable; a failed command leaves the document as + /// it was; a panic inside a command becomes an error. + pub fn run(&mut self, id: &str, params: &Value) -> Result { + let reg = self.registry.clone(); + let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; + if let Some(why) = (spec.enabled)(self) { + return Err(CmdError::Disabled(format!("{id}: {why}"))); + } + let before_doc = if spec.mutates { Some((self.doc.clone(), self.sel.clone(), self.history.len(), self.typing_open)) } else { None }; + if spec.mutates && spec.id != "text.insert" { + self.typing_open = false; + } + if spec.mutates { + let label = if spec.id == "text.insert" { "Typing" } else { spec.label }; + self.checkpoint(label); + } else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") { + // Non-mutating commands other than caret movement keep the typing group. + } else if spec.id.starts_with("caret.") { + self.typing_open = false; + } + let run = spec.run; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params))); + let result = match result { + Ok(r) => r, + Err(p) => { + let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + Err(CmdError::Failed(format!("{id} panicked: {msg}"))) + } + }; + match &result { + Ok(_) => { + if spec.mutates { + self.touch(); + self.doc.ensure_nonempty(); + } + self.clamp_selection(); + } + Err(e) => { + if let Some((d, s, h, t)) = before_doc { + self.doc = d; + self.sel = s; + self.history.truncate(h); + self.typing_open = t; + } + self.status = e.to_string(); + } + } + result + } + + /// The paragraph path the caret is in. + pub fn caret_path(&self) -> &Path { + &self.sel.focus.path + } + pub fn story(&self) -> StoryRef { + self.sel.focus.story + } + + /// Formatting typing would use at the caret. + pub fn typing_props(&self) -> CharProps { + if let Some(p) = &self.pending { + return p.clone(); + } + let f = &self.sel.focus; + self.doc.para_at(f).map(|p| p.props_at(f.off).clone()).unwrap_or_default() + } + + /// Selected plain text. + pub fn selected_text(&self) -> String { + let (a, b) = self.sel.ordered(); + self.doc.copy_range(&a, &b).plain_text() + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs new file mode 100644 index 000000000..a26c4e2ee --- /dev/null +++ b/crates/engine/src/tests.rs @@ -0,0 +1,284 @@ +use serde_json::json; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::{Session, cmd}; + +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn text(s: &Session) -> String { + s.doc.plain_text(StoryRef::Body) +} + +#[test] +fn every_command_has_unique_id() { + let reg = cmd::registry(); + let mut ids: Vec<&str> = reg.all().iter().map(|c| c.id).collect(); + let n = ids.len(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), n, "duplicate command ids"); + assert!(n > 150, "{n} commands"); +} + +#[test] +fn typing_enter_undo() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + run(&mut s, "text.insert", json!({"text": " world"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Second"})); + assert_eq!(text(&s), "Hello world\nSecond"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "Hello world\n"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "Hello world"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); + run(&mut s, "edit.redo", json!({})); + assert_eq!(text(&s), "Hello world"); +} + +#[test] +fn backspace_and_delete() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "abc"})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(text(&s), "ab"); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "cd"})); + run(&mut s, "caret.home", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(text(&s), "abcd"); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(text(&s), "bcd"); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "text.deleteWordBack", json!({})); + assert_eq!(text(&s), ""); +} + +#[test] +fn bold_toggles_selection_and_caret_word() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "make this bold"})); + run(&mut s, "select.text", json!({"text": "this"})); + run(&mut s, "format.bold", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props_of_char(5).bold, Some(true)); + assert_eq!(p.props_of_char(0).bold, None); + run(&mut s, "format.bold", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).bold, Some(false)); + // Caret inside a word formats the word. + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [0], "off": 11}})); + run(&mut s, "format.italic", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props_of_char(10).italic, Some(true)); + assert_eq!(p.props_of_char(13).italic, Some(true)); + assert_eq!(p.props_of_char(9).italic, None); +} + +#[test] +fn pending_format_applies_to_typing() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "a "})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "text.insert", json!({"text": "b"})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props_of_char(2).bold, Some(true)); + assert_eq!(p.props_of_char(0).bold, None); +} + +#[test] +fn styles_and_lists() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Title"})); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "text.newParagraph", json!({})); + // Heading's next style is Normal. + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props.style.as_deref(), Some("Normal")); + run(&mut s, "para.bullets", json!({})); + run(&mut s, "text.insert", json!({"text": "one"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "two"})); + let n1 = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap(); + let n2 = s.doc.para_at(&Pos::body(2, 0)).unwrap().props.numbering.unwrap(); + assert_eq!(n1.num, n2.num); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.newParagraph", json!({})); // empty item ends the list + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.map(|n| n.num), Some(0)); + // "1. " autoformat. + run(&mut s, "text.insert", json!({"text": "1."})); + run(&mut s, "text.insert", json!({"text": " "})); + assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_some_and(|n| n.num != 0)); +} + +#[test] +fn find_replace() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "cat dog cat\nCat bird"})); + let r = run(&mut s, "edit.find", json!({"text": "cat"})); + assert_eq!(r["count"], 3); + let r = run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "fox", "matchCase": true})); + assert_eq!(r["replaced"], 2); + assert_eq!(text(&s), "fox dog fox\nCat bird"); + let r = run(&mut s, "edit.find", json!({"text": "\\b\\w{3}\\b", "regex": true, "matchCase": false})); + assert_eq!(r["count"], 4); +} + +#[test] +fn clipboard_round_trip() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "alpha beta\ngamma"})); + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 1, "off": 2}})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard_text, "beta\nga"); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.paste", json!({})); + assert_eq!(text(&s), "alpha beta\ngammabeta\nga"); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.cut", json!({})); + assert_eq!(text(&s), ""); + run(&mut s, "edit.paste", json!({"text": "plain\ntext"})); + assert_eq!(text(&s), "plain\ntext"); +} + +#[test] +fn tables_commands() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 3})); + assert!(s.sel.focus.path.cell().is_some()); + run(&mut s, "text.insert", json!({"text": "A1"})); + run(&mut s, "text.tab", json!({})); + run(&mut s, "text.insert", json!({"text": "B1"})); + run(&mut s, "table.insertRowBelow", json!({})); + run(&mut s, "table.insertColumnRight", json!({})); + let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap(); + assert_eq!(t.rows.len(), 3); + assert_eq!(t.cols(), 4); + run(&mut s, "table.deleteTable", json!({})); + assert!(s.doc.body.iter().all(|b| b.as_table().is_none())); + assert!(s.run("table.merge", &json!({})).is_err()); +} + +#[test] +fn page_setup_and_breaks() { + let mut s = s(); + run(&mut s, "layout.orientation", json!({"value": "landscape"})); + assert!(s.doc.last_section.landscape); + run(&mut s, "layout.size", json!({"name": "A4"})); + assert!((s.doc.last_section.page_w - 841.89).abs() < 0.1); + run(&mut s, "layout.margins", json!({"preset": "narrow"})); + assert_eq!(s.doc.last_section.margin_left, 36.0); + run(&mut s, "text.insert", json!({"text": "one"})); + run(&mut s, "layout.break", json!({"kind": "nextPage"})); + run(&mut s, "text.insert", json!({"text": "two"})); + assert_eq!(s.doc.sections().len(), 2); + assert_eq!(s.layout().pages.len(), 2); + assert!(s.run("layout.margins", &json!({"left": 1000.0})).is_err()); +} + +#[test] +fn track_changes_and_accept() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "original"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.insert", json!({"text": " added"})); + run(&mut s, "select.text", json!({"text": "orig"})); + s.author = "Someone Else".into(); + run(&mut s, "text.delete", json!({})); + // Deleted text stays (marked) until accepted. + assert_eq!(text(&s), "original added"); + let ch = run(&mut s, "review.changes", json!({})); + assert_eq!(ch.as_array().unwrap().len(), 2); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "inal added"); +} + +#[test] +fn comments() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Some text here"})); + run(&mut s, "select.text", json!({"text": "text"})); + let r = run(&mut s, "review.newComment", json!({"text": "Nice"})); + let id = r["id"].as_u64().unwrap(); + let l = run(&mut s, "review.comments", json!({})); + assert_eq!(l[0]["text"], "Nice"); + assert_eq!(text(&s), "Some text here"); + run(&mut s, "review.deleteComment", json!({"id": id})); + assert!(s.doc.comments.is_empty()); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.len(), 0); +} + +#[test] +fn toc_and_fields() { + let mut s = Session::new(crate::sample::report()); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "references.toc", json!({})); + let t = text(&s); + assert!(t.contains("Contents"), "{t}"); + assert!(t.contains("Summary\t1"), "{t}"); + run(&mut s, "references.updateToc", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update"); +} + +#[test] +fn failed_command_leaves_document_unchanged() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "keep"})); + let before = text(&s); + assert!(s.run("format.size", &json!({"size": -3})).is_err()); + assert!(s.run("no.such", &json!({})).is_err()); + assert!(s.run("text.insert", &json!({})).is_err()); + assert_eq!(text(&s), before); +} + +#[test] +fn hostile_params_never_panic() { + let reg = cmd::registry(); + let junk = [json!(null), json!({}), json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}), json!({"value": -1e308, "rows": 1e9})]; + for spec in reg.all() { + if spec.id.starts_with("file.") || spec.id == "insert.picture" || spec.id == "insert.textFromFile" { + continue; + } + for j in &junk { + let mut s = Session::new(crate::sample::sample_document()); + let _ = s.run(spec.id, j); + s.clamp_selection(); + let _ = s.layout(); + } + } +} + +#[test] +fn inspect_reports_structure() { + let mut s = Session::new(crate::sample::sample_document()); + let r = run(&mut s, "document.inspect", json!({})); + assert!(r["pages"].as_u64().unwrap() >= 1); + assert!(r["blocks"].as_array().unwrap().iter().any(|b| b["type"] == "table")); + let f = run(&mut s, "format.state", json!({})); + assert_eq!(f["styleName"], "Title"); +} + +#[test] +fn caret_navigation() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "first line\nsecond line"})); + run(&mut s, "caret.down", json!({})); + assert_eq!(s.sel.focus.path.last(), 1); + run(&mut s, "caret.end", json!({})); + assert_eq!(s.sel.focus.off, 11); + run(&mut s, "caret.up", json!({"extend": true})); + assert_eq!(s.sel.focus.path.last(), 0); + assert!(!s.sel.is_collapsed()); + run(&mut s, "caret.wordLeft", json!({})); + run(&mut s, "caret.docEnd", json!({})); + assert_eq!(s.sel.focus, Pos::body(1, 11)); + run(&mut s, "caret.left", json!({})); + assert_eq!(s.sel.focus.off, 10); +}