mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-14 03:29:04 +00:00
M7.1: proofing — spelling with suggestions, grammar checks, squiggles, F7, right-click menu, thesaurus; web app; hyphenation dictionary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
435194f7c2
commit
d1a0ed39c0
26 files changed
+3339
-9
No files matched your search
@@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
|
||||
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
|
||||
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
|
||||
| `assets/app-icon/wordcraft-macos-512.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
|
||||
| `assets/app-icon/wordcraft.icns` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
|
||||
|
||||
@@ -20,6 +20,9 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
|
||||
Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the
|
||||
packages as OFL-<family>.txt. Builds without CRAFT_FONTS_DIR do not include them.
|
||||
- English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by
|
||||
Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
|
||||
(assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0.
|
||||
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
|
||||
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "wordcraft-web"
|
||||
description = "WordCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "wordcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
@@ -0,0 +1,10 @@
|
||||
# Build with: trunk build --release (from apps/wordcraft-web)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8771
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>WordCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="wordcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e6e6e6; }
|
||||
#wordcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#wordcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #555; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="wordcraft_canvas"></canvas>
|
||||
<div id="wordcraft_loading">Loading WordCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
//! WordCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`wordcraft_ui_egui::WordApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`).
|
||||
//!
|
||||
//! Differences from the desktop app: no TCP control channel; Open and Insert › Pictures use the
|
||||
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("wordcraft-web only runs in the browser: build it with `trunk build --release` in apps/wordcraft-web");
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{WordApp, Inbox, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "odt", "rtf", "txt", "md", "html", "htm", "json"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const CANVAS_ID: &str = "wordcraft_canvas";
|
||||
const LOADING_ID: &str = "wordcraft_loading";
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
|
||||
log::error!("no document");
|
||||
return;
|
||||
};
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
}
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
|
||||
let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
app.autosave = false;
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
Err(e) => el.set_inner_html(&format!("<p>WordCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
app: WordApp,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
let dialog = if purpose == "picture" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Pictures", IMAGE_EXTS)
|
||||
} else {
|
||||
rfd::AsyncFileDialog::new().add_filter("Documents", DOC_EXTS)
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
// Exports ask for a name; the browser decides where the download goes.
|
||||
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = download(name, bytes) {
|
||||
log::error!("download of {name} failed: {e}");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(mime_for(&name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(&name);
|
||||
a.style().set_property("display", "none").map_err(js)?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
body.append_child(&a).map_err(js)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoke after the click has been dispatched; the download keeps its own reference.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
web_sys::Url::revoke_object_url(&url).ok();
|
||||
});
|
||||
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
Some("odt") => "application/vnd.oasis.opendocument.text",
|
||||
Some("rtf") => "application/rtf",
|
||||
Some("html") => "text/html",
|
||||
Some("md" | "txt") => "text/plain",
|
||||
Some("json") => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ fn services() -> Services {
|
||||
d.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,7 @@ wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
wordcraft-render = { workspace = true }
|
||||
wordcraft-proof = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -39,6 +39,49 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("review.wordCount", "Word Count", "Review › Proofing", word_count).pure(),
|
||||
CommandSpec::new("review.changes", "Reviewing Pane", "Review › Tracking", list_changes).pure(),
|
||||
CommandSpec::new("review.spelling", "Spelling & Grammar", "Review › Proofing", next_issue).key("F7").pure(),
|
||||
CommandSpec::new("review.issues", "Proofing Issues", "Review › Proofing", all_issues).pure(),
|
||||
CommandSpec::new("review.suggestions", "Spelling Suggestions", "Review › Proofing", suggestions).params(r#"{"pos"?: Pos}"#).pure(),
|
||||
CommandSpec::new("review.addToDictionary", "Add to Dictionary", "Review › Proofing", |s, v| {
|
||||
let w = match p::str(v, "word") {
|
||||
Some(w) => w.to_string(),
|
||||
None => issue_at(s, &s.sel.focus.clone()).map(|(a, b, _)| s.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off).map(str::to_string)).unwrap_or_default()).unwrap_or_default(),
|
||||
};
|
||||
if w.is_empty() {
|
||||
return Err(CmdError::Params("no word".into()));
|
||||
}
|
||||
wordcraft_proof::add_word(&w);
|
||||
s.relayout();
|
||||
Ok(json!({"added": w}))
|
||||
})
|
||||
.params(r#"{"word"?: string}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("review.ignoreAll", "Ignore All", "Review › Proofing", |s, v| {
|
||||
let w = match p::str(v, "word") {
|
||||
Some(w) => w.to_string(),
|
||||
None => issue_at(s, &s.sel.focus.clone()).map(|(a, b, _)| s.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off).map(str::to_string)).unwrap_or_default()).unwrap_or_default(),
|
||||
};
|
||||
wordcraft_proof::add_word(&w);
|
||||
s.relayout();
|
||||
Ok(json!({"ignored": w}))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("review.applySuggestion", "Change", "Review › Proofing", |s, v| {
|
||||
let text = p::req_str(v, "text")?.to_string();
|
||||
let at = s.sel.focus.clone();
|
||||
if let Some((a, b, _)) = issue_at(s, &at) {
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
}
|
||||
super::type_text(s, &text)?;
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"text": string}"#),
|
||||
CommandSpec::new("review.proofing", "Check Spelling as You Type", "File › Options › Proofing", |s, v| {
|
||||
s.view.proofing = p::bool(v, "value").unwrap_or(!s.view.proofing);
|
||||
Ok(json!({"value": s.view.proofing}))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("review.thesaurus", "Thesaurus", "Review › Proofing", thesaurus).key("Shift+F7").pure(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -310,3 +353,115 @@ fn word_count(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let pages = s.layout().pages.len();
|
||||
Ok(json!({"pages": pages, "words": words, "characters": chars_no_spaces, "charactersWithSpaces": chars, "paragraphs": paragraphs, "lines": lines}))
|
||||
}
|
||||
|
||||
/// Issues (spelling + grammar) in one paragraph as positions.
|
||||
fn para_issues(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Vec<(Pos, Pos, wordcraft_proof::Issue)> {
|
||||
let Some(p) = s.doc.para(story, path) else { return Vec::new() };
|
||||
let text: String = p.text.chars().map(|c| if c == wordcraft_doc::para::OBJ { ' ' } else { c }).collect();
|
||||
let mut v: Vec<wordcraft_proof::Issue> = wordcraft_proof::check_spelling(&text);
|
||||
v.extend(wordcraft_proof::check_grammar(&text));
|
||||
v.sort_by_key(|i| i.start);
|
||||
v.into_iter()
|
||||
.filter(|i| !p.run_ranges().any(|(r, c)| r.start < i.end && i.start < r.end && (c.no_proof == Some(true) || c.link.is_some())))
|
||||
.map(|i| (Pos { story, path: path.clone(), off: i.start }, Pos { story, path: path.clone(), off: i.end }, i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The issue under a position.
|
||||
fn issue_at(s: &Session, at: &Pos) -> Option<(Pos, Pos, wordcraft_proof::Issue)> {
|
||||
para_issues(s, at.story, &at.path).into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off)
|
||||
}
|
||||
|
||||
fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Value {
|
||||
let word = s.doc.para_at(a).and_then(|p| p.text.get(a.off..b.off)).unwrap_or("").to_string();
|
||||
let sugg = if i.kind == wordcraft_proof::IssueKind::Spelling { wordcraft_proof::suggest(&word, 6) } else { i.suggestions.clone() };
|
||||
json!({"start": pos_json(a), "end": pos_json(b), "text": word, "kind": if i.kind == wordcraft_proof::IssueKind::Spelling { "spelling" } else { "grammar" }, "message": i.message, "suggestions": sugg})
|
||||
}
|
||||
|
||||
/// F7: select the next issue after the caret and return it with suggestions.
|
||||
fn next_issue(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let caret = s.sel.ordered().1;
|
||||
let story = caret.story;
|
||||
let paths = s.doc.para_paths(story);
|
||||
let start = paths.iter().position(|p| *p == caret.path).unwrap_or(0);
|
||||
for k in 0..paths.len() {
|
||||
let Some(path) = paths.get((start + k) % paths.len().max(1)) else { break };
|
||||
for (a, b, i) in para_issues(s, story, path) {
|
||||
if k == 0 && a.off < caret.off && start + k < paths.len() && paths.len() > 1 {
|
||||
continue;
|
||||
}
|
||||
let r = issue_json(s, &a, &b, &i);
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
s.status = "The spelling and grammar check is complete.".into();
|
||||
Ok(json!({"done": true}))
|
||||
}
|
||||
|
||||
fn all_issues(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let mut out = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
for (a, b, i) in para_issues(s, StoryRef::Body, &path) {
|
||||
out.push(issue_json(s, &a, &b, &i));
|
||||
if out.len() >= 500 {
|
||||
return Ok(Value::Array(out));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
fn suggestions(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let at = v.get("pos").and_then(super::parse_pos).unwrap_or_else(|| s.sel.focus.clone());
|
||||
match issue_at(s, &at) {
|
||||
Some((a, b, i)) => Ok(issue_json(s, &a, &b, &i)),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tiny built-in thesaurus of common words (our own list).
|
||||
fn thesaurus(s: &mut Session, v: &Value) -> CmdResult {
|
||||
const T: &[(&str, &[&str])] = &[
|
||||
("good", &["fine", "excellent", "decent", "solid", "worthy"]),
|
||||
("bad", &["poor", "inferior", "faulty", "unpleasant"]),
|
||||
("big", &["large", "huge", "sizable", "vast", "major"]),
|
||||
("small", &["little", "tiny", "compact", "minor", "modest"]),
|
||||
("fast", &["quick", "rapid", "swift", "speedy"]),
|
||||
("slow", &["unhurried", "gradual", "leisurely", "sluggish"]),
|
||||
("happy", &["glad", "cheerful", "content", "joyful", "pleased"]),
|
||||
("sad", &["unhappy", "sorrowful", "downcast", "gloomy"]),
|
||||
("important", &["significant", "key", "vital", "essential", "major"]),
|
||||
("show", &["display", "reveal", "present", "demonstrate"]),
|
||||
("make", &["create", "build", "produce", "craft", "form"]),
|
||||
("use", &["employ", "apply", "utilize", "operate"]),
|
||||
("help", &["assist", "aid", "support", "serve"]),
|
||||
("start", &["begin", "launch", "open", "initiate"]),
|
||||
("end", &["finish", "close", "conclude", "stop"]),
|
||||
("say", &["state", "tell", "remark", "mention"]),
|
||||
("think", &["believe", "consider", "reckon", "suppose"]),
|
||||
("new", &["fresh", "novel", "recent", "modern"]),
|
||||
("old", &["aged", "former", "ancient", "vintage"]),
|
||||
("beautiful", &["lovely", "attractive", "gorgeous", "stunning"]),
|
||||
("easy", &["simple", "effortless", "straightforward"]),
|
||||
("hard", &["difficult", "tough", "demanding", "firm"]),
|
||||
("work", &["labor", "effort", "job", "task", "operate"]),
|
||||
("idea", &["concept", "notion", "thought", "plan"]),
|
||||
("change", &["alter", "modify", "adjust", "shift"]),
|
||||
("great", &["excellent", "superb", "terrific", "considerable"]),
|
||||
("very", &["extremely", "highly", "truly", "really"]),
|
||||
("shared", &["common", "joint", "communal", "mutual"]),
|
||||
("community", &["group", "society", "collective", "circle"]),
|
||||
];
|
||||
let word = match p::str(v, "word") {
|
||||
Some(w) => w.to_lowercase(),
|
||||
None => {
|
||||
let f = s.sel.focus.clone();
|
||||
let p = s.doc.para_at(&f).ok_or_else(|| CmdError::Failed("no word".into()))?;
|
||||
let (a, b) = p.word_at(f.off);
|
||||
p.text.get(a..b).unwrap_or("").trim().to_lowercase()
|
||||
}
|
||||
};
|
||||
let syn = T.iter().find(|(w, _)| *w == word).map(|(_, l)| l.to_vec()).unwrap_or_default();
|
||||
Ok(json!({"word": word, "synonyms": syn}))
|
||||
}
|
||||
@@ -52,6 +52,8 @@ pub struct ViewState {
|
||||
pub dark_mode: bool,
|
||||
pub show_markup: bool,
|
||||
pub track_changes_pane: bool,
|
||||
/// Check spelling and grammar as you type.
|
||||
pub proofing: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
@@ -73,6 +75,7 @@ impl Default for ViewState {
|
||||
dark_mode: false,
|
||||
show_markup: true,
|
||||
track_changes_pane: false,
|
||||
proofing: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +131,7 @@ pub struct Session {
|
||||
typing_open: bool,
|
||||
rev: u64,
|
||||
cache: LayoutCache,
|
||||
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>)>,
|
||||
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>, bool)>,
|
||||
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
|
||||
pub ui_requests: Vec<Value>,
|
||||
}
|
||||
@@ -177,16 +180,17 @@ impl Session {
|
||||
/// The current layout (recomputed when the document or view changed).
|
||||
pub fn layout(&mut self) -> Arc<DocLayout> {
|
||||
let ww = self.view.web_width;
|
||||
if let Some((r, w, m, l)) = &self.layout
|
||||
if let Some((r, w, m, l, pf)) = &self.layout
|
||||
&& *r == self.rev
|
||||
&& (*w == ww || self.view.mode == ViewMode::Print)
|
||||
&& *m == self.view.mode
|
||||
&& *pf == self.view.proofing
|
||||
{
|
||||
return l.clone();
|
||||
}
|
||||
let opts = LayoutOptions { view: self.view.mode, web_width: ww, show_hidden: self.view.marks };
|
||||
let opts = LayoutOptions { view: self.view.mode, web_width: ww, show_hidden: self.view.marks, proofing: self.view.proofing };
|
||||
let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts));
|
||||
self.layout = Some((self.rev, ww, self.view.mode, l.clone()));
|
||||
self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing));
|
||||
l
|
||||
}
|
||||
/// Invalidate the cached layout (fonts changed etc.).
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-proof = { workspace = true }
|
||||
unicode-linebreak = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -270,6 +270,19 @@ fn lines(doc: &Document, story: StoryRef, path: &Path, pl: &ParaLayout, l0: usiz
|
||||
out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha });
|
||||
}
|
||||
}
|
||||
// Proofing squiggles.
|
||||
for (a, b, grammar) in &pl.issues {
|
||||
if *b <= line.start || *a >= line.stop {
|
||||
continue;
|
||||
}
|
||||
let (Some(x0), Some(x1)) = (pl.x_of(li, (*a).max(line.start)), pl.x_of(li, (*b).min(line.stop))) else { continue };
|
||||
if x1 - x0 < 1.0 {
|
||||
continue;
|
||||
}
|
||||
let y = base + 2.5;
|
||||
let color = if *grammar { Rgb(0x2B, 0x57, 0xC0) } else { Rgb(0xE0, 0x24, 0x24) };
|
||||
out.push(Draw::Line { x0: x + x0, y0: y, x1: x + x1, y1: y, width: 0.8, color, stroke: if *grammar { Stroke::Double } else { Stroke::Wave }, alpha });
|
||||
}
|
||||
// Tab leaders.
|
||||
for (k, leader) in &line.leaders {
|
||||
let (Some(c), Some(a), Some(b)) = (pl.clusters.get(*k), line.xs.get(k - line.c0), line.xs.get(k + 1 - line.c0)) else { continue };
|
||||
|
||||
@@ -46,6 +46,8 @@ pub struct LayoutOptions {
|
||||
/// Width of the window in Web/Draft views, points.
|
||||
pub web_width: f32,
|
||||
pub show_hidden: bool,
|
||||
/// Check spelling and grammar (squiggles).
|
||||
pub proofing: bool,
|
||||
}
|
||||
|
||||
/// Something placed on a page (page coordinates, points, y down).
|
||||
@@ -156,7 +158,7 @@ fn hash_of<T: Hash>(t: &T) -> u64 {
|
||||
|
||||
fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
|
||||
let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default();
|
||||
hash_of(&(s, opts.show_hidden))
|
||||
hash_of(&(s, opts.show_hidden, opts.proofing, wordcraft_proof::user_dictionary().len()))
|
||||
}
|
||||
|
||||
fn has_page_fields(p: &Paragraph) -> bool {
|
||||
@@ -199,7 +201,7 @@ impl Ctx<'_> {
|
||||
return pl.clone();
|
||||
}
|
||||
self.cache.misses += 1;
|
||||
let env = para::ParaEnv { doc: self.doc, width, label, fields: &self.fields, show_hidden: self.opts.show_hidden, table_chr };
|
||||
let env = para::ParaEnv { doc: self.doc, width, label, fields: &self.fields, show_hidden: self.opts.show_hidden, table_chr, proofing: self.opts.proofing };
|
||||
let pl = Arc::new(para::layout_para(p, &env));
|
||||
self.cache.paras.insert(key, pl.clone());
|
||||
pl
|
||||
@@ -580,7 +582,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
||||
Some(Block::Para(p)) => {
|
||||
// Don't advance list counters for a lookahead: lay out without the label.
|
||||
let rp = ctx.doc.styles.resolve_para(&p.props);
|
||||
let env = para::ParaEnv { doc: ctx.doc, width, label: None, fields: &ctx.fields, show_hidden: ctx.opts.show_hidden, table_chr: None };
|
||||
let env = para::ParaEnv { doc: ctx.doc, width, label: None, fields: &ctx.fields, show_hidden: ctx.opts.show_hidden, table_chr: None, proofing: false };
|
||||
let _ = rp;
|
||||
let pl = para::layout_para(p, &env);
|
||||
pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0)
|
||||
|
||||
@@ -130,6 +130,8 @@ pub struct ParaLayout {
|
||||
pub has_page_fields: bool,
|
||||
/// Note references (part id) in this paragraph, by cluster index.
|
||||
pub notes: Vec<(usize, u32)>,
|
||||
/// Proofing issues: (byte start, byte end, grammar?).
|
||||
pub issues: Vec<(usize, usize, bool)>,
|
||||
}
|
||||
|
||||
/// Inputs that change a paragraph's layout beyond its own content.
|
||||
@@ -142,6 +144,7 @@ pub struct ParaEnv<'a> {
|
||||
pub show_hidden: bool,
|
||||
/// Extra style applied to every run (table style conditional formatting), under direct formatting.
|
||||
pub table_chr: Option<&'a CharProps>,
|
||||
pub proofing: bool,
|
||||
}
|
||||
|
||||
fn style_key(rc: &ResolvedChar) -> String {
|
||||
@@ -461,6 +464,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
text_len: p.text.len(),
|
||||
has_page_fields,
|
||||
notes,
|
||||
issues: if env.proofing { proof_issues(p) } else { Vec::new() },
|
||||
};
|
||||
break_lines(&mut pl, env, mark_style, env.label.as_ref().map(|(_, l)| l.suffix));
|
||||
pl
|
||||
@@ -893,3 +897,15 @@ impl ParaLayout {
|
||||
(l.baseline - l.top, l.top + l.height - l.baseline)
|
||||
}
|
||||
}
|
||||
|
||||
/// Spelling and grammar issues in a paragraph (skipping "do not check" and hidden runs).
|
||||
fn proof_issues(p: &Paragraph) -> Vec<(usize, usize, bool)> {
|
||||
if p.text.trim().is_empty() || p.text.len() > 100_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
let text: String = p.text.chars().map(|c| if c == OBJ { ' ' } else { c }).collect();
|
||||
let skip = |a: usize, b: usize| p.run_ranges().any(|(r, c)| r.start < b && a < r.end && (c.no_proof == Some(true) || c.hidden == Some(true) || c.link.is_some()));
|
||||
let mut v: Vec<(usize, usize, bool)> = wordcraft_proof::check_spelling(&text).into_iter().filter(|i| !skip(i.start, i.end)).map(|i| (i.start, i.end, false)).collect();
|
||||
v.extend(wordcraft_proof::check_grammar(&text).into_iter().filter(|i| !skip(i.start, i.end)).map(|i| (i.start, i.end, true)));
|
||||
v
|
||||
}
|
||||
@@ -193,7 +193,7 @@ fn display_has_glyphs_and_marks() {
|
||||
fn web_view_is_one_page() {
|
||||
let d = Document::from_text(&"text ".repeat(3000));
|
||||
let mut c = LayoutCache::new();
|
||||
let l = layout(&d, &mut c, &LayoutOptions { view: ViewMode::Web, web_width: 800.0, show_hidden: false });
|
||||
let l = layout(&d, &mut c, &LayoutOptions { view: ViewMode::Web, web_width: 800.0, show_hidden: false, proofing: false });
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
assert!(l.pages[0].h > 800.0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "wordcraft-proof"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WordCraft proofing: spelling (public-domain word list), suggestions, grammar checks, hyphenation"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Hyphenation exception dictionary: lowercase words with their break points.
|
||||
//!
|
||||
//! On-disk format (`assets/hyphenation/en-us.dic`): raw-deflate of
|
||||
//! `"DCHYPH1\n"` followed by one line per word, sorted by the plain word's bytes. Each line is
|
||||
//! front-coded against the previous one: a byte with the shared prefix length (in bytes of the
|
||||
//! hyphenated form), then the rest of the hyphenated form (`-` at every break), then `\n`.
|
||||
|
||||
/// Sorted word list with a break bitmask per word (bit `i` = hyphen before char `i`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Dictionary {
|
||||
words: String,
|
||||
offs: Vec<u32>,
|
||||
masks: Vec<u64>,
|
||||
}
|
||||
|
||||
const MAGIC: &[u8] = b"DCHYPH1\n";
|
||||
|
||||
impl Dictionary {
|
||||
/// Build from `(lowercase word, break mask)` pairs (sorted and de-duplicated here; the first
|
||||
/// entry of a duplicate wins).
|
||||
pub fn from_entries(mut entries: Vec<(String, u64)>) -> Dictionary {
|
||||
entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
|
||||
entries.dedup_by(|b, a| a.0 == b.0);
|
||||
let mut d = Dictionary { words: String::new(), offs: vec![0], masks: Vec::with_capacity(entries.len()) };
|
||||
for (w, m) in entries {
|
||||
d.words.push_str(&w);
|
||||
d.offs.push(d.words.len() as u32);
|
||||
d.masks.push(m);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.masks.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.masks.is_empty()
|
||||
}
|
||||
|
||||
fn word(&self, i: usize) -> &str {
|
||||
&self.words[self.offs[i] as usize..self.offs[i + 1] as usize]
|
||||
}
|
||||
|
||||
/// Break mask of an exact (already lowercased) word.
|
||||
pub fn get(&self, word: &str) -> Option<u64> {
|
||||
let (mut lo, mut hi) = (0usize, self.len());
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
match self.word(mid).as_bytes().cmp(word.as_bytes()) {
|
||||
std::cmp::Ordering::Less => lo = mid + 1,
|
||||
std::cmp::Ordering::Greater => hi = mid,
|
||||
std::cmp::Ordering::Equal => return Some(self.masks[mid]),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Iterate `(word, mask)`.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, u64)> + '_ {
|
||||
(0..self.len()).map(|i| (self.word(i), self.masks[i]))
|
||||
}
|
||||
|
||||
/// Serialize (deflate-compressed).
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut raw = MAGIC.to_vec();
|
||||
let mut prev: Vec<u8> = Vec::new();
|
||||
for (w, m) in self.iter() {
|
||||
let h = hyphenated(w, m).into_bytes();
|
||||
let p = prev.iter().zip(&h).take_while(|(a, b)| a == b).count().min(255);
|
||||
raw.push(p as u8);
|
||||
raw.extend_from_slice(&h[p..]);
|
||||
raw.push(b'\n');
|
||||
prev = h;
|
||||
}
|
||||
miniz_oxide::deflate::compress_to_vec(&raw, 10)
|
||||
}
|
||||
|
||||
/// Deserialize data written by [`Dictionary::to_bytes`].
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Dictionary, String> {
|
||||
let raw = miniz_oxide::inflate::decompress_to_vec(data).map_err(|e| format!("hyphenation dictionary: inflate failed: {e:?}"))?;
|
||||
let body = raw.strip_prefix(MAGIC).ok_or("hyphenation dictionary: bad header")?;
|
||||
let mut d = Dictionary { words: String::with_capacity(body.len()), offs: vec![0], masks: Vec::new() };
|
||||
let mut cur: Vec<u8> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < body.len() {
|
||||
let p = body[i] as usize;
|
||||
let end = body[i + 1..].iter().position(|&b| b == b'\n').map(|e| i + 1 + e).ok_or("hyphenation dictionary: truncated")?;
|
||||
if p > cur.len() {
|
||||
return Err("hyphenation dictionary: bad prefix".into());
|
||||
}
|
||||
cur.truncate(p);
|
||||
cur.extend_from_slice(&body[i + 1..end]);
|
||||
i = end + 1;
|
||||
let s = std::str::from_utf8(&cur).map_err(|_| "hyphenation dictionary: bad utf-8")?;
|
||||
let (w, m) = parse_hyphenated(s);
|
||||
d.words.push_str(&w);
|
||||
d.offs.push(d.words.len() as u32);
|
||||
d.masks.push(m);
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
}
|
||||
|
||||
/// `word` with `-` before every char whose bit is set in `mask`.
|
||||
pub fn hyphenated(word: &str, mask: u64) -> String {
|
||||
let mut s = String::with_capacity(word.len() + 8);
|
||||
for (i, c) in word.chars().enumerate() {
|
||||
if i > 0 && i < 64 && mask & (1 << i) != 0 {
|
||||
s.push('-');
|
||||
}
|
||||
s.push(c);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Inverse of [`hyphenated`].
|
||||
pub fn parse_hyphenated(s: &str) -> (String, u64) {
|
||||
let mut w = String::with_capacity(s.len());
|
||||
let mut m = 0u64;
|
||||
let mut n = 0;
|
||||
for c in s.chars() {
|
||||
if c == '-' {
|
||||
if n > 0 && n < 64 {
|
||||
m |= 1 << n;
|
||||
}
|
||||
} else {
|
||||
w.push(c);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
(w, m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let e: Vec<(String, u64)> = ["ty-pog-ra-phy", "cat", "hap-pen", "o'clock", "naïve-té", "naïve"].iter().map(|s| parse_hyphenated(s)).collect();
|
||||
let d = Dictionary::from_entries(e);
|
||||
let back = Dictionary::from_bytes(&d.to_bytes()).unwrap();
|
||||
assert_eq!(back.len(), 6);
|
||||
assert_eq!(back.get("typography").map(|m| hyphenated("typography", m)), Some("ty-pog-ra-phy".to_string()));
|
||||
assert_eq!(back.get("cat"), Some(0));
|
||||
assert_eq!(back.get("naïveté").map(|m| hyphenated("naïveté", m)), Some("naïve-té".to_string()));
|
||||
assert!(back.get("dog").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! English hyphenation.
|
||||
//!
|
||||
//! Lookup order for each word:
|
||||
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
|
||||
//! by Grady Ward (~160k words), stored compactly in `assets/hyphenation/en-us.dic`; simple
|
||||
//! inflections (`'s`, `-s`, `-es`, `-ed`, `-d`) inherit the breaks of their stem;
|
||||
//! 2. **Liang patterns** ([`Patterns::en_us`]) for words not in the dictionary — our own pattern
|
||||
//! set, trained from the same list by DesignCraft's patgen (generated by DesignCraft's `hyphgen` example);
|
||||
//! 3. a small syllable **heuristic** for words outside the pattern alphabet.
|
||||
//!
|
||||
//! Then the paragraph's limits ([`Limits`]) are applied.
|
||||
|
||||
pub use crate::dict;
|
||||
|
||||
pub use crate::patterns;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use dict::Dictionary;
|
||||
pub use patterns::Patterns;
|
||||
|
||||
static EN_US_DIC: &[u8] = include_bytes!("../../../assets/hyphenation/en-us.dic");
|
||||
static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat");
|
||||
|
||||
impl Dictionary {
|
||||
/// The bundled US English dictionary (decoded on first use).
|
||||
pub fn en_us() -> &'static Dictionary {
|
||||
static D: OnceLock<Dictionary> = OnceLock::new();
|
||||
D.get_or_init(|| {
|
||||
if EN_US_DIC.is_empty() {
|
||||
return Dictionary::default();
|
||||
}
|
||||
Dictionary::from_bytes(EN_US_DIC).unwrap_or_else(|e| {
|
||||
log::error!("{e}");
|
||||
Dictionary::default()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Patterns {
|
||||
/// The bundled US English patterns (parsed on first use).
|
||||
pub fn en_us() -> &'static Patterns {
|
||||
static P: OnceLock<Patterns> = OnceLock::new();
|
||||
P.get_or_init(|| Patterns::parse(EN_US_PAT))
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyphenation limits from the paragraph's Hyphenation settings.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
pub min_word: usize,
|
||||
pub after_first: usize,
|
||||
pub before_last: usize,
|
||||
pub capitalized: bool,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits { min_word: 5, after_first: 2, before_last: 2, capitalized: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a word's break points came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
Dictionary,
|
||||
Patterns,
|
||||
Heuristic,
|
||||
None,
|
||||
}
|
||||
|
||||
fn is_apostrophe(c: char) -> bool {
|
||||
matches!(c, '\'' | '’' | 'ʼ')
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphabetic() || is_apostrophe(c)
|
||||
}
|
||||
|
||||
/// Unrestricted break points of one lowercase core (letters and `'` only, no leading/trailing `'`).
|
||||
fn core_breaks(core: &[char]) -> (Vec<usize>, Source) {
|
||||
let n = core.len();
|
||||
if n < 2 {
|
||||
return (vec![], Source::None);
|
||||
}
|
||||
let dict = Dictionary::en_us();
|
||||
let s: String = core.iter().collect();
|
||||
let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::<Vec<_>>();
|
||||
if let Some(m) = dict.get(&s) {
|
||||
return (from_mask(m, n), Source::Dictionary);
|
||||
}
|
||||
// Inflections inherit the stem's breaks (the suffix adds no break of its own).
|
||||
for suf in ["'s", "s", "es", "ed", "d"] {
|
||||
if let Some(stem) = s.strip_suffix(suf)
|
||||
&& stem.chars().count() >= 2
|
||||
&& let Some(m) = dict.get(stem)
|
||||
{
|
||||
return (from_mask(m, stem.chars().count()), Source::Dictionary);
|
||||
}
|
||||
}
|
||||
let pats = Patterns::en_us();
|
||||
if !pats.is_empty()
|
||||
&& let Some(p) = pats.points(core)
|
||||
{
|
||||
return (p, Source::Patterns);
|
||||
}
|
||||
(heuristic_points(core), Source::Heuristic)
|
||||
}
|
||||
|
||||
/// Unrestricted break points of `word` (char indices; a hyphen goes before that char), handling
|
||||
/// case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds (each letter run is
|
||||
/// hyphenated separately). No break is ever placed next to an apostrophe or non-letter.
|
||||
pub fn word_breaks(word: &str) -> (Vec<usize>, Source) {
|
||||
let chars: Vec<char> = word.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut src = Source::None;
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if !is_word_char(chars[i]) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i;
|
||||
while j < chars.len() && is_word_char(chars[j]) {
|
||||
j += 1;
|
||||
}
|
||||
let (mut a, mut b) = (i, j);
|
||||
while a < b && is_apostrophe(chars[a]) {
|
||||
a += 1;
|
||||
}
|
||||
while b > a && is_apostrophe(chars[b - 1]) {
|
||||
b -= 1;
|
||||
}
|
||||
let core: Vec<char> = chars[a..b].iter().map(|&c| if is_apostrophe(c) { '\'' } else { c.to_lowercase().next().unwrap_or(c) }).collect();
|
||||
let (pts, s) = core_breaks(&core);
|
||||
if src == Source::None {
|
||||
src = s;
|
||||
}
|
||||
out.extend(pts.into_iter().filter(|&p| p > 0 && p < core.len() && core[p - 1] != '\'' && core[p] != '\'').map(|p| a + p));
|
||||
i = j;
|
||||
}
|
||||
(out, src)
|
||||
}
|
||||
|
||||
/// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char), with
|
||||
/// the paragraph limits applied to each letter run.
|
||||
pub fn hyphen_points(word: &str, lim: &Limits) -> Vec<usize> {
|
||||
let chars: Vec<char> = word.chars().collect();
|
||||
let (pts, _) = word_breaks(word);
|
||||
if pts.is_empty() {
|
||||
return pts;
|
||||
}
|
||||
let min_before = lim.after_first.max(1);
|
||||
let min_after = lim.before_last.max(1);
|
||||
// Run bounds of each point's letter run (trimmed of apostrophes).
|
||||
let run_of = |p: usize| {
|
||||
let mut a = p;
|
||||
while a > 0 && is_word_char(chars[a - 1]) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut b = p;
|
||||
while b < chars.len() && is_word_char(chars[b]) {
|
||||
b += 1;
|
||||
}
|
||||
while a < b && is_apostrophe(chars[a]) {
|
||||
a += 1;
|
||||
}
|
||||
while b > a && is_apostrophe(chars[b - 1]) {
|
||||
b -= 1;
|
||||
}
|
||||
(a, b)
|
||||
};
|
||||
pts.into_iter()
|
||||
.filter(|&p| {
|
||||
let (a, b) = run_of(p);
|
||||
let run = &chars[a..b];
|
||||
let letters = run.iter().filter(|c| c.is_alphabetic()).count();
|
||||
if letters < lim.min_word.max(2) {
|
||||
return false;
|
||||
}
|
||||
if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) {
|
||||
return false;
|
||||
}
|
||||
if !lim.capitalized && run[0].is_uppercase() {
|
||||
return false;
|
||||
}
|
||||
let before = chars[a..p].iter().filter(|c| c.is_alphabetic()).count();
|
||||
let after = chars[p..b].iter().filter(|c| c.is_alphabetic()).count();
|
||||
before >= min_before && after >= min_after
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `word` with `-` at every hyphenation point (tests and diagnostics).
|
||||
pub fn hyphenate_word(word: &str, lim: &Limits) -> String {
|
||||
let pts = hyphen_points(word, lim);
|
||||
let mut s = String::with_capacity(word.len() + pts.len());
|
||||
for (i, ch) in word.chars().enumerate() {
|
||||
if pts.contains(&i) {
|
||||
s.push('-');
|
||||
}
|
||||
s.push(ch);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ---------- heuristic fallback (words outside the pattern alphabet) ----------
|
||||
|
||||
fn is_vowel(c: char) -> bool {
|
||||
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
|
||||
}
|
||||
|
||||
fn onset_pair(a: char, b: char) -> bool {
|
||||
let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
|
||||
matches!(
|
||||
(a, b),
|
||||
('c' | 's' | 't' | 'p' | 'w' | 'g', 'h')
|
||||
| ('b' | 'c' | 'd' | 'f' | 'g' | 'k' | 'p' | 't', 'r')
|
||||
| ('b' | 'c' | 'f' | 'g' | 'k' | 'p' | 's', 'l')
|
||||
| ('q', 'u')
|
||||
| ('s', 't' | 'p' | 'c' | 'k')
|
||||
)
|
||||
}
|
||||
|
||||
/// Syllable heuristic: V-CV ("ty-po"), VC-CV ("hap-pen"), keeping onset clusters ("gra-phy").
|
||||
fn heuristic_points(c: &[char]) -> Vec<usize> {
|
||||
let n = c.len();
|
||||
if n < 4 || !c.iter().all(|c| c.is_alphabetic()) {
|
||||
return vec![];
|
||||
}
|
||||
let v: Vec<bool> = c.iter().map(|&x| is_vowel(x)).collect();
|
||||
let mut out = vec![];
|
||||
for i in 1..n {
|
||||
let ok = if v[i - 1] && !v[i] {
|
||||
(i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2])
|
||||
} else if !v[i - 1] && !v[i] {
|
||||
i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] || (c[i - 1] == c[i] && i >= 2 && v[i - 2])
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count.
|
||||
let right: String = c[i..].iter().collect();
|
||||
let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right);
|
||||
if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn h(w: &str) -> String {
|
||||
hyphenate_word(w, &Limits::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_data_loads() {
|
||||
assert!(Dictionary::en_us().len() > 150_000, "{}", Dictionary::en_us().len());
|
||||
assert!(Patterns::en_us().len() > 1000, "{}", Patterns::en_us().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_words() {
|
||||
assert_eq!(h("happen"), "hap-pen");
|
||||
assert_eq!(h("typography"), "ty-pog-ra-phy");
|
||||
assert_eq!(h("composition"), "com-po-si-tion");
|
||||
assert_eq!(h("hyphenation"), "hy-phen-a-tion");
|
||||
assert_eq!(h("international"), "in-ter-na-tion-al");
|
||||
assert_eq!(h("cat"), "cat");
|
||||
assert!(!h("changed").contains('-'));
|
||||
assert_eq!(h("NASA"), "NASA");
|
||||
assert_eq!(word_breaks("publication").1, Source::Dictionary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_apostrophes_and_punctuation() {
|
||||
assert_eq!(h("Typography"), "Ty-pog-ra-phy");
|
||||
assert_eq!(h("TYPOGRAPHY"), "TYPOGRAPHY");
|
||||
assert_eq!(h("typography,"), "ty-pog-ra-phy,");
|
||||
assert_eq!(h("“typography”"), "“ty-pog-ra-phy”");
|
||||
assert_eq!(h("typography's"), "ty-pog-ra-phy's");
|
||||
assert_eq!(h("typography’s"), "ty-pog-ra-phy’s");
|
||||
assert_eq!(h("'typography'"), "'ty-pog-ra-phy'");
|
||||
// Compounds: each part separately, never next to the hyphen.
|
||||
assert_eq!(h("typography-composition"), "ty-pog-ra-phy-com-po-si-tion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflections_inherit_the_stem() {
|
||||
assert_eq!(h("ripples"), "rip-ples");
|
||||
assert_eq!(h("gradients"), "gra-di-ents");
|
||||
assert_eq!(h("restrained"), "re-strained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_cover_unknown_words() {
|
||||
// Not in the word list: the trained patterns decide.
|
||||
let (pts, src) = word_breaks("hyperlocalization");
|
||||
assert_eq!(src, Source::Patterns);
|
||||
assert!(pts.len() >= 4, "{}", h("hyperlocalization"));
|
||||
let (_, src) = word_breaks("zzyzx");
|
||||
assert_ne!(src, Source::Dictionary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_are_respected() {
|
||||
let lim = Limits { min_word: 5, after_first: 3, before_last: 3, capitalized: false };
|
||||
for w in ["typography", "hyphenation", "justification", "Paragraph", "beautiful"] {
|
||||
for p in hyphen_points(w, &lim) {
|
||||
assert!(p >= 3 && p <= w.chars().count() - 3, "{w} {p}");
|
||||
}
|
||||
}
|
||||
assert!(hyphen_points("Paragraph", &lim).is_empty());
|
||||
let lim = Limits { min_word: 12, ..Limits::default() };
|
||||
assert!(hyphen_points("typography", &lim).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_fallback() {
|
||||
let c: Vec<char> = "happen".chars().collect();
|
||||
assert_eq!(heuristic_points(&c), vec![3]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! WordCraft proofing.
|
||||
//!
|
||||
//! - **Spelling** against the public-domain Moby word list (~160k words, the same list that
|
||||
//! drives hyphenation), with inflections (`-s`, `-es`, `-ed`, `-ing`, `-ly`, `'s`…), a user
|
||||
//! dictionary and ignore lists; suggestions by edit distance.
|
||||
//! - **Grammar** checks that are cheap and reliable: repeated words, `a`/`an`, capital letter
|
||||
//! after a sentence end, spaces before punctuation, doubled spaces.
|
||||
//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns, heuristic.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod dict;
|
||||
pub mod hyphen;
|
||||
pub mod patterns;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
/// A problem found in text: byte range, kind, message and suggestions.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Issue {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub kind: IssueKind,
|
||||
pub message: String,
|
||||
pub suggestions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum IssueKind {
|
||||
Spelling,
|
||||
Grammar,
|
||||
}
|
||||
|
||||
fn user_words() -> &'static RwLock<HashSet<String>> {
|
||||
static U: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
|
||||
U.get_or_init(|| RwLock::new(HashSet::new()))
|
||||
}
|
||||
|
||||
/// Add a word to the user dictionary ("Add to Dictionary").
|
||||
pub fn add_word(w: &str) {
|
||||
user_words().write().unwrap_or_else(|e| e.into_inner()).insert(w.to_lowercase());
|
||||
}
|
||||
|
||||
/// Words in the user dictionary.
|
||||
pub fn user_dictionary() -> Vec<String> {
|
||||
let mut v: Vec<String> = user_words().read().unwrap_or_else(|e| e.into_inner()).iter().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Common words the source list lacks (modern vocabulary, contractions).
|
||||
const COMMON: &str = "a an the and or but if of to in on at by for with from as is am are was were be been being it its we us our you your he him his she her they them their i me my mine this that these those there here what which who whom whose when where why how all any both each few more most other some such no nor not only own same so than too very can will just should now do does did done has have had having may might must shall would could up down out over under again further then once off into onto upon about above below between through during before after until while because per via vs etc ok yes yeah oh ah hi hey bye one two three four five six seven eight nine ten first last next new old get got go goes went gone see saw seen say said make made take took come came give gave know knew think thought look want need use used find found tell told ask asked feel felt try left keep let put mean set run show turn move live play pay hear hold bring begin seem help talk start might";
|
||||
|
||||
const EXTRA: &[&str] = &[
|
||||
"don't", "doesn't", "didn't", "can't", "won't", "isn't", "aren't", "wasn't", "weren't", "shouldn't", "wouldn't", "couldn't", "i'm", "i've", "i'd", "i'll",
|
||||
"you're", "you've", "you'll", "you'd", "we're", "we've", "we'll", "they're", "they've", "they'll", "it's", "that's", "there's", "here's", "what's", "let's",
|
||||
"email", "emails", "online", "website", "websites", "internet", "software", "app", "apps", "smartphone", "blog", "login", "logout", "username", "wifi",
|
||||
"toolbar", "workflow", "workflows", "dataset", "datasets", "metadata", "startup", "startups", "covid", "selfie", "podcast", "podcasts", "hashtag",
|
||||
"wordcraft", "artcraft", "discord", "github", "rust", "ok", "okay", "unlabelled", "labelled", "colour", "colours", "favourite", "centre", "organise",
|
||||
];
|
||||
|
||||
fn known_core(w: &str) -> bool {
|
||||
let d = dict::Dictionary::en_us();
|
||||
if d.get(w).is_some() || EXTRA.contains(&w) || COMMON.split(' ').any(|c| c == w) {
|
||||
return true;
|
||||
}
|
||||
if user_words().read().unwrap_or_else(|e| e.into_inner()).contains(w) {
|
||||
return true;
|
||||
}
|
||||
// Inflections.
|
||||
let stems: [(&str, &[&str]); 12] = [
|
||||
("'s", &[""]),
|
||||
("s", &[""]),
|
||||
("es", &["", "e"]),
|
||||
("ies", &["y"]),
|
||||
("ed", &["", "e"]),
|
||||
("ied", &["y"]),
|
||||
("ing", &["", "e"]),
|
||||
("ly", &["", "le"]),
|
||||
("ily", &["y"]),
|
||||
("er", &["", "e"]),
|
||||
("est", &["", "e"]),
|
||||
("ness", &[""]),
|
||||
];
|
||||
for (suf, adds) in stems {
|
||||
if let Some(stem) = w.strip_suffix(suf)
|
||||
&& stem.len() >= 2
|
||||
{
|
||||
for a in adds {
|
||||
let s = format!("{stem}{a}");
|
||||
if d.get(&s).is_some() || EXTRA.contains(&s.as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Doubled consonant: running → run, stopped → stop.
|
||||
let b = stem.as_bytes();
|
||||
if (suf == "ing" || suf == "ed" || suf == "er" || suf == "est") && b.len() >= 3 && b.get(b.len() - 1) == b.get(b.len() - 2) {
|
||||
if let Some(s) = stem.get(..stem.len() - 1)
|
||||
&& d.get(s).is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prefixes.
|
||||
for pre in ["un", "re", "non", "pre", "over", "under", "co", "mis"] {
|
||||
if let Some(rest) = w.strip_prefix(pre)
|
||||
&& rest.len() >= 3
|
||||
&& d.get(rest).is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Is `word` spelled correctly? Numbers, single letters, ALL-CAPS acronyms, URLs and words with
|
||||
/// digits are accepted.
|
||||
pub fn is_correct(word: &str) -> bool {
|
||||
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'');
|
||||
let w = w.trim_matches('\'');
|
||||
if w.chars().count() <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
|
||||
return true;
|
||||
}
|
||||
if w.chars().all(|c| !c.is_lowercase()) && w.chars().count() <= 6 {
|
||||
return true;
|
||||
}
|
||||
if !w.is_ascii() && !w.chars().any(|c| c.is_ascii_alphabetic()) {
|
||||
return true; // other scripts: no dictionary
|
||||
}
|
||||
let lower = w.to_lowercase().replace('’', "'");
|
||||
if known_core(&lower) {
|
||||
return true;
|
||||
}
|
||||
// Hyphenated compounds: each part.
|
||||
if lower.contains('-') {
|
||||
return lower.split('-').all(|p| p.is_empty() || known_core(p));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Spelling suggestions for a misspelled word (best first, at most `max`).
|
||||
pub fn suggest(word: &str, max: usize) -> Vec<String> {
|
||||
let lower = word.to_lowercase();
|
||||
let n = lower.chars().count();
|
||||
if n == 0 || n > 40 {
|
||||
return Vec::new();
|
||||
}
|
||||
let d = dict::Dictionary::en_us();
|
||||
let mut scored: Vec<(usize, i64, String)> = Vec::new();
|
||||
let target: Vec<char> = lower.chars().collect();
|
||||
for (w, _) in d.iter() {
|
||||
let wn = w.chars().count();
|
||||
if wn + 2 < n || wn > n + 2 {
|
||||
continue;
|
||||
}
|
||||
let dist = damerau(&target, w);
|
||||
if dist <= 2 {
|
||||
let same_first = w.chars().next() == target.first().copied();
|
||||
scored.push((dist, if same_first { 0 } else { 1 }, w.to_string()));
|
||||
}
|
||||
}
|
||||
scored.sort_by(|a, b| (a.0, a.1, a.2.len().abs_diff(n)).cmp(&(b.0, b.1, b.2.len().abs_diff(n))).then(a.2.cmp(&b.2)));
|
||||
let cap = word.chars().next().is_some_and(char::is_uppercase);
|
||||
let upper = word.chars().all(|c| !c.is_lowercase());
|
||||
scored
|
||||
.into_iter()
|
||||
.take(max)
|
||||
.map(|(_, _, w)| {
|
||||
if upper && n > 1 {
|
||||
w.to_uppercase()
|
||||
} else if cap {
|
||||
let mut c = w.chars();
|
||||
c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or(w)
|
||||
} else {
|
||||
w
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Optimal string alignment distance, early-exit at 3.
|
||||
fn damerau(a: &[char], b: &str) -> usize {
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let (n, m) = (a.len(), b.len());
|
||||
let mut prev2 = vec![0usize; m + 1];
|
||||
let mut prev: Vec<usize> = (0..=m).collect();
|
||||
let mut cur = vec![0usize; m + 1];
|
||||
for i in 1..=n {
|
||||
if let Some(c) = cur.get_mut(0) {
|
||||
*c = i;
|
||||
}
|
||||
let mut row_min = i;
|
||||
for j in 1..=m {
|
||||
let (ai, bj) = (a.get(i - 1), b.get(j - 1));
|
||||
let cost = usize::from(ai != bj);
|
||||
let mut v = (prev.get(j).copied().unwrap_or(9) + 1).min(cur.get(j - 1).copied().unwrap_or(9) + 1).min(prev.get(j - 1).copied().unwrap_or(9) + cost);
|
||||
if i > 1 && j > 1 && a.get(i - 1) == b.get(j - 2) && a.get(i - 2) == b.get(j - 1) {
|
||||
v = v.min(prev2.get(j - 2).copied().unwrap_or(9) + 1);
|
||||
}
|
||||
if let Some(c) = cur.get_mut(j) {
|
||||
*c = v;
|
||||
}
|
||||
row_min = row_min.min(v);
|
||||
}
|
||||
if row_min > 2 {
|
||||
return 3;
|
||||
}
|
||||
std::mem::swap(&mut prev2, &mut prev);
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev.get(m).copied().unwrap_or(9)
|
||||
}
|
||||
|
||||
/// Words in `text`: (byte start, byte end).
|
||||
pub fn words(text: &str) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
let is_w = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
|
||||
for (i, c) in text.char_indices() {
|
||||
match (start, is_w(c)) {
|
||||
(None, true) => start = Some(i),
|
||||
(Some(s), false) => {
|
||||
out.push((s, i));
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
out.push((s, text.len()));
|
||||
}
|
||||
// Trim surrounding apostrophes/hyphens.
|
||||
out.into_iter()
|
||||
.filter_map(|(a, b)| {
|
||||
let w = text.get(a..b)?;
|
||||
let lead = w.len() - w.trim_start_matches(['\'', '’', '-']).len();
|
||||
let trail = w.len() - w.trim_end_matches(['\'', '’', '-']).len();
|
||||
(a + lead < b - trail).then_some((a + lead, b - trail))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Spelling issues in a paragraph of text (no suggestions; ask [`suggest`] on demand).
|
||||
pub fn check_spelling(text: &str) -> Vec<Issue> {
|
||||
let mut v = Vec::new();
|
||||
for (a, b) in words(text) {
|
||||
let Some(w) = text.get(a..b) else { continue };
|
||||
// Skip URLs/emails.
|
||||
let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0);
|
||||
let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len());
|
||||
let token = text.get(ts..te).unwrap_or("");
|
||||
if token.contains("://") || token.contains('@') || token.starts_with("www.") {
|
||||
continue;
|
||||
}
|
||||
if !is_correct(w) {
|
||||
v.push(Issue { start: a, end: b, kind: IssueKind::Spelling, message: "Possible spelling mistake".into(), suggestions: Vec::new() });
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Grammar issues in a paragraph of text.
|
||||
pub fn check_grammar(text: &str) -> Vec<Issue> {
|
||||
let mut v = Vec::new();
|
||||
let ws = words(text);
|
||||
for pair in ws.windows(2) {
|
||||
let [(a0, b0), (a1, b1)] = pair else { continue };
|
||||
let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue };
|
||||
if gap.trim().is_empty() && w0.eq_ignore_ascii_case(w1) && w0.chars().all(char::is_alphabetic) && !matches!(w0.to_lowercase().as_str(), "had" | "that" | "bye" | "so") {
|
||||
v.push(Issue { start: *a0, end: *b1, kind: IssueKind::Grammar, message: format!("Repeated word: \"{w1}\""), suggestions: vec![w0.to_string()] });
|
||||
}
|
||||
if gap == " " && (w0 == "a" || w0 == "A") && w1.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) && !starts_consonant_sound(w1) {
|
||||
v.push(Issue { start: *a0, end: *b0, kind: IssueKind::Grammar, message: "Use \"an\" before a vowel sound".into(), suggestions: vec![if w0 == "A" { "An" } else { "an" }.into()] });
|
||||
}
|
||||
if gap == " " && (w0 == "an" || w0 == "An") && w1.chars().next().is_some_and(|c| c.is_alphabetic() && !"aeiouAEIOUhH".contains(c)) && !w1.chars().all(|c| c.is_uppercase()) {
|
||||
v.push(Issue { start: *a0, end: *b0, kind: IssueKind::Grammar, message: "Use \"a\" before a consonant sound".into(), suggestions: vec![if w0 == "An" { "A" } else { "a" }.into()] });
|
||||
}
|
||||
}
|
||||
// Space before punctuation; doubled spaces.
|
||||
for (i, _) in text.match_indices(" ,").chain(text.match_indices(" .")).chain(text.match_indices(" ;")) {
|
||||
if text.get(i + 2..).is_some_and(|r| r.starts_with('.') || r.starts_with(',')) {
|
||||
continue; // "..." or numbers like " .5"
|
||||
}
|
||||
v.push(Issue { start: i, end: i + 2, kind: IssueKind::Grammar, message: "Remove the space before the punctuation".into(), suggestions: vec![text.get(i + 1..i + 2).unwrap_or("").to_string()] });
|
||||
}
|
||||
for (i, _) in text.match_indices(" ") {
|
||||
if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) {
|
||||
continue;
|
||||
}
|
||||
v.push(Issue { start: i, end: i + 2, kind: IssueKind::Grammar, message: "Extra space".into(), suggestions: vec![" ".into()] });
|
||||
}
|
||||
// Sentence start capital.
|
||||
let mut after_end = true;
|
||||
for (a, b) in &ws {
|
||||
let Some(w) = text.get(*a..*b) else { continue };
|
||||
if after_end && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) {
|
||||
let fixed: String = w.chars().next().map(|f| f.to_uppercase().chain(w.chars().skip(1)).collect()).unwrap_or_default();
|
||||
v.push(Issue { start: *a, end: *b, kind: IssueKind::Grammar, message: "Capitalize the first word of a sentence".into(), suggestions: vec![fixed] });
|
||||
}
|
||||
let tail = text.get(*b..).unwrap_or("");
|
||||
let next_non_space = tail.trim_start_matches(['"', '”', ')', '\'']).chars().next();
|
||||
after_end = matches!(next_non_space, Some('.' | '!' | '?')) && tail.trim_start_matches(['.', '!', '?', '"', '”', ')']).starts_with(' ');
|
||||
}
|
||||
v.sort_by_key(|i| i.start);
|
||||
v
|
||||
}
|
||||
|
||||
fn starts_consonant_sound(w: &str) -> bool {
|
||||
let l = w.to_lowercase();
|
||||
["uni", "use", "usu", "eu", "one", "once", "ubiq", "uti", "ure", "uro"].iter().any(|p| l.starts_with(p))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dictionary_loads() {
|
||||
assert!(dict::Dictionary::en_us().len() > 100_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spelling() {
|
||||
for w in ["if", "we", "hello", "world", "running", "studios", "don't", "Thursday", "NASA", "2026", "co-operate", "unhappy", "colour", "it's", "stopped"] {
|
||||
assert!(is_correct(w), "{w}");
|
||||
}
|
||||
for w in ["helo", "wrold", "sentense", "recieve"] {
|
||||
assert!(!is_correct(w), "{w}");
|
||||
}
|
||||
let s = suggest("recieve", 5);
|
||||
assert!(s.contains(&"receive".to_string()), "{s:?}");
|
||||
assert!(suggest("Wrold", 3).contains(&"World".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spelling_issues_in_text() {
|
||||
let t = "This sentense has a mispelled word, see https://example.com/xyzq.";
|
||||
let v = check_spelling(t);
|
||||
let bad: Vec<&str> = v.iter().map(|i| &t[i.start..i.end]).collect();
|
||||
assert_eq!(bad, vec!["sentense", "mispelled"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_issues() {
|
||||
let t = "this is is a apple , and an car. Done";
|
||||
let v = check_grammar(t);
|
||||
let msgs: Vec<&str> = v.iter().map(|i| i.message.as_str()).collect();
|
||||
assert!(msgs.iter().any(|m| m.contains("Repeated")), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m.contains("\"an\"")), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m.contains("\"a\" before")), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m.contains("space before")), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m.contains("Capitalize")), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m.contains("Extra space")), "{msgs:?}");
|
||||
assert!(check_grammar("A university is an hour away.").is_empty(), "{:?}", check_grammar("A university is an hour away."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_dictionary_works() {
|
||||
assert!(!is_correct("zxqwordcrafty"));
|
||||
add_word("zxqwordcrafty");
|
||||
assert!(is_correct("zxqwordcrafty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphenation() {
|
||||
let l = hyphen::Limits::default();
|
||||
assert_eq!(hyphen::hyphenate_word("hyphenation", &l).matches('-').count() >= 2, true);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn never_panics(s in "\\PC{0,80}") {
|
||||
let _ = check_spelling(&s);
|
||||
let _ = check_grammar(&s);
|
||||
let _ = suggest(&s, 3);
|
||||
let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Liang hyphenation patterns (TeX notation, e.g. `.ab1c`, `4t1ion`): parsing and application.
|
||||
//!
|
||||
//! The bundled English set is *ours*: trained from the public-domain Moby Hyphenator word list by
|
||||
//! DesignCraft's patgen (see `examples/hyphgen.rs`). Patterns are stored in a hash map keyed by the
|
||||
//! packed letter string, so applying them to a word costs `O(len × max_pattern_len)` lookups.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
/// Word-boundary marker in patterns.
|
||||
pub const DOT: char = '.';
|
||||
/// Longest pattern the packed key supports.
|
||||
pub const MAX_PATTERN_LEN: usize = 9;
|
||||
|
||||
/// Small, fast multiplicative hasher for packed `u64` keys (FxHash-style).
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub struct FxHasher(u64);
|
||||
|
||||
impl Hasher for FxHasher {
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for &b in bytes {
|
||||
self.write_u64(b as u64);
|
||||
}
|
||||
}
|
||||
fn write_u64(&mut self, i: u64) {
|
||||
self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
|
||||
}
|
||||
}
|
||||
|
||||
pub type FxMap<K, V> = HashMap<K, V, BuildHasherDefault<FxHasher>>;
|
||||
|
||||
/// Maps characters to 6-bit codes (1 = word boundary, 2.. = letters); 0 = not in the alphabet.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Alphabet {
|
||||
ascii: [u8; 128],
|
||||
other: Vec<(char, u8)>,
|
||||
next: u8,
|
||||
}
|
||||
|
||||
impl Default for Alphabet {
|
||||
fn default() -> Self {
|
||||
let mut a = Alphabet { ascii: [0; 128], other: Vec::new(), next: 2 };
|
||||
a.ascii[DOT as usize] = 1;
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
impl Alphabet {
|
||||
pub fn code(&self, c: char) -> u8 {
|
||||
if (c as u32) < 128 { self.ascii[c as usize] } else { self.other.iter().find(|x| x.0 == c).map_or(0, |x| x.1) }
|
||||
}
|
||||
/// Code for `c`, adding it if new (None when the 6-bit alphabet is full).
|
||||
pub fn intern(&mut self, c: char) -> Option<u8> {
|
||||
let k = self.code(c);
|
||||
if k != 0 {
|
||||
return Some(k);
|
||||
}
|
||||
if self.next >= 63 {
|
||||
return None;
|
||||
}
|
||||
let k = self.next;
|
||||
self.next += 1;
|
||||
if (c as u32) < 128 {
|
||||
self.ascii[c as usize] = k;
|
||||
} else {
|
||||
self.other.push((c, k));
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
}
|
||||
|
||||
/// Packed key of a code string (≤ [`MAX_PATTERN_LEN`] codes).
|
||||
#[inline]
|
||||
pub fn pack(codes: &[u8]) -> u64 {
|
||||
let mut k = 0u64;
|
||||
for &c in codes {
|
||||
k = (k << 6) | c as u64;
|
||||
}
|
||||
k | ((codes.len() as u64) << 58)
|
||||
}
|
||||
|
||||
/// A compiled pattern set.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Patterns {
|
||||
alpha: Alphabet,
|
||||
/// Packed letters → inter-letter values (`len + 1` entries).
|
||||
map: FxMap<u64, Box<[u8]>>,
|
||||
max_len: usize,
|
||||
}
|
||||
|
||||
impl Patterns {
|
||||
/// Parse TeX-style patterns: whitespace-separated, `%`/`#` start a comment line.
|
||||
pub fn parse(text: &str) -> Patterns {
|
||||
let mut p = Patterns::default();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('%') || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
for tok in line.split_whitespace() {
|
||||
p.insert(tok);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Add one pattern like `a1b`; returns false if it can't be represented.
|
||||
pub fn insert(&mut self, pat: &str) -> bool {
|
||||
let mut codes = Vec::with_capacity(pat.len());
|
||||
let mut vals = vec![0u8];
|
||||
for c in pat.chars() {
|
||||
if let Some(d) = c.to_digit(10) {
|
||||
*vals.last_mut().unwrap_or(&mut 0) = d as u8;
|
||||
} else {
|
||||
let Some(k) = self.alpha.intern(c) else { return false };
|
||||
codes.push(k);
|
||||
vals.push(0);
|
||||
}
|
||||
}
|
||||
if codes.is_empty() || codes.len() > MAX_PATTERN_LEN {
|
||||
return false;
|
||||
}
|
||||
self.max_len = self.max_len.max(codes.len());
|
||||
self.map.insert(pack(&codes), vals.into_boxed_slice());
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts(alpha: Alphabet, map: FxMap<u64, Box<[u8]>>, max_len: usize) -> Patterns {
|
||||
Patterns { alpha, map, max_len }
|
||||
}
|
||||
|
||||
/// Make `letters` part of the alphabet even if no pattern mentions them.
|
||||
pub fn add_letters(&mut self, letters: impl IntoIterator<Item = char>) {
|
||||
for c in letters {
|
||||
let _ = self.alpha.intern(c);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.map.is_empty()
|
||||
}
|
||||
|
||||
/// Inter-letter values for a lowercase word: `out[i]` is the value *before* char `i`
|
||||
/// (`out.len() == word.len() + 1`). None if a char is outside the pattern alphabet.
|
||||
pub fn values(&self, word: &[char]) -> Option<Vec<u8>> {
|
||||
let mut codes = Vec::with_capacity(word.len() + 2);
|
||||
codes.push(1);
|
||||
for &c in word {
|
||||
let k = self.alpha.code(c);
|
||||
if k == 0 {
|
||||
return None;
|
||||
}
|
||||
codes.push(k);
|
||||
}
|
||||
codes.push(1);
|
||||
let mut vals = vec![0u8; codes.len() + 1];
|
||||
apply(&self.map, self.max_len, &codes, &mut vals);
|
||||
// Padded position p sits before padded char p; word char i is padded char i + 1.
|
||||
Some(vals[1..codes.len()].to_vec())
|
||||
}
|
||||
|
||||
/// Break points (char indices, a hyphen goes before that char) of a lowercase word.
|
||||
pub fn points(&self, word: &[char]) -> Option<Vec<usize>> {
|
||||
let v = self.values(word)?;
|
||||
Some((1..word.len()).filter(|&i| v[i] % 2 == 1).collect())
|
||||
}
|
||||
|
||||
/// The patterns in TeX notation (sorted by letters), e.g. for writing a pattern file.
|
||||
pub fn to_tex(&self) -> Vec<String> {
|
||||
let mut rev: Vec<(u8, char)> =
|
||||
(0u8..128).filter(|&c| self.alpha.ascii[c as usize] != 0).map(|c| (self.alpha.ascii[c as usize], c as char)).collect();
|
||||
rev.extend(self.alpha.other.iter().map(|&(c, k)| (k, c)));
|
||||
let ch = |k: u8| rev.iter().find(|x| x.0 == k).map_or('?', |x| x.1);
|
||||
let mut out: Vec<(String, String)> = self
|
||||
.map
|
||||
.iter()
|
||||
.map(|(&key, vals)| {
|
||||
let n = (key >> 58) as usize;
|
||||
let letters: Vec<char> = (0..n).map(|i| ch(((key >> (6 * (n - 1 - i))) & 63) as u8)).collect();
|
||||
let mut s = String::new();
|
||||
for (i, l) in letters.iter().enumerate() {
|
||||
if vals[i] > 0 {
|
||||
s.push(char::from(b'0' + vals[i]));
|
||||
}
|
||||
s.push(*l);
|
||||
}
|
||||
if vals[n] > 0 {
|
||||
s.push(char::from(b'0' + vals[n]));
|
||||
}
|
||||
(letters.into_iter().collect(), s)
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out.into_iter().map(|x| x.1).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `map` to padded `codes`, raising `vals` (len = codes.len() + 1).
|
||||
#[inline]
|
||||
pub fn apply(map: &FxMap<u64, Box<[u8]>>, max_len: usize, codes: &[u8], vals: &mut [u8]) {
|
||||
let n = codes.len();
|
||||
for s in 0..n {
|
||||
let mut k = 0u64;
|
||||
for len in 1..=max_len.min(n - s) {
|
||||
k = (k << 6) | codes[s + len - 1] as u64;
|
||||
if let Some(v) = map.get(&(k | ((len as u64) << 58))) {
|
||||
for (j, &x) in v.iter().enumerate() {
|
||||
if x > vals[s + j] {
|
||||
vals[s + j] = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn liang_example() {
|
||||
// The classic example from Liang's thesis: "hy-phen-ation".
|
||||
let p = Patterns::parse("hy3ph he2n hena4 hen5at 1na n2at 1tio 2io o2n");
|
||||
let w: Vec<char> = "hyphenation".chars().collect();
|
||||
assert_eq!(p.points(&w).unwrap(), vec![2, 6]);
|
||||
let mut tex = p.to_tex();
|
||||
tex.sort();
|
||||
assert!(tex.contains(&"hen5at".to_string()));
|
||||
assert!(p.values(&['h', 'é']).is_none());
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ pub struct CanvasState {
|
||||
pub render_ms: f64,
|
||||
pub ime_preedit: String,
|
||||
pub want_focus: bool,
|
||||
pub context_issue: Option<serde_json::Value>,
|
||||
pub context_synonyms: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for CanvasState {
|
||||
@@ -56,6 +58,8 @@ impl Default for CanvasState {
|
||||
render_ms: 0.0,
|
||||
ime_preedit: String::new(),
|
||||
want_focus: true,
|
||||
context_issue: None,
|
||||
context_synonyms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,6 +306,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
app.canvas.focused = resp.has_focus();
|
||||
mouse(app, ui, &resp, &rects, &layout, geo.scale);
|
||||
// Right-click: move the caret there (unless inside the selection), then the context menu.
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Some((page, x, y)) = page_at(&rects, geo.scale, p)
|
||||
&& let Some(pos) = layout.hit(page, x, y, app.session.sel.focus.story)
|
||||
{
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
if !(a <= pos && pos <= b) || app.session.sel.is_collapsed() {
|
||||
app.session.sel = wordcraft_engine::Selection::caret(pos);
|
||||
}
|
||||
app.canvas.context_issue = app.session.run("review.suggestions", &json!({})).ok().filter(|v| !v.is_null());
|
||||
app.canvas.context_synonyms = app.session.run("review.thesaurus", &json!({})).ok().and_then(|v| v.get("synonyms").cloned());
|
||||
}
|
||||
resp.context_menu(|ui| context_menu(app, ui));
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
}
|
||||
@@ -556,3 +574,78 @@ pub fn pos_from_screen(app: &mut WordApp, p: Pos2) -> Option<Pos> {
|
||||
let story = app.session.sel.focus.story;
|
||||
app.session.layout().hit(page, x, y, story)
|
||||
}
|
||||
|
||||
fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.set_min_width(220.0);
|
||||
let item = |ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: serde_json::Value| {
|
||||
let sc = crate::widgets::shortcut_text(app, id);
|
||||
let on = crate::widgets::enabled(app, id);
|
||||
if ui.add_enabled(on, egui::Button::new(label).shortcut_text(sc)).clicked() {
|
||||
let _ = app.run(id, params);
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
if let Some(issue) = app.canvas.context_issue.clone() {
|
||||
let sugg: Vec<String> = issue.get("suggestions").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
ui.label(egui::RichText::new(issue.get("message").and_then(|m| m.as_str()).unwrap_or("")).small().weak());
|
||||
if sugg.is_empty() {
|
||||
ui.label(egui::RichText::new("(no suggestions)").italics());
|
||||
}
|
||||
for sgt in sugg {
|
||||
if ui.button(egui::RichText::new(&sgt).strong()).clicked() {
|
||||
let _ = app.run("review.applySuggestion", json!({"text": sgt}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if issue.get("kind").and_then(|k| k.as_str()) == Some("spelling") {
|
||||
item(ui, app, "Ignore All", "review.ignoreAll", json!({}));
|
||||
item(ui, app, "Add to Dictionary", "review.addToDictionary", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
item(ui, app, "Cut", "edit.cut", json!({}));
|
||||
if ui.add(egui::Button::new("Copy").shortcut_text(crate::widgets::shortcut_text(app, "edit.copy"))).clicked() {
|
||||
if let Ok(r) = app.run("edit.copy", json!({}))
|
||||
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
|
||||
{
|
||||
ui.ctx().copy_text(t.to_string());
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
item(ui, app, "Paste", "edit.paste", json!({}));
|
||||
ui.separator();
|
||||
if let Some(syn) = app.canvas.context_synonyms.clone().and_then(|v| v.as_array().cloned()).filter(|a| !a.is_empty()) {
|
||||
ui.menu_button("Synonyms", |ui| {
|
||||
for w in syn.iter().filter_map(|x| x.as_str()) {
|
||||
if ui.button(w).clicked() {
|
||||
let _ = app.run("select.word", json!({}));
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
let trimmed = app.session.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off)).map(|t| t.trim_end().len()).unwrap_or(0);
|
||||
let end = wordcraft_doc::Pos { off: a.off + trimmed, ..b };
|
||||
app.session.sel = wordcraft_engine::Selection { anchor: a, focus: end };
|
||||
let _ = app.run("text.insert", json!({"text": w, "raw": true}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
item(ui, app, "Font…", "ui.dialog", json!({"name": "font"}));
|
||||
item(ui, app, "Paragraph…", "ui.dialog", json!({"name": "paragraph"}));
|
||||
item(ui, app, "Link…", "ui.dialog", json!({"name": "link"}));
|
||||
item(ui, app, "New Comment", "review.newComment", json!({}));
|
||||
if app.session.sel.focus.path.cell().is_some() {
|
||||
ui.separator();
|
||||
ui.menu_button("Insert", |ui| {
|
||||
item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({}));
|
||||
item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({}));
|
||||
item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({}));
|
||||
item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({}));
|
||||
});
|
||||
ui.menu_button("Delete", |ui| {
|
||||
item(ui, app, "Delete Rows", "table.deleteRow", json!({}));
|
||||
item(ui, app, "Delete Columns", "table.deleteColumn", json!({}));
|
||||
item(ui, app, "Delete Table", "table.deleteTable", json!({}));
|
||||
});
|
||||
item(ui, app, "Merge Cells", "table.merge", json!({}));
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,17 @@ pub struct Services {
|
||||
pub pick_open: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
/// Pick a path to save to, given a suggested name.
|
||||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
/// Web: open a file picker; the file arrives later through `inbox`.
|
||||
pub open_async: Option<Box<dyn Fn(&str)>>,
|
||||
/// Web: files (name, bytes) delivered asynchronously (picker, drag and drop).
|
||||
pub inbox: Option<Inbox>,
|
||||
/// Web: hand bytes to the browser as a download.
|
||||
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
|
||||
}
|
||||
|
||||
/// Files delivered asynchronously.
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// UI state that persists between runs (and that agents can read and set).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -125,6 +134,17 @@ impl WordApp {
|
||||
if let Some(r) = self.ui_command(id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
// Web: saving and exporting become downloads.
|
||||
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
|
||||
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{}.docx", self.title_stem()));
|
||||
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
|
||||
let bytes = wordcraft_engine::io::save_bytes(&name, &self.session.doc)?;
|
||||
if let Some(d) = &self.services.download {
|
||||
d(&name, &bytes);
|
||||
}
|
||||
self.session.dirty = false;
|
||||
return Ok(json!({"downloaded": name, "bytes": bytes.len()}));
|
||||
}
|
||||
let r = self.session.run(id, ¶ms).map_err(|e| e.to_string());
|
||||
self.after_command(id);
|
||||
if let Err(e) = &r {
|
||||
@@ -231,6 +251,10 @@ impl WordApp {
|
||||
}
|
||||
|
||||
fn open_dialog(&mut self) {
|
||||
if let Some(f) = &self.services.open_async {
|
||||
f("document");
|
||||
return;
|
||||
}
|
||||
let picked = self.services.pick_open.as_ref().and_then(|f| f("document"));
|
||||
if let Some(path) = picked {
|
||||
let _ = self.run("file.open", json!({"path": path}));
|
||||
@@ -247,6 +271,10 @@ impl WordApp {
|
||||
}
|
||||
|
||||
fn pick_picture(&mut self) {
|
||||
if let Some(f) = &self.services.open_async {
|
||||
f("picture");
|
||||
return;
|
||||
}
|
||||
let picked = self.services.pick_open.as_ref().and_then(|f| f("picture"));
|
||||
if let Some(path) = picked {
|
||||
let _ = self.run("insert.picture", json!({"path": path}));
|
||||
@@ -279,6 +307,7 @@ impl WordApp {
|
||||
self.applied_dark = Some(dark);
|
||||
}
|
||||
self.drain_control(ctx);
|
||||
self.drain_inbox();
|
||||
// AutoSave: write a saved document a couple of seconds after the last change.
|
||||
let now = now_ms();
|
||||
if self.autosave && self.session.dirty && self.session.path.is_some() && now - self.last_autosave > 2500.0 && now - self.canvas.caret_visible_since > 1500.0 {
|
||||
@@ -355,6 +384,21 @@ impl WordApp {
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
/// Files that arrived asynchronously (web picker, drops): documents open, pictures insert.
|
||||
fn drain_inbox(&mut self) {
|
||||
let Some(inbox) = self.services.inbox.clone() else { return };
|
||||
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
for (name, bytes) in files {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let data = wordcraft_engine::cmd::insert::base64_encode(&bytes);
|
||||
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lower.ends_with(e));
|
||||
let r = if img { self.run("insert.picture", json!({"data": data})) } else { self.run("file.open", json!({"path": name, "data": data})) };
|
||||
if r.is_ok() {
|
||||
self.ui.backstage = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
|
||||
+1
-1
@@ -36,12 +36,12 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("geom", Class::Layer(0)),
|
||||
("doc", Class::Layer(1)),
|
||||
("fonts", Class::Layer(1)),
|
||||
("proof", Class::Layer(1)),
|
||||
("layout", Class::Layer(2)),
|
||||
("docx", Class::Layer(2)),
|
||||
("formats", Class::Layer(2)),
|
||||
("render", Class::Layer(3)),
|
||||
("pdf", Class::Layer(3)),
|
||||
("proof", Class::Layer(3)),
|
||||
("engine", Class::Layer(4)),
|
||||
("mcp", Class::Layer(5)),
|
||||
("ui-egui", Class::Layer(6)),
|
||||
|
||||
Reference in new issue
Block a user