Merge #267 stack (main, clipboard pane, table styles, Manage Styles, paste pictures, Asian typography, Group) into #280 (Shape Effects)

#280 now builds on #267's object model: selected_all() uses InlineObject::is_drawing (groups
included); group members carry effects everywhere #267 handles them (docx writer's shared
wsp() writes a:effectLst for standalone shapes and group members alike, the reader's read_wsp
reads it for both, layout's float_items/display draw members with their effects); the
groups_round_trip test gives a member a shadow and soft edges and checks they come back.
shape.effects also applies to shapes added to the selection with Shift+click (#267's
also_selected). Serbian rows for the Shape Effects strings; parity regenerated (390/431) and
floor raised to 90.4%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-10 14:26:31 -04:00
commit f1b9079911
73 changed files with 8860 additions and 446 deletions

1
Cargo.lock generated
View File

@ -4865,6 +4865,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
name = "wordcraft"
version = "0.4.0"
dependencies = [
"arboard",
"eframe",
"egui",
"fmv-macos-events",

View File

@ -8,8 +8,8 @@ and agent control. This file tracks where we are honestly. Generated numbers com
| Measure | Value |
|---|---|
| Commands (every action, scriptable by CLI/MCP/control channel) | **422** |
| Feature catalog coverage (Word ribbon/menu features with a command) | **378 / 428 (88%)** |
| Commands (every action, scriptable by CLI/MCP/control channel) | **431** |
| Feature catalog coverage (Word ribbon/menu features with a command) | **390 / 431 (90%)** |
| **Estimated real feature parity** (depth and fidelity, not just a command) | **~62%** |
| **Distance to alpha** | **~85% of the way; ≈20–25 h of Claude wall-clock work** |
| **Distance to 100% parity** | **≈120–150 h of Claude Opus 5.5 wall-clock work** (with parallel agents) |
@ -47,7 +47,7 @@ the icon art).
| Area | Status | Parity |
|---|---|---|
| Typing, selection, clipboard, undo, find/replace | Solid; IME, autocorrect, smart quotes, list autoformat | 85% |
| Typing, selection, clipboard, undo, find/replace | Solid; IME, autocorrect, smart quotes, list autoformat, column (block) selection | 85% |
| Character & paragraph formatting | Nearly all properties; Font/Paragraph dialogs | 85% |
| Styles (gallery, pane, create/modify, style sets, themes) | Good | 75% |
| Lists (bullets, numbering, multilevel, restart, set value) | Good | 75% |
@ -74,8 +74,8 @@ the icon art).
1. **Alpha blockers** (≈20–25 h): DOCX fidelity corpus, footnote continuation, column balancing,
native printing, autosave soak test, first signed release.
2. **Objects** (≈12 h): tight/through (contour) wrap, rotation handles on the canvas,
grouping, z-order polish, track-changes balloons and formatting revisions.
2. **Objects** (≈10 h): tight/through (contour) wrap, rotation handles on the canvas,
moving a multi-selection together, z-order polish, track-changes balloons and formatting revisions.
3. **Draw tab / ink** (≈15 h).
4. **Charts (own renderer) and SmartArt-style diagrams** (≈20 h).
5. **Equation editor** — done: OMML read/write, 2D layout, in-place editing with Word's Equation tab (structures, symbols, built-ins, Unicode/LaTeX input, build-up, Math AutoCorrect, `#` numbers, structure commands). Remaining: line breaking of long display equations, ink equations (≈4 h).
@ -102,7 +102,7 @@ the icon art).
| M8 | View | mostly done |
| M9 | Mailings | done (first version) |
| M10 | File/Backstage | mostly done; native printing missing |
| M11 | Draw + objects | text wrap and text boxes done; ink, rotation, grouping missing |
| M11 | Draw + objects | text wrap, text boxes and grouping done; ink, rotation missing |
| M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT) | **done** (first versions) |
| M13 | Performance budgets | on track (1.4 ms relayout) |
| M14 | 1.0 polish, packaging, signing | pipeline written; waiting on remote and secrets |

View File

@ -28,4 +28,4 @@ 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", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File"] }

View File

@ -44,6 +44,9 @@ pub fn start() {
log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let inbox: Inbox = Inbox::default();
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
log::error!("pasting pictures is unavailable: {e}");
}
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(), dirty));
app.autosave = false;
@ -100,6 +103,52 @@ impl eframe::App for WebShell {
}
}
/// Largest picture pasted from the browser's clipboard (as `insert.picture` allows).
const MAX_PASTED_BYTES: f64 = (200u32 << 20) as f64;
/// Most pictures one paste inserts.
const MAX_PASTED_PICTURES: u32 = 20;
/// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just
/// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through
/// the inbox like dropped ones. A clipboard with text is left to eframe.
fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> {
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
let Some(data) = e.clipboard_data() else { return };
if !data.get_data("text").unwrap_or_default().is_empty() {
return;
}
let Some(files) = data.files() else { return };
for i in 0..files.length().min(MAX_PASTED_PICTURES) {
let Some(file) = files.get(i) else { continue };
if !file.type_().starts_with("image/") {
continue;
}
if file.size() > MAX_PASTED_BYTES {
log::warn!("pasted picture is larger than 200 MB; not inserted");
continue;
}
let inbox = inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => {
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
// The inbox inserts files named like pictures; the engine reads the format from the bytes.
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read the pasted picture: {e:?}"),
}
});
}
});
document.add_event_listener_with_callback("paste", on_paste.as_ref().unchecked_ref()).map_err(|e| format!("{e:?}"))?;
// The listener lives as long as the page.
on_paste.forget();
Ok(())
}
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services {
let open_inbox = inbox.clone();

View File

@ -37,5 +37,8 @@ rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
pollster = "0.4"
# Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already
# in the build through egui-winit, with the same features.
arboard = { workspace = true, features = ["image-data"] }
# The control server's random key.
getrandom = "0.3"

View File

@ -20,6 +20,7 @@ mod logging;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
paste_picture::ClipboardPicture,
window_geometry::{WindowGeometry, take_rescue},
};
@ -118,9 +119,23 @@ fn save_prefs(app: &WordApp) {
}
}
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94).
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), and pictures on
/// the system clipboard (#45).
fn services(file_dialog: file_dialogs::Hook) -> Services {
Services { file_dialog: Some(file_dialog), ..Default::default() }
Services { file_dialog: Some(file_dialog), clipboard_picture: Some(Box::new(clipboard_picture)), ..Default::default() }
}
/// The picture on the system clipboard (#45): copied files, else image pixels. The UI checks the
/// sizes before inserting anything.
fn clipboard_picture() -> Option<ClipboardPicture> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()?;
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
let image = clipboard.get_image().ok()?;
Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
}
/// Window, Dock and taskbar icon.

View File

@ -74,12 +74,83 @@ fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph))
}
}
/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style
/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job.
fn uses_style(b: &Block, id: &str, depth: usize) -> bool {
let is = |s: &Option<String>| s.as_deref() == Some(id);
match b {
Block::Para(p) => is(&p.props.style) || is(&p.mark.style) || p.runs.iter().any(|r| is(&r.props.style)),
Block::Table(t) if depth < 16 => {
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).any(|cb| uses_style(cb, id, depth + 1))
}
Block::Table(_) => false,
}
}
/// Point every paragraph and run in `b` that uses style `from` at `to`; returns how many
/// paragraphs changed.
fn restyle_block(b: &mut Block, from: &str, to: &Option<String>, depth: usize) -> usize {
let fix = |s: &mut Option<String>| {
if s.as_deref() == Some(from) {
s.clone_from(to);
true
} else {
false
}
};
match b {
Block::Para(p) => {
let mut changed = fix(&mut p.props.style);
changed |= fix(&mut p.mark.style);
for r in &mut p.runs {
changed |= fix(&mut r.props.style);
}
if changed {
p.normalize();
p.touch();
}
usize::from(changed)
}
Block::Table(t) => {
let mut n = 0;
if depth < 16 {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
for cb in &mut cell.blocks {
if uses_style(cb, from, depth + 1) {
n += restyle_block(Arc::make_mut(cb), from, to, depth + 1);
}
}
}
}
n
}
}
}
/// Text box story ids of a paragraph's shapes.
fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story: Some(id), .. } = o { Some(*id) } else { None })
p.objects.iter().flat_map(InlineObject::text_boxes)
}
impl Document {
/// Point every paragraph and run that uses paragraph or character style `from` at `to`
/// (`None`: the default style), in every story, tables included. Returns how many paragraphs
/// changed. (Tables' own table styles are retargeted by the engine's table style deletion.)
pub fn restyle(&mut self, from: &str, to: Option<String>) -> usize {
let stories: Vec<crate::StoryRef> =
std::iter::once(crate::StoryRef::Body).chain(self.parts.keys().map(|k| crate::StoryRef::Part(*k))).collect();
let mut n = 0;
for s in stories {
let Ok(bl) = self.story_mut(s) else { continue };
for b in bl.iter_mut() {
if uses_style(b, from, 0) {
n += restyle_block(Arc::make_mut(b), from, &to, 0);
}
}
}
n
}
/// Insert plain text at `pos` (`\n` inside `s` is a manual line break; use
/// [`Document::split_paragraph`] for paragraph breaks). Returns the position after it.
pub fn insert_text(&mut self, pos: &Pos, s: &str, props: &CharProps) -> Result<Pos> {
@ -320,11 +391,11 @@ impl Document {
each_para_mut(b, 0, &mut |p| {
let mut changed = false;
for o in p.objects.iter_mut() {
if let InlineObject::Shape { story, .. } = o
&& let Some(old) = *story
{
*story = self.adopt_text_box(old, parts, depth, budget);
changed = true;
for story in o.text_box_slots() {
if let Some(old) = *story {
*story = self.adopt_text_box(old, parts, depth, budget);
changed = true;
}
}
}
if changed {

View File

@ -588,7 +588,7 @@ impl Document {
for path in self.para_paths(story) {
let Some(p) = self.para(story, &path) else { continue };
for off in p.object_offsets() {
if matches!(p.object_at(off), Some(InlineObject::Shape { story: Some(id), .. }) if *id == part) {
if p.object_at(off).is_some_and(|o| o.text_boxes().contains(&part)) {
return Some(Pos { story, path, off: off + para::OBJ.len_utf8() });
}
}
@ -692,9 +692,8 @@ impl Document {
/// document order. Headers, footers, comments and stories nothing points at aren't included.
pub fn counted_stories(&self) -> Vec<StoryRef> {
self.reachable(vec![StoryRef::Body], &|o| match o {
InlineObject::Shape { story: Some(id), .. } => Some((*id, &[PartKind::TextBox])),
InlineObject::NoteRef { id, .. } => Some((*id, &[PartKind::Footnote, PartKind::Endnote])),
_ => None,
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
})
}
@ -708,10 +707,7 @@ impl Document {
let roots = std::iter::once(StoryRef::Body)
.chain(self.parts.iter().filter(|(_, p)| p.kind != PartKind::TextBox).map(|(id, _)| StoryRef::Part(*id)))
.collect();
let live = self.reachable(roots, &|o| match o {
InlineObject::Shape { story: Some(id), .. } => Some((*id, &[PartKind::TextBox])),
_ => None,
});
let live = self.reachable(roots, &|o| o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect());
let before = self.parts.len();
self.parts.retain(|id, p| p.kind != PartKind::TextBox || live.contains(&StoryRef::Part(*id)));
before - self.parts.len()
@ -757,13 +753,16 @@ impl Document {
for b in blocks {
edit::each_para(b, 0, &mut |p| {
for (k, o) in p.objects.iter().enumerate() {
if f(p, k)
&& let InlineObject::Shape { story: Some(id), .. } = o
&& let Some(part) = self.parts.get(id).filter(|p| p.kind == PartKind::TextBox)
&& budget.enter(*id)
{
self.walk_objects(&part.blocks, budget, f);
budget.leave();
if !f(p, k) {
continue;
}
for id in o.text_boxes() {
if let Some(part) = self.parts.get(&id).filter(|p| p.kind == PartKind::TextBox)
&& budget.enter(id)
{
self.walk_objects(&part.blocks, budget, f);
budget.leave();
}
}
}
});
@ -772,7 +771,7 @@ impl Document {
/// `roots`, then every story their objects lead to (`follow`: an object's story id and the
/// part kinds that count), transitively, in the order found.
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Option<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
let mut out = roots;
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
let mut i = 0;
@ -781,7 +780,7 @@ impl Document {
let mut found = Vec::new();
for b in self.story(s).into_iter().flatten() {
edit::each_para(b, 0, &mut |p| {
found.extend(p.objects.iter().filter_map(follow));
found.extend(p.objects.iter().flat_map(follow));
});
}
for (id, kinds) in found {

View File

@ -217,6 +217,20 @@ pub enum InlineObject {
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
effects: crate::effects::ShapeEffects,
},
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
/// `a:chExt` and `a:ext`), so resizing the group scales them.
Group {
w: f32,
h: f32,
#[serde(default)]
float: Float,
/// Size of the members' coordinate space (their offsets and sizes are in it).
ch_w: f32,
ch_h: f32,
children: Vec<GroupChild>,
},
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
/// `result` the cached display text.
Field {
@ -276,7 +290,93 @@ pub enum InlineObject {
},
}
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupChild {
pub x: f32,
pub y: f32,
pub obj: InlineObject,
}
/// Most members a group keeps (hostile files).
pub const MAX_GROUP_CHILDREN: usize = 1000;
impl InlineObject {
/// A picture, shape, text box or group: something drawn with a frame, sized and wrapped.
pub fn is_drawing(&self) -> bool {
matches!(self, InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
}
/// A drawing's size and placement.
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
match self {
InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. } | InlineObject::Group { w, h, float, .. } => {
Some((*w, *h, float))
}
_ => None,
}
}
/// A drawing's placement, to change.
pub fn float_mut(&mut self) -> Option<&mut Float> {
match self {
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => Some(float),
_ => None,
}
}
/// Resize a drawing (a group's members scale with it).
pub fn set_size(&mut self, nw: f32, nh: f32) {
if let InlineObject::Image { w, h, .. } | InlineObject::Shape { w, h, .. } | InlineObject::Group { w, h, .. } = self {
*w = nw;
*h = nh;
}
}
/// The text box story this object shows, if it is a text box.
pub fn text_box(&self) -> Option<u32> {
match self {
InlineObject::Shape { story, .. } => *story,
_ => None,
}
}
/// The text box stories this object shows: a text box's, or its group members'.
pub fn text_boxes(&self) -> Vec<u32> {
match self {
InlineObject::Shape { story: Some(id), .. } => vec![*id],
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
_ => Vec::new(),
}
}
/// The text box story slots of this object and its group members, to repoint.
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
match self {
InlineObject::Shape { story, .. } => vec![story],
InlineObject::Group { children, .. } => children
.iter_mut()
.filter_map(|c| match &mut c.obj {
InlineObject::Shape { story, .. } => Some(story),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
/// (x, y, w, h) and the member. Empty for anything else.
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
children
.iter()
.take(MAX_GROUP_CHILDREN)
.filter_map(|c| {
let (cw, chh, _) = c.obj.frame()?;
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
})
.collect()
}
/// Zero-width markers don't take part in layout.
pub fn is_marker(&self) -> bool {
matches!(
@ -291,7 +391,7 @@ impl InlineObject {
}
pub fn is_floating(&self) -> bool {
match self {
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
_ => false,
}
}

View File

@ -622,13 +622,34 @@ pub struct ParaProps {
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
/// Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub kinsoku: Option<bool>,
/// `w:wordWrap`: Latin words wrap whole; `false` lets them break at any character (Word's
/// "Allow Latin text to wrap in the middle of a word"). Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub word_wrap: Option<bool>,
/// `w:overflowPunct`: punctuation may hang past the line end. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub overflow_punct: Option<bool>,
/// `w:topLinePunct`: compress punctuation at the start of a line. Unset = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub top_line_punct: Option<bool>,
/// `w:autoSpaceDE`: automatic space between Asian and Latin text. Unset = on.
#[serde(rename = "autoSpaceDE", skip_serializing_if = "Option::is_none")]
pub auto_space_de: Option<bool>,
/// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on.
#[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")]
pub auto_space_dn: Option<bool>,
}
impl ParaProps {
pub fn overlay(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing,
contextual_spacing, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap);
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);

View File

@ -141,6 +141,14 @@ pub struct ResolvedPara {
pub bidi: bool,
pub drop_cap: u8,
pub suppress_hyphens: bool,
/// Asian typography (see `ParaProps`): East Asian line-breaking rules.
pub kinsoku: bool,
/// Latin words wrap whole (`false`: they may break at any character).
pub word_wrap: bool,
pub overflow_punct: bool,
pub top_line_punct: bool,
pub auto_space_de: bool,
pub auto_space_dn: bool,
}
const MAX_INDENT: f32 = 1584.0; // 22"
@ -186,6 +194,12 @@ impl ResolvedPara {
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
kinsoku: p.kinsoku.unwrap_or(true),
word_wrap: p.word_wrap.unwrap_or(true),
overflow_punct: p.overflow_punct.unwrap_or(true),
top_line_punct: p.top_line_punct.unwrap_or(false),
auto_space_de: p.auto_space_de.unwrap_or(true),
auto_space_dn: p.auto_space_dn.unwrap_or(true),
}
}
}

View File

@ -52,27 +52,52 @@ pub struct TableStyleParts {
pub cell_margins: Option<[f32; 4]>,
pub header_fill: Option<Rgb>,
pub header_chr: CharProps,
/// Cell borders of the header row (`firstRow` region's `w:tcBorders`): `between` is the
/// border between header rows, `inside_v` between its cells.
pub header_borders: Option<Borders>,
pub band_fill: Option<Rgb>,
/// Character formatting of the odd row bands (`band1Horz` region).
pub band_chr: CharProps,
/// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`).
pub band_borders: Option<Borders>,
/// Rows per band (`w:tblStyleRowBandSize`); one when unset.
pub band_size: Option<u32>,
pub first_col_chr: CharProps,
pub total_chr: CharProps,
pub total_border_top: Option<Border>,
/// Shading and cell borders of the first column (`firstCol` region).
pub first_col_fill: Option<Rgb>,
pub first_col_borders: Option<Borders>,
/// The last column (`lastCol` region).
pub last_col_fill: Option<Rgb>,
pub last_col_chr: CharProps,
pub last_col_borders: Option<Borders>,
/// Shading and cell borders of the total (last) row (`lastRow` region); a top edge left
/// unset falls back to `total_border_top`.
pub total_fill: Option<Rgb>,
pub total_borders: Option<Borders>,
/// The odd column bands (`band1Vert` region).
pub col_band_fill: Option<Rgb>,
pub col_band_chr: CharProps,
pub col_band_borders: Option<Borders>,
}
impl TableStyleParts {
/// Apply `patch` (a style further down a based-on chain) over these parts: what it sets wins,
/// the rest is inherited; borders merge edge by edge.
pub fn overlay(&mut self, patch: &TableStyleParts) {
self.borders = match (self.borders, patch.borders) {
(Some(b), Some(p)) => Some(Borders {
top: p.top.or(b.top),
left: p.left.or(b.left),
bottom: p.bottom.or(b.bottom),
right: p.right.or(b.right),
between: p.between.or(b.between),
inside_v: p.inside_v.or(b.inside_v),
}),
let merge = |b: Option<Borders>, p: Option<Borders>| match (b, p) {
(Some(mut b), Some(p)) => {
b.overlay(&p);
Some(b)
}
(b, p) => p.or(b),
};
self.borders = merge(self.borders, patch.borders);
self.header_borders = merge(self.header_borders, patch.header_borders);
self.band_borders = merge(self.band_borders, patch.band_borders);
self.band_size = patch.band_size.or(self.band_size);
self.band_chr.overlay(&patch.band_chr);
self.fill = patch.fill.or(self.fill);
self.cell_margins = patch.cell_margins.or(self.cell_margins);
self.header_fill = patch.header_fill.or(self.header_fill);
@ -81,6 +106,16 @@ impl TableStyleParts {
self.first_col_chr.overlay(&patch.first_col_chr);
self.total_chr.overlay(&patch.total_chr);
self.total_border_top = patch.total_border_top.or(self.total_border_top);
self.first_col_fill = patch.first_col_fill.or(self.first_col_fill);
self.first_col_borders = merge(self.first_col_borders, patch.first_col_borders);
self.last_col_fill = patch.last_col_fill.or(self.last_col_fill);
self.last_col_chr.overlay(&patch.last_col_chr);
self.last_col_borders = merge(self.last_col_borders, patch.last_col_borders);
self.total_fill = patch.total_fill.or(self.total_fill);
self.total_borders = merge(self.total_borders, patch.total_borders);
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
self.col_band_chr.overlay(&patch.col_band_chr);
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
}
}
@ -427,6 +462,25 @@ impl StyleSheet {
None => self.styles.push(st),
}
}
/// Remove style `id` and return it. Styles based on it are rebased onto its own base, and
/// `next` / `linked` references to it are dropped. Text that uses it is the document's to
/// retarget ([`crate::Document::restyle`]).
pub fn remove(&mut self, id: &str) -> Option<Style> {
let i = self.styles.iter().position(|s| s.id == id)?;
let gone = self.styles.remove(i);
for s in &mut self.styles {
if s.based_on.as_deref() == Some(id) {
s.based_on = gone.based_on.clone().filter(|b| *b != s.id);
}
if s.next.as_deref() == Some(id) {
s.next = None;
}
if s.linked.as_deref() == Some(id) {
s.linked = None;
}
}
Some(gone)
}
/// An id not used yet, derived from a display name.
pub fn new_id(&self, name: &str) -> String {
let base: String = name.chars().filter(|c| c.is_alphanumeric()).collect();

View File

@ -444,9 +444,11 @@ impl Reader<'_> {
}
t.fill = tp.child("w:shd").and_then(props::shd_fill);
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
}
for c in s.children("w:tblStylePr") {
for c in s.children("w:tblStylePr").take(64) {
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(props::shd_fill);
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
match c.attr("w:type") {
Some("wholeTable") => {
@ -464,12 +466,35 @@ impl Reader<'_> {
Some("firstRow") => {
t.header_fill = fill;
t.header_chr = chr;
t.header_borders = cell_borders;
}
Some("band1Horz") => {
t.band_fill = fill;
t.band_chr = chr;
t.band_borders = cell_borders;
}
Some("firstCol") => {
t.first_col_chr = chr;
t.first_col_fill = fill;
t.first_col_borders = cell_borders;
}
Some("lastCol") => {
t.last_col_chr = chr;
t.last_col_fill = fill;
t.last_col_borders = cell_borders;
}
Some("band1Horz") => t.band_fill = fill,
Some("firstCol") => t.first_col_chr = chr,
Some("lastRow") => {
t.total_chr = chr;
t.total_border_top = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).and_then(|b| b.child("w:top")).map(props::border);
t.total_fill = fill;
// The top rule is kept on its own (as built-in styles have it); the other edges
// are the region's borders.
t.total_border_top = cell_borders.and_then(|b| b.top);
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
}
Some("band1Vert") => {
t.col_band_fill = fill;
t.col_band_chr = chr;
t.col_band_borders = cell_borders;
}
_ => {}
}

View File

@ -142,6 +142,12 @@ impl PropCtx {
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
"w:topLinePunct" => p.top_line_punct = Some(on_off(k)),
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
"w:framePr" => {
if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);

View File

@ -125,7 +125,7 @@ fn clean_text(s: &str) -> String {
fn children_of_choice(ac: &El) -> Option<&El> {
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
const KNOWN: &[&str] = &["wps", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
const KNOWN: &[&str] = &["wps", "wpg", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r));
ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice"))
}
@ -650,19 +650,11 @@ impl Reader<'_> {
}
}
let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?;
if let Some(blip) = gd.find("a:blip") {
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
let mut crop = [0.0f32; 4];
if let Some(sr) = gd.find("a:srcRect") {
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
if let Some(v) = sr.attr(n).and_then(int)
&& let Some(slot) = crop.get_mut(i)
{
*slot = (v.clamp(0, 100_000) as f32 / 100_000.0).clamp(0.0, 1.0);
}
}
}
return Some(InlineObject::Image { media, w, h, alt, float, crop });
if let Some(g) = gd.child("wpg:wgp") {
return self.read_group(sc, g, rels, w, h, float);
}
if gd.find("a:blip").is_some() {
return self.read_pic(gd, rels, w, h, alt, float);
}
if let Some(wsp) = gd.find("wps:wsp") {
return Some(self.read_wsp(sc, wsp, rels, w, h, float));
@ -670,6 +662,99 @@ impl Reader<'_> {
None
}
/// A picture: the first `a:blip` in `pic` and its crop.
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, float: Float) -> Option<InlineObject> {
let blip = pic.find("a:blip")?;
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
let mut crop = [0.0f32; 4];
if let Some(sr) = pic.find("a:srcRect") {
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
if let Some(v) = sr.attr(n).and_then(int)
&& let Some(slot) = crop.get_mut(i)
{
*slot = (v.clamp(0, 100_000) as f32 / 100_000.0).clamp(0.0, 1.0);
}
}
}
Some(InlineObject::Image { media, w, h, alt, float, crop })
}
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
/// flattened into it. `None` when it has no member we can show.
fn read_group(&mut self, sc: &mut StoryCtx, g: &El, rels: &Rels, w: f32, h: f32, float: Float) -> Option<InlineObject> {
let x = group_xfrm(g.child("wpg:grpSpPr"));
// The members' space; without `a:chExt` it is the group's own size.
let (ch_w, ch_h) = match x.ch_ext {
Some((cw, ch)) if cw > 0.0 && ch > 0.0 => (cw, ch),
_ => (w.max(1.0), h.max(1.0)),
};
let (cx, cy) = x.ch_off.unwrap_or((0.0, 0.0));
let mut children = Vec::new();
self.group_members(sc, g, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
if children.is_empty() {
return None;
}
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children })
}
/// The members of group `g` into `out`, mapped into the outermost group's space by `t`
/// (x0, y0, sx, sy: outer = x0 + inner × sx).
fn group_members(
&mut self,
sc: &mut StoryCtx,
g: &El,
rels: &Rels,
t: (f32, f32, f32, f32),
depth: usize,
out: &mut Vec<wordcraft_doc::para::GroupChild>,
) {
let (x0, y0, sx, sy) = t;
for e in g.els() {
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
return;
}
let e = match e.name.as_str() {
"mc:AlternateContent" => match children_of_choice(e).and_then(|c| c.els().next()) {
Some(c) => c,
None => continue,
},
_ => e,
};
let sppr = match e.name.as_str() {
"wps:wsp" => e.child("wps:spPr"),
"pic:pic" => e.child("pic:spPr"),
"wpg:grpSp" => e.child("wpg:grpSpPr"),
_ => continue,
};
let x = group_xfrm(sppr);
let ((ox, oy), (ew, eh)) = (x.off, x.ext);
if e.name == "wpg:grpSp" {
if depth >= 8 {
continue;
}
// Its members' space maps onto its own box.
let (cox, coy) = x.ch_off.unwrap_or((0.0, 0.0));
let (cw, ch) = x.ch_ext.filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
let (kx, ky) = (ew / cw, eh / ch);
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
self.group_members(sc, e, rels, nt, depth + 1, out);
continue;
}
let max = crate::units::MAX_LEN_PT;
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0));
let obj = if e.name == "pic:pic" {
let alt = e.find("pic:cNvPr").and_then(|p| p.attr("descr")).unwrap_or("").to_string();
self.read_pic(e, rels, w, h, alt, Float::default())
} else {
Some(self.read_wsp(sc, e, rels, w, h, Float::default()))
};
if let Some(obj) = obj {
out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + ox * sx, -max), y: fit(y0 + oy * sy, -max), obj });
}
}
}
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, float: Float) -> InlineObject {
let sppr = wsp.child("wps:spPr");
let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
@ -967,3 +1052,30 @@ fn effect_color(e: &El) -> (Option<Rgb>, f32) {
let opacity = c.child("a:alpha").and_then(|a| a.attr("val")).and_then(int).map(|v| v.clamp(0, 100_000) as f32 / 1000.0).unwrap_or(100.0);
(rgb, 100.0 - opacity)
}
/// A DrawingML `a:xfrm` (ECMA-376 §20.1.7.5/6) in points: offset, extent and, for a group, its
/// members' offset and extent.
struct Xfrm {
off: (f32, f32),
ext: (f32, f32),
ch_off: Option<(f32, f32)>,
ch_ext: Option<(f32, f32)>,
}
/// The `a:xfrm` in shape properties `sppr` (zeros when missing).
fn group_xfrm(sppr: Option<&El>) -> Xfrm {
let x = sppr.and_then(|s| s.child("a:xfrm"));
let pair = |name: &str, a: &str, b: &str, lim: f32| {
let e = x.and_then(|x| x.child(name))?;
let v = |n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).map(|v| v.clamp(-lim, lim)).unwrap_or(0.0);
Some((v(a), v(b)))
};
let max = crate::units::MAX_LEN_PT;
let pos = |p: Option<(f32, f32)>| p.map(|(a, b)| (a.max(0.0), b.max(0.0)));
Xfrm {
off: pair("a:off", "x", "y", max).unwrap_or((0.0, 0.0)),
ext: pos(pair("a:ext", "cx", "cy", max)).unwrap_or((0.0, 0.0)),
ch_off: pair("a:chOff", "x", "y", max),
ch_ext: pos(pair("a:chExt", "cx", "cy", max)),
}
}

View File

@ -622,8 +622,11 @@ fn style_xml(w: &mut W, st: &Style) {
w.close("w:rPr");
}
if let Some(t) = &st.table {
if t.borders.is_some() || t.cell_margins.is_some() {
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() {
w.open("w:tblPr", &[]);
if let Some(n) = t.band_size {
w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string());
}
if let Some(b) = &t.borders {
props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
}
@ -632,35 +635,49 @@ fn style_xml(w: &mut W, st: &Style) {
}
w.close("w:tblPr");
}
let cond =
|w: &mut W, ty: &str, chr: &wordcraft_doc::CharProps, fill: Option<wordcraft_doc::Rgb>, top: Option<&wordcraft_doc::props::Border>| {
if !props::has_rpr(chr) && fill.is_none() && top.is_none() {
return;
// One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders
// and shading.
let cond = |w: &mut W,
ty: &str,
chr: &wordcraft_doc::CharProps,
fill: Option<wordcraft_doc::Rgb>,
borders: Option<wordcraft_doc::props::Borders>| {
if !props::has_rpr(chr) && fill.is_none() && borders.is_none() {
return;
}
w.open("w:tblStylePr", &[("w:type", ty)]);
if props::has_rpr(chr) {
w.open("w:rPr", &[]);
props::rpr_inner(w, chr);
w.close("w:rPr");
}
if fill.is_some() || borders.is_some() {
w.open("w:tcPr", &[]);
if let Some(bs) = &borders {
props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]);
}
w.open("w:tblStylePr", &[("w:type", ty)]);
if props::has_rpr(chr) {
w.open("w:rPr", &[]);
props::rpr_inner(w, chr);
w.close("w:rPr");
if let Some(f) = fill {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
}
if fill.is_some() || top.is_some() {
w.open("w:tcPr", &[]);
if let Some(b) = top {
let bs = wordcraft_doc::props::Borders { top: Some(*b), ..Default::default() };
props::borders(w, "w:tcBorders", &bs, None, &[]);
}
if let Some(f) = fill {
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
}
w.close("w:tcPr");
}
w.close("w:tblStylePr");
};
w.close("w:tcPr");
}
w.close("w:tblStylePr");
};
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
cond(w, "firstRow", &t.header_chr, t.header_fill, None);
cond(w, "lastRow", &t.total_chr, None, t.total_border_top.as_ref());
cond(w, "firstCol", &t.first_col_chr, None, None);
cond(w, "band1Horz", &wordcraft_doc::CharProps::default(), t.band_fill, None);
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
// The total row's borders, its top edge falling back to the built-in top rule.
let total_borders = match (t.total_borders, t.total_border_top) {
(Some(mut b), top) => {
b.top = b.top.or(top);
Some(b)
}
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
};
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
}
w.close("w:style");
}

View File

@ -230,6 +230,12 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool) {
w.close("w:tabs");
}
toggle(w, "w:suppressAutoHyphens", p.suppress_hyphens);
toggle(w, "w:kinsoku", p.kinsoku);
toggle(w, "w:wordWrap", p.word_wrap);
toggle(w, "w:overflowPunct", p.overflow_punct);
toggle(w, "w:topLinePunct", p.top_line_punct);
toggle(w, "w:autoSpaceDE", p.auto_space_de);
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() {
let before = p.space_before.map(|v| twips(v.max(0.0)));

View File

@ -370,10 +370,8 @@ impl Writer<'_> {
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Image { media, w: iw, h: ih, alt, float, crop } => {
InlineObject::Image { media, w: iw, h: ih, alt, float, .. } => {
let Some(file) = self.media_files.get(media).cloned() else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
self.used_media.insert(media.clone());
self.rev_open(w, props);
w.open("w:r", &[]);
rpr(w, props);
@ -384,30 +382,7 @@ impl Writer<'_> {
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
w.empty("pic:cNvPr", &[("id", "0"), ("name", &file), ("descr", alt)]);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
w.close("pic:nvPicPr");
w.open("pic:blipFill", &[]);
w.empty("a:blip", &[("r:embed", &rid)]);
if crop.iter().any(|c| *c > 0.0) {
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
}
w.open("a:stretch", &[]);
w.empty("a:fillRect", &[]);
w.close("a:stretch");
w.close("pic:blipFill");
w.open("pic:spPr", &[("bwMode", "auto")]);
xfrm(w, *iw, *ih);
w.open("a:prstGeom", &[("prst", "rect")]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
w.close("pic:spPr");
w.close("pic:pic");
self.pic(w, o, &file, (0.0, 0.0), rels);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
@ -415,7 +390,7 @@ impl Writer<'_> {
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, float, story, effects } => {
InlineObject::Shape { w: sw, h: sh, float, effects, .. } => {
self.rev_open(w, props);
w.open("w:r", &[]);
rpr(w, props);
@ -432,67 +407,53 @@ impl Writer<'_> {
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
w.open("wps:wsp", &[]);
if *kind == ShapeKind::TextBox {
w.empty("wps:cNvSpPr", &[("txBox", "1")]);
} else {
w.empty("wps:cNvSpPr", &[]);
}
w.open("wps:spPr", &[]);
xfrm(w, *sw, *sh);
let prst = match kind {
ShapeKind::Rectangle | ShapeKind::TextBox => "rect",
ShapeKind::RoundedRectangle => "roundRect",
ShapeKind::Ellipse => "ellipse",
ShapeKind::Triangle => "triangle",
ShapeKind::Diamond => "diamond",
ShapeKind::Line => "line",
ShapeKind::Arrow => "rightArrow",
ShapeKind::Star => "star5",
ShapeKind::Heart => "heart",
};
w.open("a:prstGeom", &[("prst", prst)]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
match fill {
Some(c) => {
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
}
None => w.empty("a:noFill", &[]),
}
match stroke {
Some(c) => {
w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("a:ln");
}
None => {
w.open("a:ln", &[]);
w.empty("a:noFill", &[]);
w.close("a:ln");
self.wsp(w, o, None, (0.0, 0.0), rels, depth);
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
w.close("w:drawing");
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children } => {
self.rev_open(w, props);
w.open("w:r", &[]);
rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("Group {docpr}");
self.drawing_open(w, float, *gw, *gh, &docpr, &name, "");
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]);
// ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt`
// maps onto the group's `a:off`/`a:ext`.
w.open("wpg:wgp", &[]);
w.empty("wpg:cNvGrpSpPr", &[]);
w.open("wpg:grpSpPr", &[]);
w.open("a:xfrm", &[]);
w.empty("a:off", &[("x", "0"), ("y", "0")]);
w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]);
w.empty("a:chOff", &[("x", "0"), ("y", "0")]);
w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]);
w.close("a:xfrm");
w.close("wpg:grpSpPr");
for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) {
let at = (c.x, c.y);
match &c.obj {
InlineObject::Image { media, .. } => {
if let Some(file) = self.media_files.get(media).cloned() {
self.pic(w, &c.obj, &file, at, rels);
}
}
InlineObject::Shape { .. } => {
let id = self.next_docpr();
self.wsp(w, &c.obj, Some(&id), at, rels, depth);
}
_ => {}
}
}
effect_list(w, effects);
w.close("wps:spPr");
// Its text, within the same bounds layout shows boxes inside boxes with.
if let Some(id) = *story
&& let Some(part) = self.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::TextBox)
&& self.boxes.enter(id)
{
w.open("wps:txbx", &[]);
w.open("w:txbxContent", &[]);
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
w.close("w:txbxContent");
w.close("wps:txbx");
self.boxes.leave();
}
w.empty("wps:bodyPr", &[]);
w.close("wps:wsp");
w.close("wpg:wgp");
w.close("a:graphicData");
w.close("a:graphic");
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
@ -582,6 +543,108 @@ impl Writer<'_> {
run(w, &|w| w.empty("w:fldChar", &[("w:fldCharType", "separate")]));
}
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, .. } = o else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
w.empty("pic:cNvPr", &[("id", "0"), ("name", file), ("descr", alt)]);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
w.close("pic:nvPicPr");
w.open("pic:blipFill", &[]);
w.empty("a:blip", &[("r:embed", &rid)]);
if crop.iter().any(|c| *c > 0.0) {
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
}
w.open("a:stretch", &[]);
w.empty("a:fillRect", &[]);
w.close("a:stretch");
w.close("pic:blipFill");
w.open("pic:spPr", &[("bwMode", "auto")]);
xfrm(w, off, *iw, *ih);
w.open("a:prstGeom", &[("prst", "rect")]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
w.close("pic:spPr");
w.close("pic:pic");
}
/// A shape's or text box's `wps:wsp`, at `off` in its group (or 0, 0). In a group it carries
/// its own `wps:cNvPr` with drawing id `id`.
fn wsp(&mut self, w: &mut W, o: &InlineObject, id: Option<&str>, off: (f32, f32), rels: &mut PartRels, depth: usize) {
let InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, story, effects, .. } = o else { return };
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = format!("Shape {id}");
w.empty("wps:cNvPr", &[("id", id), ("name", &name)]);
}
if *kind == ShapeKind::TextBox {
w.empty("wps:cNvSpPr", &[("txBox", "1")]);
} else {
w.empty("wps:cNvSpPr", &[]);
}
w.open("wps:spPr", &[]);
xfrm(w, off, *sw, *sh);
let prst = match kind {
ShapeKind::Rectangle | ShapeKind::TextBox => "rect",
ShapeKind::RoundedRectangle => "roundRect",
ShapeKind::Ellipse => "ellipse",
ShapeKind::Triangle => "triangle",
ShapeKind::Diamond => "diamond",
ShapeKind::Line => "line",
ShapeKind::Arrow => "rightArrow",
ShapeKind::Star => "star5",
ShapeKind::Heart => "heart",
};
w.open("a:prstGeom", &[("prst", prst)]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
match fill {
Some(c) => {
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
}
None => w.empty("a:noFill", &[]),
}
match stroke {
Some(c) => {
w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("a:ln");
}
None => {
w.open("a:ln", &[]);
w.empty("a:noFill", &[]);
w.close("a:ln");
}
}
effect_list(w, effects);
w.close("wps:spPr");
// Its text, within the same bounds layout shows boxes inside boxes with.
if let Some(id) = *story
&& let Some(part) = self.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::TextBox)
&& self.boxes.enter(id)
{
w.open("wps:txbx", &[]);
w.open("w:txbxContent", &[]);
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
w.close("w:txbxContent");
w.close("wps:txbx");
self.boxes.leave();
}
w.empty("wps:bodyPr", &[]);
w.close("wps:wsp");
}
#[allow(clippy::too_many_arguments)]
fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) {
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
@ -826,9 +889,9 @@ fn effect_list(w: &mut W, effects: &ShapeEffects) {
w.close("a:effectLst");
}
fn xfrm(w: &mut W, cw: f32, ch: f32) {
fn xfrm(w: &mut W, (x, y): (f32, f32), cw: f32, ch: f32) {
w.open("a:xfrm", &[]);
w.empty("a:off", &[("x", "0"), ("y", "0")]);
w.empty("a:off", &[("x", &emu(x)), ("y", &emu(y))]);
w.empty("a:ext", &[("cx", &emu(cw.max(0.0))), ("cy", &emu(ch.max(0.0)))]);
w.close("a:xfrm");
}

View File

@ -6,7 +6,7 @@ use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, VMerge};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph};
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
const ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
@ -646,3 +646,28 @@ fn hostile_vml_style_values_stay_finite() {
assert!([*w, *h, float.x, float.y, float.dist, float.dist_top].iter().all(|v| v.is_finite()), "{w} {h} {float:?}");
assert_eq!((float.h_align, float.dist, float.dist_top), (None, 0.0, 1584.0));
}
/// A group as Word saves one (in `mc:AlternateContent`, needing `wpg`): its members' space
/// starts at `a:chOff`, and a nested group is flattened into it through its own transform.
#[test]
fn word_group_with_nested_group() {
let body = r#"<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor behindDoc="0" distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="2540000" cy="1270000"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapSquare wrapText="bothSides"/><wp:docPr id="1" name="Group 1"/><wp:cNvGraphicFramePr/>
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2540000" cy="1270000"/><a:chOff x="127000" y="127000"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
<wps:wsp><wps:cNvPr id="2" name="Rectangle 2"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="127000" y="127000"/><a:ext cx="1270000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
<wpg:grpSp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="1397000" y="762000"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
<wps:wsp><wps:cNvPr id="3" name="Oval 3"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="254000" y="254000"/><a:ext cx="508000" cy="254000"/></a:xfrm><a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict/></mc:Fallback></mc:AlternateContent></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
let InlineObject::Group { w, h, float, ch_w, ch_h, children } = &p[0].objects[0] else { panic!("{:?}", p[0].objects) };
assert_eq!((*w, *h, *ch_w, *ch_h), (200.0, 100.0, 200.0, 100.0));
assert_eq!(float.wrap, Wrap::Square);
let got: Vec<(f32, f32, ShapeKind, f32, f32)> = children
.iter()
.map(|c| match &c.obj {
InlineObject::Shape { kind, w, h, .. } => (c.x, c.y, *kind, *w, *h),
o => panic!("{o:?}"),
})
.collect();
assert_eq!(got, [(0.0, 0.0, ShapeKind::Rectangle, 100.0, 50.0), (110.0, 60.0, ShapeKind::Ellipse, 20.0, 10.0)]);
}

View File

@ -136,11 +136,18 @@ fn every_para_prop_round_trips() {
suppress_line_numbers: Some(true),
bidi: Some(false),
drop_cap: None,
kinsoku: Some(false),
word_wrap: Some(false),
overflow_punct: Some(false),
top_line_punct: Some(true),
auto_space_de: Some(false),
auto_space_dn: Some(true),
};
let variants = [
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
ParaProps { line_spacing: Some(LineSpacing::Exactly(20.0)), align: Some(Align::Right), ..Default::default() },
ParaProps { align: Some(Align::Distribute), ..Default::default() },
ParaProps { kinsoku: Some(true), word_wrap: Some(true), auto_space_dn: Some(false), ..Default::default() },
ParaProps::default(),
];
let mut ps = vec![Paragraph::with_text("main", CharProps::default())];
@ -429,7 +436,8 @@ fn comments_round_trip() {
}
}
/// A table style's cell text formatting, whole-table shading and cell margins survive a save.
/// A table style's cell text formatting, whole-table shading, cell margins and its header row and
/// row band conditional formats (#146) survive a save.
#[test]
fn table_style_formatting_round_trips() {
use wordcraft_doc::styles::TableStyleParts;
@ -440,6 +448,11 @@ fn table_style_formatting_round_trips() {
cell_margins: Some([1.0, 14.4, 0.0, 14.4]),
header_chr: CharProps { italic: Some(true), ..Default::default() },
header_fill: Some(Rgb(0xFF, 0xFF, 0)),
header_borders: Some(Borders::all(Border::single(1.5))),
band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)),
band_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0, 0x70, 0xC0))), ..Default::default() },
band_borders: Some(Borders::box_(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
band_size: Some(3),
..Default::default()
};
d.styles.upsert(Style {
@ -843,6 +856,82 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
assert_eq!(got[1].props, dc.props);
}
/// A group of a picture, a shape and a text box (`wpg:wgp`) comes back as it was saved: its
/// size, placement and members' space, and each member's offset, size and content.
#[test]
fn groups_round_trip() {
use wordcraft_doc::para::GroupChild;
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]);
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4] };
let oval = InlineObject::Shape {
kind: ShapeKind::Ellipse,
w: 50.0,
h: 30.0,
fill: Some(Rgb(200, 0, 0)),
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: None,
// A member's shape effects (#275) come back too.
effects: wordcraft_doc::effects::ShapeEffects {
shadow: Some(wordcraft_doc::effects::Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0 }),
glow: None,
soft_edge: Some(2.5),
},
};
let tb = InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 100.0,
h: 40.0,
fill: Some(Rgb(255, 255, 255)),
stroke: Some(Rgb(0, 0, 0)),
stroke_width: 0.75,
float: Float::default(),
story: Some(story),
effects: Default::default(),
};
let float = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 20.0, y: 10.0, dist: 9.0, ..Default::default() };
let group = InlineObject::Group {
w: 240.0,
h: 120.0,
float,
ch_w: 200.0,
ch_h: 100.0,
children: vec![
GroupChild { x: 0.0, y: 0.0, obj: pic },
GroupChild { x: 150.0, y: 10.0, obj: oval.clone() },
GroupChild { x: 50.0, y: 60.0, obj: tb },
],
};
let mut p = Paragraph::with_text("grouped ", CharProps::default());
let end = p.len();
p.insert_object(end, group, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let r = rt(&d);
let got = paras(&r);
let InlineObject::Group { w, h, float: f, ch_w, ch_h, children } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) };
assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0));
assert_eq!(*f, float);
assert_eq!(children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(), [(0.0, 0.0), (150.0, 10.0), (50.0, 60.0)]);
match &children[0].obj {
InlineObject::Image { media, w, h, alt, .. } => {
assert_eq!((*w, *h, alt.as_str()), (60.0, 40.0, "A picture"));
assert_eq!(r.media.get(media).map(|m| m.as_slice()), Some(tiny_png().as_slice()));
}
o => panic!("{o:?}"),
}
assert_eq!(children[1].obj, oval);
match &children[2].obj {
InlineObject::Shape { kind, w, h, story, .. } => {
assert_eq!((*kind, *w, *h), (ShapeKind::TextBox, 100.0, 40.0));
assert_eq!(part_text(&r, *story), "In the group");
}
o => panic!("{o:?}"),
}
}
#[test]
fn compatibility_mode_round_trips() {
// New documents are Word 2013+ documents; an older file keeps its mode, so saving it doesn't

View File

@ -327,13 +327,16 @@ 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|New Table Style|table.newStyle
Table Design|Table Styles|Modify Table Style|table.modifyStyle
Table Design|Table Styles|Delete Table Style|table.deleteStyle
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|View Gridlines|table.viewGridlines
Table Layout|Table|Properties|table.properties
Table Layout|Draw|Draw Table|table.draw
Table Layout|Draw|Eraser|table.eraser
@ -500,6 +503,6 @@ mod tests {
let p = parity(&reg);
let pct = p["percent"].as_f64().unwrap();
// The floor only ever rises.
assert!(pct >= 60.0, "parity {pct}%");
assert!(pct >= 90.4, "parity {pct}%");
}
}

View File

@ -0,0 +1,232 @@
//! Column (block) selection: Alt+drag, or Ctrl+Shift+F8 then the arrow keys, selects the same
//! horizontal range on a run of lines. Copy and Cut take one line per block row, Delete /
//! Backspace remove just the block, character formatting applies to each row's piece, paragraph
//! formatting to the paragraphs it touches. Typing or pasting first deletes the block; any other
//! edit collapses it to its top-left corner. Any ordinary caret move or selection drops it.
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Block, Paragraph, Pos};
use super::{parse_pos, pos_json};
use crate::{CmdError, CmdResult, ColumnBlock, CommandSpec, Selection, Session, p};
/// Most lines a block covers (hostile params: a block over a huge document).
const MAX_LINES: usize = 100_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("select.column", "Column Selection", "Editing › Selection", select_column)
.key("Mod+Shift+F8")
.params(
r#"{"anchor": Pos, "focus": Pos} | {"from": {"page": n, "x": pt, "y": pt}, "to": {"page": n, "x": pt, "y": pt}, "story"?: StoryRef} | {"on"?: bool} (no corners: toggle column selection mode, where the arrow keys extend the block)"#,
)
.pure(),
]
}
/// Result: the corners, each block row's text and whether column mode is on.
fn result(s: &Session) -> CmdResult {
let rows: Vec<String> = s.column_segments().map(|segs| segs.iter().map(|(a, b)| row_text(s, a, b)).collect()).unwrap_or_default();
Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus), "rows": rows, "columnMode": s.column_mode}))
}
fn row_text(s: &Session, a: &Pos, b: &Pos) -> String {
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
if a.path != b.path || a.story != b.story {
return String::new();
}
s.doc.para_at(&a).and_then(|p| p.text.get(a.off.min(b.off)..b.off.max(a.off))).map(str::to_string).unwrap_or_default()
}
/// `{"page": n, "x": pt, "y": pt}`.
fn point(v: &Value, name: &str) -> Result<(usize, f32, f32), CmdError> {
match (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) {
(Some(pg), Some(x), Some(y)) => Ok((usize::try_from(pg).unwrap_or(usize::MAX), x, y)),
_ => Err(CmdError::Params(format!("`{name}` needs `page`, `x` and `y`"))),
}
}
fn select_column(s: &mut Session, v: &Value) -> CmdResult {
if let (Some(av), Some(fv)) = (v.get("anchor"), v.get("focus")) {
let a = parse_pos(av).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
let b = parse_pos(fv).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
let (a, b) = (s.doc.clamp(&a), s.doc.clamp(&b));
if a.story != b.story {
return Err(CmdError::Params("`anchor` and `focus` must be in the same story".into()));
}
let l = s.layout();
let xa = l.caret_on(&a, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`anchor` isn't laid out".into()))?;
let xb = l.caret_on(&b, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`focus` isn't laid out".into()))?;
make(s, a, b, xa, xb);
return result(s);
}
if let (Some(fv), Some(tv)) = (v.get("from"), v.get("to")) {
let (fp, fx, fy) = point(fv, "from")?;
let (tp, tx, ty) = point(tv, "to")?;
let story = super::story_param(s, v);
let l = s.layout();
let a = l.hit(fp, fx, fy, story).ok_or_else(|| CmdError::Params("no text at `from`".into()))?;
let b = l.hit(tp, tx, ty, story).ok_or_else(|| CmdError::Params("no text at `to`".into()))?;
s.page_hint = tp;
make(s, a, b, fx, tx);
return result(s);
}
// Ctrl+Shift+F8: toggle the mode; the block starts at the caret.
let on = p::bool(v, "on").unwrap_or(!s.column_mode);
s.column_mode = on;
if on {
let f = s.sel.focus.clone();
let x = s.layout().caret_on(&f, s.page_hint).map(|c| c.x).unwrap_or(0.0);
make(s, f.clone(), f, x, x);
s.status = "Column selection: the arrow keys extend the block, Esc ends it".into();
} else {
s.status.clear();
}
result(s)
}
/// Make the block between corners `a` and `b` whose page x are `xa` and `xb`.
fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) {
let (xa, xb) = (if xa.is_finite() { xa } else { 0.0 }, if xb.is_finite() { xb } else { 0.0 });
let (left, right) = (xa.min(xb), xa.max(xb));
let l = s.layout();
let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES);
s.sel = Selection { anchor: a, focus: b };
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments });
s.pending = None;
}
/// Column mode: the caret moved to `to`; grow the block to it (keeping the goal column on
/// Up/Down).
fn extend_to(s: &mut Session, to: Pos) {
let Some(block) = s.column.clone() else { return };
let x = match s.goal_x {
Some(gx) => gx,
None => s.layout().caret_on(&to, s.page_hint).map(|c| c.x).unwrap_or(block.focus_x),
};
make(s, block.sel.anchor, to, block.anchor_x, x);
}
/// Run a command with the column block in mind (called by `Session::run` for every command).
pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Session, &Value) -> CmdResult, v: &Value) -> CmdResult {
// A block the selection has moved away from is gone.
if s.column.as_ref().is_some_and(|c| c.sel != s.sel) {
s.column = None;
s.column_mode = false;
}
if s.column_mode && id.starts_with("caret.") && id != "caret.set" {
let mut params = v.clone();
match params.as_object_mut() {
Some(o) => {
o.insert("extend".into(), Value::Bool(true));
}
None => params = json!({"extend": true}),
}
run(s, &params)?;
let to = s.sel.focus.clone();
extend_to(s, to);
return result(s);
}
if matches!(id, "caret.set" | "select.collapse") || (mutates && !id.starts_with("format.") && !id.starts_with("para.")) {
s.column_mode = false;
}
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
match id {
"edit.copy" => copy(s, &segs),
"edit.cut" => {
let r = copy(s, &segs)?;
delete(s, &segs)?;
Ok(r)
}
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
delete(s, &segs)?;
super::sel_result(s)
}
"format.changeCase" | "format.clear" => per_row(s, run, v, &segs),
_ if id.starts_with("format.") || id.starts_with("para.") || id == "select.column" => run(s, v),
_ if mutates && (id.starts_with("text.") || id.starts_with("edit.paste")) => {
delete(s, &segs)?;
run(s, v)
}
_ if mutates => {
let at = segs.first().map(|(a, _)| s.doc.clamp(a)).unwrap_or_else(|| s.sel.focus.clone());
s.sel = Selection::caret(at);
s.column = None;
run(s, v)
}
_ => run(s, v),
}
}
/// Copy: one paragraph per block row (formatting kept); the plain text joins rows with newlines.
fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult {
let mut blocks = Vec::new();
let mut parts = std::collections::BTreeMap::new();
let mut rows = Vec::new();
for (a, b) in segs {
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
let frag = if a.path == b.path && a.story == b.story && a != b { s.doc.copy_range(&a, &b) } else { Fragment::default() };
// Text boxes in a row keep their stories (keyed by this document's part ids).
parts.extend(frag.parts);
let mut para = frag
.blocks
.into_iter()
.find_map(|bl| match bl {
Block::Para(p) => Some(p),
Block::Table(_) => None,
})
.unwrap_or_else(|| Paragraph::with_text("", CharProps::default()));
para.section = None;
rows.push(row_text(s, &a, &b));
blocks.push(Block::Para(para));
}
s.clipboard_text = rows.join("\n");
s.clipboard = Some(Fragment { blocks, parts });
Ok(json!({"text": s.clipboard_text}))
}
/// Delete each row's piece (bottom up, so earlier positions stay valid) and leave the caret at
/// the block's top-left corner.
fn delete(s: &mut Session, segs: &[(Pos, Pos)]) -> Result<(), CmdError> {
for (a, b) in segs.iter().rev() {
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
if a == b || a.path != b.path || a.story != b.story {
continue;
}
if s.doc.settings.track_changes {
super::track_delete(s, &a, &b)?;
} else {
s.doc.delete_range(&a, &b)?;
}
}
let at = segs.first().map(|(a, _)| s.doc.clamp(a)).unwrap_or_else(|| s.sel.focus.clone());
s.sel = Selection::caret(at);
s.column = None;
s.column_mode = false;
s.goal_x = None;
s.pending = None;
Ok(())
}
/// Run a selection command once per non-empty row, then restore the block.
fn per_row(s: &mut Session, run: fn(&mut Session, &Value) -> CmdResult, v: &Value, segs: &[(Pos, Pos)]) -> CmdResult {
let block = s.column.take();
for (a, b) in segs.iter().rev() {
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
if a == b || a.path != b.path {
continue;
}
s.sel = Selection { anchor: a, focus: b };
if let Err(e) = run(s, v) {
s.column = block;
return Err(e);
}
}
if let Some(c) = &block {
s.sel = c.sel.clone();
}
s.column = block;
result(s)
}

View File

@ -2,7 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::{Block, Pos, StoryRef};
use wordcraft_doc::{Pos, StoryRef};
use super::{delete_selection, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@ -32,10 +32,38 @@ pub fn specs() -> Vec<CommandSpec> {
.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.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
s.view.clipboard_pane = on;
Ok(json!({"value": on, "count": s.clip_history.len()}))
})
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("edit.clipboardItems", "Clipboard Items", "Home › Clipboard › Clipboard Pane", |s, _| Ok(s.clip_history.describe())).pure(),
CommandSpec::new("edit.pasteClipboardItem", "Paste Clipboard Item", "Home › Clipboard › Clipboard Pane", paste_clip_item)
.params(r#"{"index": n (0 = newest)}"#)
.when(has_clips),
CommandSpec::new("edit.pasteAllClipboard", "Paste All", "Home › Clipboard › Clipboard Pane", paste_all_clips).when(has_clips),
CommandSpec::new("edit.deleteClipboardItem", "Delete Clipboard Item", "Home › Clipboard › Clipboard Pane", |s, v| {
let i = clip_index(s, v)?;
s.clip_history.items.remove(i);
Ok(s.clip_history.describe())
})
.params(r#"{"index": n (0 = newest)}"#)
.when(has_clips)
.pure(),
CommandSpec::new("edit.clearClipboard", "Clear All", "Home › Clipboard › Clipboard Pane", |s, _| {
s.clip_history.items.clear();
Ok(s.clip_history.describe())
})
.pure(),
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.advancedFind", "Advanced Find", "Home › Editing › Find", advanced_find)
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool, "in"?: "main|selection", "highlight"?: 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").pure(),
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#),
@ -63,6 +91,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
}
let f = s.doc.copy_range(&a, &b);
s.clipboard_text = f.plain_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(json!({"text": s.clipboard_text}))
}
@ -81,24 +110,142 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult {
(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);
super::paste::insert(s, frag)?;
sel_result(s)
}
/// Most items the Clipboard pane keeps.
pub const CLIP_MAX_ITEMS: usize = 24;
/// A copy bigger than this (estimated) is not collected (it still reaches the clipboard).
const CLIP_ITEM_BYTES: usize = 4 << 20;
/// All collected items together stay under this; the oldest go first.
const CLIP_TOTAL_BYTES: usize = 24 << 20;
/// Characters of preview per item.
const CLIP_PREVIEW_CHARS: usize = 120;
/// One item collected by Copy or Cut.
#[derive(Clone, Debug)]
pub struct ClipItem {
pub fragment: Fragment,
/// Its plain text.
pub text: String,
/// Rough memory use.
bytes: usize,
}
impl ClipItem {
/// One line of the item's text for a list: whitespace runs become one space, objects drop out.
pub fn preview(&self) -> String {
let words = self.text.split(|c: char| c.is_whitespace() || c == wordcraft_doc::para::OBJ).filter(|w| !w.is_empty());
let mut out = String::new();
let mut n = 0;
for w in words {
if !out.is_empty() {
out.push(' ');
n += 1;
}
for c in w.chars() {
if n >= CLIP_PREVIEW_CHARS {
out.push('…');
return out;
}
out.push(c);
n += 1;
}
}
out
}
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
sel_result(s)
}
/// Items collected by Copy and Cut during the session, newest first: the Clipboard pane
/// (Home › Clipboard). At most [`CLIP_MAX_ITEMS`]; oversized copies are skipped.
#[derive(Clone, Debug, Default)]
pub struct ClipHistory {
items: Vec<ClipItem>,
}
impl ClipHistory {
pub fn items(&self) -> &[ClipItem] {
&self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
fn push(&mut self, f: &Fragment, text: &str) {
// Text plus per-paragraph overhead; pictures are references into the document.
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
if bytes > CLIP_ITEM_BYTES || f.blocks.is_empty() {
return;
}
// Copying the same thing again doesn't add a second item.
if self.items.first().is_some_and(|i| i.text == text && i.fragment == *f) {
return;
}
self.items.insert(0, ClipItem { fragment: f.clone(), text: text.to_string(), bytes });
self.items.truncate(CLIP_MAX_ITEMS);
while self.items.len() > 1 && self.items.iter().map(|i| i.bytes).sum::<usize>() > CLIP_TOTAL_BYTES {
self.items.pop();
}
}
/// The items for agents and the pane, newest first.
fn describe(&self) -> Value {
Value::Array(
self.items
.iter()
.enumerate()
.map(|(i, it)| {
json!({
"index": i,
"preview": it.preview(),
"chars": it.text.chars().count(),
"objects": it.text.matches(wordcraft_doc::para::OBJ).count(),
})
})
.collect(),
)
}
}
fn has_clips(s: &Session) -> Option<&'static str> {
if s.clip_history.is_empty() { Some("the Clipboard pane is empty") } else { None }
}
fn clip_index(s: &Session, v: &Value) -> Result<usize, CmdError> {
let i = p::u64(v, "index").ok_or_else(|| CmdError::Params("`index` (number, 0 = newest) is required".into()))?;
usize::try_from(i)
.ok()
.filter(|i| *i < s.clip_history.len())
.ok_or_else(|| CmdError::Params(format!("no clipboard item {i} (there are {})", s.clip_history.len())))
}
/// Paste a collected item through the normal paste path. The clipboard itself stays as it was.
fn paste_clip(s: &mut Session, f: Fragment) -> CmdResult {
let saved = s.clipboard.replace(f);
let r = paste(s, &json!({}));
s.clipboard = saved;
r
}
fn paste_clip_item(s: &mut Session, v: &Value) -> CmdResult {
let i = clip_index(s, v)?;
let f = s.clip_history.items.get(i).map(|x| x.fragment.clone()).ok_or_else(|| CmdError::Params("no such clipboard item".into()))?;
paste_clip(s, f)
}
/// Every collected item, in the order they were copied (one undo step).
fn paste_all_clips(s: &mut Session, _: &Value) -> CmdResult {
let all: Vec<Fragment> = s.clip_history.items.iter().rev().map(|x| x.fragment.clone()).collect();
let mut r = sel_result(s)?;
for f in all {
r = paste_clip(s, f)?;
}
Ok(r)
}
fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
@ -115,7 +262,7 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
/// paragraph without it: a match lies within one stretch of live text, and word
/// boundaries and anchors see the neighbouring live text, not the deleted text.
/// Otherwise Replace would find its own deleted text again.
fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
pub(crate) fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
use regex_automata::{Input, meta, util::syntax};
let f = &s.find;
if f.query.is_empty() {
@ -160,6 +307,8 @@ fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
}
fn read_opts(s: &mut Session, v: &Value) {
// A new search changes what Reading Highlight marks.
s.find.highlights = None;
if let Some(t) = p::str(v, "text") {
s.find.query = t.to_string();
}
@ -199,6 +348,35 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
}))
}
/// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read.
fn advanced_find(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "text").is_none() && v.get("highlight").is_none() {
s.ui_requests.push(json!({"open": "find"}));
return Ok(json!({"count": s.find.results.len()}));
}
read_opts(s, v);
let within = match p::str(v, "in").unwrap_or("main") {
"main" => None,
"selection" if !s.sel.is_collapsed() => Some(s.sel.ordered()),
"selection" => return Err(CmdError::Params("`in: selection` needs a selection".into())),
other => return Err(CmdError::Params(format!("`in` is main or selection, not `{other}`"))),
};
let story = within.as_ref().map(|(a, _)| a.story).unwrap_or(StoryRef::Body);
let mut found = search(s, story)?;
if let Some((a, b)) = &within {
found.retain(|(x, y)| x >= a && y <= b);
}
if let Some(h) = p::bool(v, "highlight") {
s.find.highlight = h;
s.find.highlights = None;
}
let n = found.len();
let matches: Vec<Value> = found.iter().take(200).map(|(a, b)| json!({"start": pos_json(a), "end": pos_json(b)})).collect();
s.find.current = 0;
s.find.results = found;
Ok(json!({"count": n, "matches": matches, "highlight": s.find.highlight}))
}
fn step(s: &mut Session, dir: i64) -> CmdResult {
let story = s.sel.focus.story;
s.find.results = search(s, story)?;

View File

@ -131,12 +131,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
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);
s.stamp_save();
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", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) {

View File

@ -160,8 +160,28 @@ fn target(s: &Session) -> Option<(Pos, Pos)> {
None
}
/// The non-empty rows of a column selection, if one is active.
fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
let segs = s.column_segments()?;
let rows: Vec<(Pos, Pos)> = segs
.iter()
.map(|(a, b)| (s.doc.clamp(a), s.doc.clamp(b)))
.filter(|(a, b)| a != b && a.path == b.path && a.story == b.story)
.map(|(a, b)| if a <= b { (a, b) } else { (b, a) })
.collect();
// An empty block (column mode just started) formats like a caret.
(!rows.is_empty()).then_some(rows)
}
/// Apply a change to the selection (or the pending caret formatting).
pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
// A column selection formats each row's piece.
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
s.doc.format_range(&a, &b, f)?;
}
return sel_result(s);
}
match target(s) {
Some((a, b)) => {
s.doc.format_range(&a, &b, f)?;
@ -186,6 +206,22 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
/// Resolved formatting of every character in the target (or the caret).
fn resolved(s: &Session) -> Vec<ResolvedChar> {
let mut out = Vec::new();
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
let Some(p) = s.doc.para_at(&a) else { continue };
for (r, c) in p.run_ranges() {
if r.end > a.off && r.start < b.off {
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c));
}
}
if out.len() > 10_000 {
break;
}
}
if !out.is_empty() {
return out;
}
}
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());

View File

@ -0,0 +1,145 @@
//! Style Inspector (Home › Styles): the paragraph style and the character style at the caret,
//! the direct formatting layered on each, and a way to clear each level.
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value, json};
use wordcraft_doc::props::{CharProps, ParaProps};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// The levels `styles.inspectorClear` can reset.
pub const LEVELS: [&str; 4] = ["paragraphStyle", "paragraphFormatting", "characterStyle", "characterFormatting"];
/// Direct character properties that aren't formatting (links, revisions, proofing, direction).
const CHAR_SKIP: [&str; 7] = ["style", "link", "ins", "del", "lang", "noProof", "rtl"];
/// Direct paragraph properties the inspector doesn't list (lists have their own UI).
const PARA_SKIP: [&str; 2] = ["style", "numbering"];
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("styles.inspector", "Style Inspector", "Home › Styles", |s, v| {
s.view.style_inspector = p::bool(v, "value").unwrap_or(!s.view.style_inspector);
let mut out = inspect(s);
if let Value::Object(m) = &mut out {
m.insert("pane".into(), json!(s.view.style_inspector));
}
Ok(out)
})
.params(r#"{"value"?: bool (show/hide the pane)}"#)
.pure(),
CommandSpec::new("styles.inspect", "Inspect Styles at Selection", "Home › Styles › Style Inspector", |s, _| Ok(inspect(s))).pure(),
CommandSpec::new("styles.inspectorClear", "Clear Formatting Level", "Home › Styles › Style Inspector", clear)
.params(r#"{"level": "paragraphStyle|paragraphFormatting|characterStyle|characterFormatting"}"#),
]
}
/// The paragraph props at the caret (the selection start for a selection).
fn para_props(s: &Session) -> ParaProps {
let (a, _) = s.sel.ordered();
s.doc.para_at(&a).map(|p| p.props.clone()).unwrap_or_default()
}
/// The direct character props at the caret (the first selected character for a selection).
fn char_props(s: &Session) -> CharProps {
let (a, b) = s.sel.ordered();
if a == b {
return s.typing_props();
}
match s.doc.para_at(&a) {
Some(p) if a.off < p.len() => p.props_of_char(a.off).clone(),
_ => s.typing_props(),
}
}
/// Each set field of `direct` (minus `skip`) whose value changes the result of `resolve`, given
/// a props value holding only that field. Returns `[{"prop", "value"}]` in field order.
fn differences<T: Serialize + DeserializeOwned>(direct: &T, skip: &[&str], base: &Value, resolve: &dyn Fn(T) -> Value) -> Vec<Value> {
let Ok(Value::Object(fields)) = serde_json::to_value(direct) else { return Vec::new() };
let mut out = Vec::new();
for (k, v) in fields {
if skip.contains(&k.as_str()) || v.is_null() {
continue;
}
let mut one = Map::new();
one.insert(k.clone(), v.clone());
let Ok(single) = serde_json::from_value::<T>(Value::Object(one)) else { continue };
if resolve(single) != *base {
out.push(json!({"prop": k, "value": v}));
}
}
out
}
fn style_name(s: &Session, id: &str) -> String {
s.doc.styles.get(id).map(|st| st.name.clone()).unwrap_or_else(|| id.to_string())
}
/// What the Style Inspector shows, as JSON.
pub fn inspect(s: &Session) -> Value {
let pp = para_props(s);
let pstyle = pp.style.clone().unwrap_or_else(|| "Normal".into());
let styles = &s.doc.styles;
let para_base = ParaProps { style: Some(pstyle.clone()), ..Default::default() };
let pbase = serde_json::to_value(styles.resolve_para(&para_base)).unwrap_or(Value::Null);
let pdiff = differences(&pp, &PARA_SKIP, &pbase, &|mut one: ParaProps| {
one.style = Some(pstyle.clone());
serde_json::to_value(styles.resolve_para(&one)).unwrap_or(Value::Null)
});
let cp = char_props(s);
let cstyle = cp.style.clone();
let char_base = CharProps { style: cstyle.clone(), ..Default::default() };
let cbase = serde_json::to_value(styles.resolve_char(Some(&pstyle), &char_base)).unwrap_or(Value::Null);
let cdiff = differences(&cp, &CHAR_SKIP, &cbase, &|mut one: CharProps| {
one.style = cstyle.clone();
serde_json::to_value(styles.resolve_char(Some(&pstyle), &one)).unwrap_or(Value::Null)
});
json!({
"paragraph": {
"style": pstyle,
"styleName": style_name(s, &pstyle),
"direct": pdiff,
},
"character": {
"style": cstyle,
"styleName": cstyle.as_deref().map(|c| style_name(s, c)),
"direct": cdiff,
},
})
}
fn clear(s: &mut Session, v: &Value) -> CmdResult {
let level = p::req_str(v, "level")?;
let (a, b) = s.sel.ordered();
match level {
"paragraphStyle" => {
// Reset to Normal, keeping direct formatting.
s.doc.format_paragraphs(&a, &b, &|p| p.style = None)?;
}
"paragraphFormatting" => {
s.doc.format_paragraphs(&a, &b, &|p| {
*p = ParaProps { style: p.style.clone(), numbering: p.numbering, ..Default::default() };
})?;
}
"characterStyle" => return super::format::apply(s, &|c| c.style = None),
"characterFormatting" => {
return super::format::apply(s, &|c| {
*c = CharProps {
style: c.style.clone(),
link: c.link.clone(),
ins: c.ins,
del: c.del,
lang: c.lang.clone(),
no_proof: c.no_proof,
rtl: c.rtl,
..Default::default()
};
});
}
other => return Err(CmdError::Params(format!("unknown level `{other}`; expected one of {}", LEVELS.join(", ")))),
}
sel_result(s)
}

View File

@ -2,20 +2,24 @@
pub mod caret;
pub mod citations;
pub mod column;
pub mod design;
pub mod edit;
pub mod equation;
pub mod file;
pub mod format;
pub mod insert;
pub mod inspector;
pub mod mailings;
pub mod objects;
pub mod page;
pub mod para;
pub mod paste;
pub mod references;
pub mod review;
pub mod speech;
pub mod table;
pub mod table_style;
pub mod text;
pub mod tools;
pub mod view;
@ -31,14 +35,18 @@ pub fn registry() -> Registry {
let mut v = Vec::new();
v.extend(text::specs());
v.extend(caret::specs());
v.extend(column::specs());
v.extend(edit::specs());
v.extend(paste::specs());
v.extend(format::specs());
v.extend(para::specs());
v.extend(inspector::specs());
v.extend(view::specs());
v.extend(insert::specs());
v.extend(equation::specs());
v.extend(page::specs());
v.extend(table::specs());
v.extend(table_style::specs());
v.extend(review::specs());
v.extend(file::specs());
v.extend(design::specs());
@ -116,7 +124,7 @@ pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Pa
}
/// Tracked deletion: own insertions are removed, other text is marked deleted.
fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
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.

View File

@ -22,10 +22,19 @@ fn has_object(s: &Session) -> Option<&'static str> {
}
fn has_floating(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Image { float, .. } | InlineObject::Shape { float, .. })) if float.wrap != Wrap::Inline => None,
Some((_, o)) if o.is_floating() => None,
_ => Some("select a floating picture or shape first"),
}
}
fn has_group(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Group { .. })) => None,
_ => Some("select a group first"),
}
}
fn can_group(s: &Session) -> Option<&'static str> {
if picked_objects(s).len() >= 2 { None } else { Some("select two or more pictures or shapes first (Shift+click)") }
}
fn has_shape(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Shape { .. })) => None,
@ -260,6 +269,13 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"dx"?: pt, "dy"?: pt}"#)
.when(has_floating),
CommandSpec::new("arrange.group", "Group", "Layout › Arrange", group)
.params(r#"{} (groups the selected pictures, shapes and text boxes: the selected one plus those added with select.addObject)"#)
.when(can_group),
CommandSpec::new("arrange.ungroup", "Ungroup", "Layout › Arrange", ungroup).when(has_group),
CommandSpec::new("select.addObject", "Add Object to Selection", "Home › Editing › Select", add_object)
.params(r#"{"pos"?: Pos, "index"?: n, "objects"?: [Pos | n]} (n: its Selection Pane index; with no object selected yet, the first one becomes the selection)"#)
.pure(),
CommandSpec::new("select.objects", "Select Objects", "Home › Editing › Select", |s, v| {
let n = p::u64(v, "index").unwrap_or(0) as usize;
let list = all_objects(s);
@ -320,7 +336,7 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
return None;
}
let o = s.doc.para_at(&a)?.object_at(a.off)?;
matches!(o, InlineObject::Image { .. } | InlineObject::Shape { .. }).then_some((a, o))
o.is_drawing().then_some((a, o))
}
/// The first picture/shape in the selection, or just before a collapsed caret.
@ -347,7 +363,7 @@ pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
if !inside {
continue;
}
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
out.push((Pos { story, path: path.clone(), off }, o.clone()));
if out.len() >= max {
return out;
@ -410,8 +426,14 @@ fn shape_effects(s: &mut Session, v: &Value) -> CmdResult {
Some(n @ Value::Number(_)) => Some(n.as_f64().map(|x| x as f32)),
Some(_) => return Err(CmdError::Params("`softEdge`: a radius in points or null".into())),
};
let targets: Vec<Pos> =
// The shapes in the selection and those added to it (Shift+click).
let mut targets: Vec<Pos> =
selected_all(s, MAX_EFFECT_SHAPES).into_iter().filter(|(_, o)| matches!(o, InlineObject::Shape { .. })).map(|(pos, _)| pos).collect();
for pos in picked_objects(s) {
if !targets.contains(&pos) && matches!(s.doc.para_at(&pos).and_then(|q| q.object_at(pos.off)), Some(InlineObject::Shape { .. })) {
targets.push(pos);
}
}
if targets.is_empty() {
return Err(CmdError::Disabled("no shape selected".into()));
}
@ -462,12 +484,7 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
let w = p::f32(v, "width").unwrap_or(w0).clamp(min, MAX_OFFSET);
let h = p::f32(v, "height").unwrap_or(h0).clamp(min, MAX_OFFSET);
if (w, h) != (w0, h0) {
with_obj(s, |o| {
if let InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } = o {
*ow = w;
*oh = h;
}
})?;
with_obj(s, |o| o.set_size(w, h))?;
}
let pos = match to {
Some((x, y)) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
@ -547,7 +564,7 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
fn unalign(s: &mut Session) -> Result<(), CmdError> {
use wordcraft_doc::para::Anchor;
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let (InlineObject::Image { float, .. } | InlineObject::Shape { float, .. }) = &obj else { return Ok(()) };
let Some((_, _, float)) = obj.frame() else { return Ok(()) };
if float.h_align.is_none() && float.v_align.is_none() {
return Ok(());
}
@ -596,10 +613,7 @@ fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Op
}
fn obj_size(o: &InlineObject) -> (f32, f32) {
match o {
InlineObject::Image { w, h, .. } | InlineObject::Shape { w, h, .. } => (*w, *h),
_ => (0.0, 0.0),
}
o.frame().map_or((0.0, 0.0), |(w, h, _)| (w, h))
}
fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
@ -620,15 +634,15 @@ fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result
fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result<InlineObject, CmdError> {
edit_obj(s, pos, |o| {
if let InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } = o {
if let Some(float) = o.float_mut() {
f(float);
}
})
}
fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
with_obj(s, |o| match o {
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => {
with_obj(s, |o| {
if let Some(float) = o.float_mut() {
let was_inline = float.wrap == Wrap::Inline;
f(float);
// Word's distance from text for a newly wrapped object: 0.125" at the sides.
@ -636,7 +650,6 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
float.dist = 9.0;
}
}
_ => {}
})
}
@ -836,12 +849,188 @@ fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
}
}
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
/// those added to it ([`Session::also_selected`]), each once, in the body.
pub fn picked_objects(s: &Session) -> Vec<Pos> {
let Some((first, _)) = object_selection(s) else { return Vec::new() };
let mut out = vec![first];
for p in &s.also_selected {
if !out.contains(p) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing) {
out.push(p.clone());
}
}
out
}
/// The object a `select.addObject` item names: a position, or a Selection Pane index.
fn object_ref(s: &Session, v: &Value) -> Result<Pos, CmdError> {
let pos = match v.as_u64() {
Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p),
None => super::parse_pos(v),
};
pos.filter(|p| p.story == StoryRef::Body && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
.ok_or_else(|| CmdError::Params("no picture or shape there".into()))
}
/// Add objects to the selection (Shift+click): the first one is selected if no object is yet;
/// one already selected is taken out again.
fn add_object(s: &mut Session, v: &Value) -> CmdResult {
let mut items: Vec<Value> = match v.get("objects").and_then(Value::as_array) {
Some(a) => a.iter().take(1000).cloned().collect(),
None => Vec::new(),
};
if let Some(p) = v.get("pos") {
items.push(p.clone());
}
if let Some(i) = v.get("index") {
items.push(i.clone());
}
if items.is_empty() {
return Err(CmdError::Params("`pos`, `index` or `objects` required".into()));
}
for it in &items {
let pos = object_ref(s, it)?;
match object_selection(s).map(|(p, _)| p) {
None => {
s.also_selected.clear();
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos } };
}
Some(first) if first == pos => {}
Some(_) => {
if let Some(i) = s.also_selected.iter().position(|p| *p == pos) {
s.also_selected.remove(i);
} else {
s.also_selected.push(pos);
}
}
}
}
Ok(json!({"selected": picked_objects(s).iter().map(super::pos_json).collect::<Vec<_>>()}))
}
/// Group the selected objects (Layout › Arrange › Group): one floating group object, anchored
/// where the first of them was, in the box around them all; they keep their places and sizes.
/// A group among them is merged in (its members join the new group).
fn group(s: &mut Session, _: &Value) -> CmdResult {
use wordcraft_doc::para::GroupChild;
let mut picks = picked_objects(s);
if picks.len() < 2 {
return Err(CmdError::Disabled("select two or more pictures or shapes first".into()));
}
picks.sort();
let layout = s.layout();
let mut found = Vec::new();
for pos in &picks {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only objects in the body text (not in tables) can be grouped".into()));
}
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
if !obj.is_floating() {
return Err(CmdError::Disabled("objects in line with text can't be grouped: choose a text wrapping for them first".into()));
}
let hit = layout.object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
found.push((obj, hit));
}
let page = found.first().map(|(_, h)| h.page).unwrap_or(0);
if found.iter().any(|(_, h)| h.page != page) {
return Err(CmdError::Disabled("objects on different pages can't be grouped".into()));
}
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (_, h) in &found {
(x0, y0) = (x0.min(h.rect.x), y0.min(h.rect.y));
(x1, y1) = (x1.max(h.rect.right()), y1.max(h.rect.bottom()));
}
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
let mut children = Vec::new();
let member = |obj: &InlineObject, r: [f32; 4]| {
let mut obj = obj.clone();
obj.set_size(r[2], r[3]);
if let Some(f) = obj.float_mut() {
*f = Float::default();
}
GroupChild { x: r[0] - x0, y: r[1] - y0, obj }
};
for (obj, hit) in &found {
let r = hit.rect;
match obj {
InlineObject::Group { .. } => children.extend(obj.group_rects(r.x, r.y, r.w, r.h).into_iter().map(|(cr, c)| member(c, cr))),
_ => children.push(member(obj, [r.x, r.y, r.w, r.h])),
}
}
let float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| *f).unwrap_or_default();
let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children };
// Take the members out, last first so the earlier positions hold, and put the group where
// the first one was.
let len = wordcraft_doc::para::OBJ.len_utf8();
for pos in picks.iter().rev() {
s.doc.delete_range(pos, &Pos { off: pos.off + len, ..pos.clone() })?;
}
let first = picks.first().cloned().ok_or_else(|| CmdError::Failed("nothing to group".into()))?;
let at = s.doc.clamp(&first);
s.doc.insert_object(&at, grouped, &wordcraft_doc::CharProps::default())?;
s.touch();
let pos = move_object(s, at, Some(page as u64), x0, y0, (w, h))?;
s.also_selected.clear();
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + len, ..pos.clone() } };
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
}
/// Split the selected group back into its pictures and shapes (Layout › Arrange › Ungroup), in
/// the places and sizes the group shows them, anchored where the group was. They stay
/// selected, so Group puts them back together.
fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a group first".into()))?;
let InlineObject::Group { w, h, float, .. } = &obj else { return Err(CmdError::Disabled("select a group first".into())) };
// Its offsets, explicit: the members are placed from the same anchor.
let pos = if float.wrap == Wrap::Inline {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only groups in the body text can be ungrouped in line with text".into()));
}
let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
move_object(s, pos, Some(hit.page as u64), hit.rect.x, hit.rect.y, (*w, *h))?
} else {
unalign(s)?;
pos
};
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let Some((w, h, float)) = obj.frame() else { return Err(CmdError::Failed("object vanished".into())) };
let float = *float;
let members: Vec<InlineObject> = obj
.group_rects(0.0, 0.0, w, h)
.into_iter()
.map(|([x, y, cw, ch], c)| {
let mut c = c.clone();
c.set_size(cw.max(min_size(&c)), ch.max(min_size(&c)));
if let Some(f) = c.float_mut() {
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float };
}
c
})
.collect();
if members.is_empty() {
return Err(CmdError::Failed("the group is empty".into()));
}
let len = wordcraft_doc::para::OBJ.len_utf8();
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
let mut at = s.doc.clamp(&pos);
let mut placed = Vec::new();
for m in members {
placed.push(at.clone());
at = s.doc.insert_object(&at, m, &wordcraft_doc::CharProps::default())?;
}
let first = placed.first().cloned().unwrap_or(pos);
s.sel = Selection { anchor: first.clone(), focus: Pos { off: first.off + len, ..first } };
s.also_selected = placed.into_iter().skip(1).collect();
sel_result(s)
}
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
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 off in p.object_offsets() {
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
v.push((Pos { story: StoryRef::Body, path: path.clone(), off }, o.clone()));
}
}
@ -858,6 +1047,7 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
let (kind, name) = match &o {
InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }),
InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)),
InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)),
_ => ("object", format!("Object {}", i + 1)),
};
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
@ -961,6 +1151,66 @@ mod tests {
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
}
/// The page rectangle of the object at `pos`.
fn rect_of(s: &mut Session, pos: &Pos) -> [f32; 4] {
let r = s.layout().object(pos, 0).unwrap().rect;
[r.x, r.y, r.w, r.h]
}
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5)
}
/// Two floating shapes, Shift+clicked together and grouped: one object that moves as one with
/// its members in place; ungrouping puts them back where the group showed them, still
/// selected, so Group regroups them.
#[test]
fn group_moves_as_one_and_ungroups() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "one\ntwo\nthree\nfour"})).unwrap();
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 150})).unwrap();
s.run("select.collapse", &json!({"end": true})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 60, "height": 40})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 300, "y": 200})).unwrap();
// One shape selected: nothing to group yet.
assert!(s.run("arrange.group", &json!({})).is_err());
let r = s.run("select.addObject", &json!({"index": 0})).unwrap();
assert_eq!(r["selected"].as_array().unwrap().len(), 2);
let g = s.run("arrange.group", &json!({})).unwrap();
let pos: Pos = serde_json::from_value(g["pos"].clone()).unwrap();
let list = s.run("arrange.selectionPane", &json!({})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 1, "{list}");
assert_eq!(list[0]["kind"], "group");
assert!(s.also_selected.is_empty());
assert!(near(rect_of(&mut s, &pos), [100.0, 150.0, 260.0, 90.0]), "{:?}", rect_of(&mut s, &pos));
// Moving the group moves its members with it.
s.run("arrange.nudge", &json!({"dx": 20, "dy": 10})).unwrap();
let (pos, obj) = selected(&s).unwrap();
let [x, y, w, h] = rect_of(&mut s, &pos);
let members: Vec<[f32; 4]> = obj.group_rects(x, y, w, h).into_iter().map(|(r, _)| r).collect();
assert!(near(members[0], [120.0, 160.0, 100.0, 50.0]) && near(members[1], [320.0, 210.0, 60.0, 40.0]), "{members:?}");
// Drawn there too: the page shows both shapes inside the group's frame.
let shapes = s.layout().pages[0].items.iter().filter(|i| matches!(i, wordcraft_layout::Placed::Shape { .. })).count();
assert_eq!(shapes, 2);
// Ungroup: two shapes again, where the group showed them, both still selected.
s.run("arrange.ungroup", &json!({})).unwrap();
let list = all_objects(&s);
assert_eq!(list.len(), 2);
let rects: Vec<[f32; 4]> = list.iter().map(|(p, _)| rect_of(&mut s, p)).collect();
assert!(rects.iter().any(|r| near(*r, [120.0, 160.0, 100.0, 50.0])), "{rects:?}");
assert!(rects.iter().any(|r| near(*r, [320.0, 210.0, 60.0, 40.0])), "{rects:?}");
assert_eq!(picked_objects(&s).len(), 2);
s.run("arrange.group", &json!({})).unwrap();
assert_eq!(all_objects(&s).len(), 1);
// Undo goes back step by step.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(all_objects(&s).len(), 2);
}
#[test]
fn picture_alt_and_crop_round_trip() {
let mut s = Session::new(wordcraft_doc::Document::new());

View File

@ -129,6 +129,9 @@ pub fn specs() -> Vec<CommandSpec> {
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.asianTypography", "Asian Typography", "Home › Paragraph › Asian Typography", asian_typography).params(
r#"{"kinsoku"?: bool (East Asian line-breaking rules), "wordWrap"?: bool (false: Latin words may break at any character), "overflowPunct"?: bool (hanging punctuation), "topLinePunct"?: bool (compress punctuation at the start of a line), "autoSpaceDE"?: bool (space between Asian and Latin text), "autoSpaceDN"?: bool (space between Asian text and numbers)} — flags left out stay as they are; returns the flags at the caret"#,
),
CommandSpec::new("para.tabs", "Tabs", "Home › Paragraph › Paragraph", |s, v| {
let tabs: Vec<TabStop> = serde_json::from_value(v.get("tabs").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
if tabs.len() > 64 {
@ -154,14 +157,20 @@ pub fn specs() -> Vec<CommandSpec> {
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()));
CommandSpec::new("styles.delete", "Delete Style", "Home › Styles", delete_style).params(r#"{"style": string (name or id; custom styles only)}"#),
CommandSpec::new("styles.manage", "Manage Styles", "Home › Styles", |s, v| {
let sort = p::str(v, "sort").unwrap_or("recommended");
if !matches!(sort, "recommended" | "alphabetical") {
return Err(CmdError::Params(format!("unknown sort `{sort}` (recommended|alphabetical)")));
}
s.doc.styles.styles.retain(|st| st.id != id);
sel_result(s)
}),
let list = manage_list(s, sort == "alphabetical");
s.ui_requests.push(json!({"open": "manageStyles"}));
Ok(list)
})
.params(r#"{"sort"?: "recommended|alphabetical"} → [{id, name, type: "paragraph|character|linked|table", builtIn, inGallery, hidden, basedOn, priority, format}]"#)
.pure(),
CommandSpec::new("styles.setVisibility", "Show or Hide Style", "Home › Styles", set_visibility)
.params(r#"{"style": string, "gallery"?: bool (Styles gallery, w:qFormat), "hidden"?: bool (hidden from style lists, w:semiHidden)}"#),
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(),
@ -240,6 +249,42 @@ fn tog(s: &mut Session, v: &Value, get: fn(&ParaProps) -> Option<bool>, set: fn(
fmt(s, &|p| set(p, on))
}
/// Asian Typography (the Paragraph dialog's tab): sets the given flags on every selected
/// paragraph and returns the resolved flags at the caret.
fn asian_typography(s: &mut Session, v: &Value) -> CmdResult {
let flag = |k: &str| p::bool(v, k);
let (kinsoku, word_wrap, overflow, top_line, de, dn) =
(flag("kinsoku"), flag("wordWrap"), flag("overflowPunct"), flag("topLinePunct"), flag("autoSpaceDE"), flag("autoSpaceDN"));
let mut out = if [kinsoku, word_wrap, overflow, top_line, de, dn].iter().any(Option::is_some) {
fmt(s, &|p| {
let set = |field: &mut Option<bool>, val: Option<bool>| {
if val.is_some() {
*field = val;
}
};
set(&mut p.kinsoku, kinsoku);
set(&mut p.word_wrap, word_wrap);
set(&mut p.overflow_punct, overflow);
set(&mut p.top_line_punct, top_line);
set(&mut p.auto_space_de, de);
set(&mut p.auto_space_dn, dn);
})?
} else {
sel_result(s)?
};
let r = cur(s);
if let Some(o) = out.as_object_mut() {
o.insert(
"asianTypography".into(),
json!({
"kinsoku": r.kinsoku, "wordWrap": r.word_wrap, "overflowPunct": r.overflow_punct,
"topLinePunct": r.top_line_punct, "autoSpaceDE": r.auto_space_de, "autoSpaceDN": r.auto_space_dn,
}),
);
}
Ok(out)
}
pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
let (a, b) = s.sel.ordered();
let paths = s.doc.paths_between(&a, &b);
@ -584,6 +629,155 @@ fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Delete a custom style. Text that uses it falls back to the style it was based on (or the
/// default style), styles based on it are rebased, and a custom character style linked to it
/// goes with it. Built-in styles are refused.
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
let id = style_id(s, p::req_str(v, "style")?)?;
let Some(st) = s.doc.styles.get(&id).cloned() else { return Err(CmdError::Params(format!("no style `{id}`"))) };
// Table styles: their tables take Table Grid and styles based on them keep their look.
if st.kind == StyleKind::Table {
let mut out = super::table_style::delete(s, &id)?;
if let Some(o) = out.as_object_mut() {
let tables = o.get("tables").cloned().unwrap_or(json!(0));
o.insert("deleted".into(), json!([id]));
o.insert("restyled".into(), tables);
}
return Ok(out);
}
if st.builtin {
return Err(CmdError::Failed(format!("`{}` is a built-in style and can't be deleted", st.name)));
}
let mut gone = vec![st.clone()];
if let Some(partner) = st.linked.as_deref().and_then(|l| s.doc.styles.get(l))
&& !partner.builtin
&& partner.linked.as_deref() == Some(id.as_str())
{
gone.push(partner.clone());
}
let mut changed = 0;
for g in &gone {
s.doc.styles.remove(&g.id);
// Fall back to the base only while it is a style of the same kind that still exists.
let base = g.based_on.clone().filter(|b| s.doc.styles.get(b).is_some_and(|x| x.kind == g.kind));
changed += s.doc.restyle(&g.id, base);
}
touch_all(s);
Ok(json!({"deleted": gone.iter().map(|g| g.id.clone()).collect::<Vec<_>>(), "restyled": changed}))
}
/// Styles gallery and style list visibility (`w:qFormat` / `w:semiHidden`).
fn set_visibility(s: &mut Session, v: &Value) -> CmdResult {
let id = style_id(s, p::req_str(v, "style")?)?;
let (gallery, hidden) = (p::bool(v, "gallery"), p::bool(v, "hidden"));
if gallery.is_none() && hidden.is_none() {
return Err(CmdError::Params("give `gallery` and/or `hidden`".into()));
}
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
if let Some(h) = hidden {
st.hidden = h;
}
if let Some(g) = gallery {
st.quick = g;
// Showing a style in the gallery unhides it.
if g {
st.hidden = false;
}
}
let out = json!({"style": st.id, "inGallery": st.quick && !st.hidden && st.kind != StyleKind::Table, "hidden": st.hidden});
touch_all(s);
Ok(out)
}
/// Manage Styles' list: paragraph, character, linked and table styles (a linked pair once, as
/// its paragraph half), sorted as recommended (priority, then name) or alphabetically.
pub fn manage_list(s: &Session, alphabetical: bool) -> Value {
let sheet = &s.doc.styles;
let links_back = |a: &Style, b: &str| sheet.get(b).is_some_and(|x| x.linked.as_deref() == Some(a.id.as_str()));
let mut list: Vec<&Style> = sheet
.styles
.iter()
.filter(|st| match st.kind {
StyleKind::Numbering => false,
// The character half of a linked pair is listed as the linked style.
StyleKind::Character => {
!st.linked.as_deref().is_some_and(|l| sheet.get(l).is_some_and(|x| x.kind == StyleKind::Paragraph) && links_back(st, l))
}
_ => true,
})
.collect();
if alphabetical {
list.sort_by_cached_key(|st| st.name.to_lowercase());
} else {
list.sort_by_cached_key(|st| (st.priority.unwrap_or(99), st.name.to_lowercase()));
}
Value::Array(
list.into_iter()
.map(|st| {
let ty = match st.kind {
// Linked: its character half links back (or isn't in the document yet).
StyleKind::Paragraph
if st.linked.as_deref().is_some_and(|l| sheet.get(l).is_none_or(|x| x.kind == StyleKind::Character && links_back(st, l))) =>
{
"linked"
}
StyleKind::Paragraph => "paragraph",
StyleKind::Character => "character",
_ => "table",
};
json!({
"id": st.id,
"name": st.name,
"type": ty,
"builtIn": st.builtin || (st.kind == StyleKind::Table && super::table_style::is_builtin_table_style(st)),
"inGallery": st.quick && !st.hidden && st.kind != StyleKind::Table,
"hidden": st.hidden,
"basedOn": st.based_on.as_deref().and_then(|b| sheet.get(b)).map(|b| b.name.clone()),
"priority": st.priority,
"format": own_format(st),
})
})
.collect(),
)
}
/// The formatting a style sets itself (not what it inherits), for its description.
fn own_format(st: &Style) -> Value {
let mut f = serde_json::Map::new();
let c = &st.chr;
if let Some(x) = &c.font {
f.insert("font".into(), json!(x));
}
if let Some(x) = c.size {
f.insert("size".into(), json!(x));
}
for (k, v) in [("bold", c.bold), ("italic", c.italic), ("caps", c.caps), ("smallCaps", c.small_caps)] {
if let Some(b) = v {
f.insert(k.into(), json!(b));
}
}
if let Some(wordcraft_doc::TextColor::Rgb(rgb)) = c.color {
f.insert("color".into(), json!(rgb.hex()));
}
let pp = &st.para;
if let Some(a) = pp.align {
f.insert("align".into(), json!(a));
}
if let Some(x) = pp.space_before {
f.insert("spaceBefore".into(), json!(x));
}
if let Some(x) = pp.space_after {
f.insert("spaceAfter".into(), json!(x));
}
if let Some(x) = pp.indent_left {
f.insert("indentLeft".into(), json!(x));
}
if let Some(LineSpacing::Multiple(m)) = pp.line_spacing {
f.insert("lineSpacing".into(), json!(m));
}
Value::Object(f)
}
/// 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) {

View File

@ -0,0 +1,215 @@
//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted).
//!
//! The clipboard can hold several representations of the same content. WordCraft's own copy keeps
//! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end
//! with a richer clipboard) may pass `html` or `rtf` too. Text that is itself RTF or HTML source
//! can also be pasted as that format.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::{Block, Document, Paragraph};
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// Largest clipboard payload Paste Special accepts, in bytes.
pub const MAX_PASTE_BYTES: usize = 16 << 20;
/// Nesting depth we follow when adjusting pasted tables.
const MAX_DEPTH: usize = 64;
/// The formats, in the order the dialog lists them: (id, label).
pub const FORMATS: [(&str, &str); 4] =
[("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("text", "Unformatted Text")];
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special)
.key("Mod+Alt+V")
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"text", "text"?: string, "html"?: string, "rtf"?: string}"#),
]
}
/// Clipboard text from the params (system clipboard), else WordCraft's own copy.
fn clip_text(s: &Session, v: &Value) -> Option<String> {
match p::str(v, "text") {
Some(t) => Some(t.replace("\r\n", "\n")),
None => s.clipboard.as_ref().map(Fragment::plain_text),
}
}
fn looks_like_rtf(t: &str) -> bool {
t.trim_start().starts_with("{\\rtf")
}
fn looks_like_html(t: &str) -> bool {
let t = t.trim_start();
t.starts_with('<') && (t.contains("</") || t.contains("/>"))
}
/// The source for a format, if the clipboard has it.
fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
let non_empty = |t: String| if t.is_empty() { None } else { Some(t) };
match fmt {
"formatted" => {
let f = s.clipboard.as_ref()?;
// System clipboard text that isn't our own copy means someone else copied since.
match p::str(v, "text") {
Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None,
_ => Some(String::new()),
}
}
"rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty),
"html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty),
"text" => clip_text(s, v).and_then(non_empty),
_ => None,
}
}
/// Formats the clipboard can be pasted as, best first.
pub fn available(s: &Session, v: &Value) -> Vec<&'static str> {
FORMATS.iter().map(|(id, _)| *id).filter(|id| source(s, v, id).is_some()).collect()
}
fn formats_json(ids: &[&str]) -> Value {
json!(ids.iter().map(|id| json!({"id": id, "label": FORMATS.iter().find(|(f, _)| f == id).map(|(_, l)| *l).unwrap_or(*id)})).collect::<Vec<_>>())
}
fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
for k in ["text", "html", "rtf"] {
if p::str(v, k).is_some_and(|t| t.len() > MAX_PASTE_BYTES) {
return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20)));
}
}
let ids = available(s, v);
let Some(fmt) = p::str(v, "as") else {
// No format chosen: list them and ask a front end (if any) to show the dialog.
let mut req = json!({"open": "pasteSpecial", "formats": ids});
for k in ["text", "html", "rtf"] {
if let Some(t) = p::str(v, k) {
req[k] = json!(t);
}
}
s.ui_requests.push(req);
return Ok(json!({"formats": formats_json(&ids)}));
};
let fmt = match fmt.to_ascii_lowercase().as_str() {
"formatted" | "keep" | "wordcraft" => "formatted",
"rtf" => "rtf",
"html" => "html",
"text" | "unformatted" | "plain" => "text",
other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html or text)"))),
};
let Some(src) = source(s, v, fmt) else {
return Err(CmdError::Failed(if ids.is_empty() {
"the clipboard is empty".into()
} else {
format!("the clipboard has no {fmt} content (available: {})", ids.join(", "))
}));
};
let frag = match fmt {
"text" => {
super::type_text(s, &src.replace('\n', "\r"))?;
let mut r = sel_result(s)?;
r["pastedAs"] = json!(fmt);
return Ok(r);
}
"formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?,
"html" => adopt(s, wordcraft_formats::html::import(&src)),
_ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?),
};
insert(s, frag)?;
let mut r = sel_result(s)?;
r["pastedAs"] = json!(fmt);
Ok(r)
}
/// Replace the selection with `frag`, marking it as an insertion under Track Changes.
pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
let at = delete_selection(s)?;
if s.doc.settings.track_changes {
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
for b in &mut frag.blocks {
each_para(b, 0, &mut |p| {
for r in &mut p.runs {
r.props.ins = Some(rid);
r.props.del = None;
}
});
}
}
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
Ok(())
}
/// Every paragraph in a block, tables included (bounded depth).
fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
if depth > MAX_DEPTH {
return;
}
match b {
Block::Para(p) => f(p),
Block::Table(t) => {
for row in &mut t.rows {
for cell in &mut row.cells {
for cb in &mut cell.blocks {
each_para(Arc::make_mut(cb), depth + 1, f);
}
}
}
}
}
}
/// Turn an imported document into a fragment of the session's document: bring over the media,
/// lists and styles it refers to (renumbered so they can't collide with ours).
fn adopt(s: &mut Session, src: Document) -> Fragment {
let mut media = BTreeMap::new();
for (key, data) in &src.media {
let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png");
media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext));
}
let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
let mut abs_map = BTreeMap::new();
for (i, a) in src.numbering.abstracts.iter().enumerate() {
let id = next_abs.saturating_add(i as u32);
abs_map.insert(a.id, id);
s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() });
}
let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
let mut num_map = BTreeMap::new();
for (i, n) in src.numbering.nums.iter().enumerate() {
let id = next_num.saturating_add(i as u32);
num_map.insert(n.id, id);
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() });
}
for st in &src.styles.styles {
if s.doc.styles.get(&st.id).is_none() {
s.doc.styles.upsert(st.clone());
}
}
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
for b in &mut blocks {
each_para(b, 0, &mut |p| {
if let Some(n) = p.props.numbering.as_mut()
&& n.num != 0
{
n.num = num_map.get(&n.num).copied().unwrap_or(0);
}
for o in &mut p.objects {
if let wordcraft_doc::InlineObject::Image { media: key, .. } = o
&& let Some(k) = media.get(key.as_str())
{
*key = k.clone();
}
}
});
}
// Text box stories the imported shapes show; pasting gives each a new story of its own.
let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect();
Fragment { blocks, parts }
}

View File

@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
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}`")))?;
let id = super::table_style::table_style_id(s, st).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?;
with_table(s, |t| t.props.style = Some(id.clone()))
})
.params(r#"{"style": string}"#)),
@ -242,7 +242,6 @@ fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResul
sel_result(s)
}
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
/// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the
/// caret cell's direction (horizontal → down → up) and gives every selected cell the result.
fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
@ -260,7 +259,8 @@ fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
with_cells(s, |cl| cl.props.text_direction = dir)
}
fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
pub(crate) fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
let story = s.sel.focus.story;
let paths: Vec<Path> = 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 {

View File

@ -0,0 +1,336 @@
//! Table Design › Table Styles: create, modify and delete table styles (whole table, header and
//! total rows, first and last columns, banded rows and columns), stored as table styles in the
//! document's style sheet (ECMA-376 §17.7.6).
use std::sync::Arc;
use serde_json::{Value, json};
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor};
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps};
use wordcraft_doc::{Block, Table};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// The regions a table style formats, by param name: the table style options they follow.
pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"];
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow`
/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) takes the same
/// shortcuts.
macro_rules! style_params {
($head:literal) => {
concat!(
"{",
$head,
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#,
r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"#
)
};
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)),
CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style)
.params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)),
CommandSpec::new("table.deleteStyle", "Delete Table Style", "Table Design › Table Styles", delete_style)
.params(r#"{"style"?: string (default: the current table's)}"#),
]
}
/// A table style's id by id or display name (case-insensitive); styles of other kinds don't count.
pub fn table_style_id(s: &Session, name: &str) -> Option<String> {
let st = &s.doc.styles.styles;
st.iter()
.find(|x| x.kind == StyleKind::Table && x.id == name)
.or_else(|| st.iter().find(|x| x.kind == StyleKind::Table && x.name.eq_ignore_ascii_case(name)))
.map(|x| x.id.clone())
}
/// The style of the table at the caret.
fn current_table_style(s: &Session) -> Option<String> {
let (tp, _, _) = s.sel.focus.path.cell()?;
s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()
}
/// The style `table.newStyle` would add for `v`, validated.
fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
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 based_on = match p::str(v, "basedOn") {
Some(b) => Some(table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?),
None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")),
};
let id = s.doc.styles.new_id(&name);
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
edit(&mut st, v)?;
Ok(st)
}
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
let st = build_new(s, v)?;
let id = st.id.clone();
s.doc.styles.upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(true) {
apply(s, &id)?;
}
Ok(json!({"id": id}))
}
/// The table style `v` names (`style`), else the current table's.
fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
match p::str(v, "style") {
Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`"))),
None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into())),
}
}
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
let st = build_modified(s, v)?;
let id = st.id.clone();
s.doc.styles.upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(false) {
apply(s, &id)?;
}
Ok(json!({"id": id}))
}
/// The style `table.modifyStyle` would store for `v`, validated.
fn build_modified(s: &Session, v: &Value) -> Result<Style, CmdError> {
let id = target(s, v)?;
let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if let Some(n) = &rename
&& s.doc.styles.find(n).is_some_and(|x| x.id != id)
{
return Err(CmdError::Failed(format!("a style named `{n}` already exists")));
}
let based_on = match p::str(v, "basedOn") {
Some(b) => {
let b = table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?;
// A style can't be based on itself or on a style based on it.
if s.doc.styles.chain(&b).iter().any(|x| x.id == id) {
return Err(CmdError::Params("a style can't be based on itself".into()));
}
Some(b)
}
None => None,
};
let mut st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
if let Some(n) = rename {
st.name = n;
}
if let Some(b) = based_on {
st.based_on = Some(b);
}
edit(&mut st, v)?;
Ok(st)
}
/// How table style `v["style"]` (or, without it, a new style) would look with the formatting
/// params of `table.modifyStyle` / `table.newStyle` applied, for a dialog's live preview. Names
/// aren't checked; `None` when the params don't validate.
pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
if !v.is_object() {
return None;
}
let mut v = v.clone();
let st = if p::str(&v, "style").is_some() {
if let Some(o) = v.as_object_mut() {
o.remove("name");
}
build_modified(s, &v).ok()?
} else {
// A name no style has: the look doesn't depend on it.
v["name"] = json!("\u{1}preview");
build_new(s, &v).ok()?
};
let id = st.id.clone();
let mut sheet = s.doc.styles.clone();
sheet.upsert(st);
sheet.table_style(&id)
}
/// Delete a custom table style. Styles based on it are re-based on its own base with its
/// formatting folded in, so they look the same; tables using it take Table Grid (the style new
/// tables get), or no style when the document has no Table Grid.
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
let id = target(s, v)?;
delete(s, &id)
}
/// Delete table style `id` (also what `styles.delete` runs for a table style): see
/// [`delete_style`].
pub fn delete(s: &mut Session, id: &str) -> CmdResult {
let id = id.to_string();
let st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
if st.kind != StyleKind::Table {
return Err(CmdError::Params(format!("`{}` isn't a table style", st.name)));
}
if is_builtin_table_style(&st) {
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
}
let mut rebased = Vec::new();
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
let mut para = st.para.clone();
para.overlay(&child.para);
child.para = para;
let mut chr = st.chr.clone();
chr.overlay(&child.chr);
child.chr = chr;
if child.kind == StyleKind::Table {
let mut parts = st.table.clone().unwrap_or_default();
if let Some(t) = &child.table {
parts.overlay(t);
}
child.table = (parts != TableStyleParts::default()).then_some(parts);
}
child.based_on = st.based_on.clone();
rebased.push(child.id.clone());
}
s.doc.styles.styles.retain(|x| x.id != id);
let fallback = table_style_id(s, "TableGrid");
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
for part in s.doc.parts.values_mut() {
tables += restyle(&mut part.blocks, &id, fallback.as_deref(), 0);
}
s.dirty = true;
Ok(json!({"deleted": id, "tables": tables, "rebased": rebased}))
}
/// Whether `st` is one of Word's built-in table styles. A .docx can mark any style custom
/// (`w:customStyle`), so Table Grid and Normal Table are recognised by id and name too: deleting
/// them would leave new tables (and the fallback for deleted styles) without a style to take.
pub fn is_builtin_table_style(st: &Style) -> bool {
const IDS: [&str; 2] = ["TableGrid", "TableNormal"];
const NAMES: [&str; 3] = ["Table Grid", "Normal Table", "Table Normal"];
st.builtin || IDS.iter().any(|x| st.id.eq_ignore_ascii_case(x)) || NAMES.iter().any(|x| st.name.eq_ignore_ascii_case(x))
}
/// Tables nest at most this deep for restyling.
const MAX_DEPTH: usize = 32;
/// Whether table `t`, or one nested in it, uses style `id`.
fn uses_style(t: &Table, id: &str, depth: usize) -> bool {
t.props.style.as_deref() == Some(id)
|| (depth < MAX_DEPTH
&& t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).any(|b| b.as_table().is_some_and(|t| uses_style(t, id, depth + 1))))
}
/// Give every table in `blocks` that uses style `from` style `to`; the number restyled.
fn restyle(blocks: &mut [Arc<Block>], from: &str, to: Option<&str>, depth: usize) -> usize {
let mut n = 0;
for b in blocks {
if !b.as_table().is_some_and(|t| uses_style(t, from, depth)) {
continue;
}
if let Block::Table(t) = Arc::make_mut(b) {
if t.props.style.as_deref() == Some(from) {
t.props.style = to.map(str::to_string);
n += 1;
}
if depth < MAX_DEPTH {
for c in t.rows.iter_mut().flat_map(|r| &mut r.cells) {
n += restyle(&mut c.blocks, from, to, depth + 1);
}
}
}
}
n
}
/// Apply style `id` to the table at the caret, if any.
fn apply(s: &mut Session, id: &str) -> Result<(), CmdError> {
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Ok(()) };
s.doc.table_mut(s.sel.focus.story, &tp)?.props.style = Some(id.to_string());
super::table::touch_cells(s, &tp)
}
/// Apply the formatting params to `st`.
fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
let mut parts = st.table.take().unwrap_or_default();
if let Some(r) = v.get("wholeTable") {
region(r, &mut parts.fill, &mut st.chr, &mut parts.borders)?;
}
if let Some(r) = v.get("headerRow") {
region(r, &mut parts.header_fill, &mut parts.header_chr, &mut parts.header_borders)?;
}
if let Some(r) = v.get("bandedRows") {
region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?;
}
if let Some(r) = v.get("lastRow") {
region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?;
}
if let Some(r) = v.get("firstColumn") {
region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?;
}
if let Some(r) = v.get("lastColumn") {
region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?;
}
if let Some(r) = v.get("bandedColumns") {
region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?;
}
if let Some(n) = v.get("bandSize") {
parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32);
}
if let Some(pp) = v.get("para") {
let pp: ParaProps = serde_json::from_value(pp.clone()).map_err(|e| CmdError::Params(format!("para: {e}")))?;
st.para.overlay(&pp);
st.para.style = None;
st.para.numbering = None;
}
st.table = (parts != TableStyleParts::default()).then_some(parts);
Ok(())
}
/// One region's shortcuts: borders, fill and character formatting.
fn region(r: &Value, fill: &mut Option<Rgb>, chr: &mut CharProps, borders: &mut Option<Borders>) -> Result<(), CmdError> {
if !r.is_object() {
return Err(CmdError::Params("a region is an object".into()));
}
let color = |k: &str| -> Result<Option<Rgb>, CmdError> {
match r.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))),
Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))),
}
};
if r.get("fill").is_some() {
*fill = color("fill")?;
}
if r.get("color").is_some() {
chr.color = color("color")?.map(TextColor::Rgb);
}
if let Some(b) = p::bool(r, "bold") {
chr.bold = Some(b);
chr.bold_cs = Some(b);
}
if let Some(b) = p::bool(r, "italic") {
chr.italic = Some(b);
chr.italic_cs = Some(b);
}
if let Some(sz) = p::f32(r, "size") {
chr.size = Some(sz.clamp(1.0, 1638.0));
chr.size_cs = chr.size;
}
if let Some(f) = p::str(r, "font").map(str::trim).filter(|f| !f.is_empty()) {
chr.font = Some(f.to_string());
}
match r.get("borders") {
Some(Value::Bool(true)) => {
let width = p::f32(r, "borderWidth").unwrap_or(0.5).clamp(0.25, 6.0);
*borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color("borderColor")?, space: 0.0 }));
}
Some(Value::Bool(false)) => *borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
Some(Value::Null) => *borders = None,
Some(_) => return Err(CmdError::Params("`borders` is true, false or null".into())),
None => {}
}
Ok(())
}

View File

@ -33,18 +33,25 @@ pub fn specs() -> Vec<CommandSpec> {
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(),
// The drawing grid over the page (screen only; never printed or exported).
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines))
.params(r#"{"value"?: bool}"#)
.pure(),
// Outlines of table cells (screen only; never printed or exported).
CommandSpec::new("table.viewGridlines", "View Gridlines", "Table Layout › Table", |s, v| toggle(s, v, |x| &mut x.table_gridlines))
.params(r#"{"value"?: bool}"#)
.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, _| {
CommandSpec::new("view.zoomIn", "Zoom In", "View › Zoom", |s, _| {
let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0);
zoom(s, &json!({"value": z * 100.0}))
})
.pure(),
CommandSpec::new("view.zoomOut", "Zoom Out", "Status Bar", |s, _| {
CommandSpec::new("view.zoomOut", "Zoom Out", "View › Zoom", |s, _| {
let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1);
zoom(s, &json!({"value": z * 100.0}))
})

View File

@ -19,7 +19,7 @@ use std::collections::HashMap;
use serde_json::Value;
pub use session::{EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
pub use wordcraft_doc as doc;
pub use wordcraft_layout as layout;
pub use wordcraft_render as render;

View File

@ -31,6 +31,19 @@ impl Selection {
}
}
/// A column (block) selection: the same x range on every line between two corners.
#[derive(Clone, Debug, PartialEq)]
pub struct ColumnBlock {
/// The stream selection the block was made with (its corners). The block is current only
/// while `Session::sel` still equals it, so any ordinary caret move or selection drops it.
pub sel: Selection,
/// Page x of the anchor and focus corners (points); the block spans the range between.
pub anchor_x: f32,
pub focus_x: f32,
/// One `(start, end)` per line, in document order, each inside one paragraph.
pub segments: Vec<(Pos, Pos)>,
}
/// View state that commands can change (ribbon View tab, status bar).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -41,10 +54,20 @@ pub struct ViewState {
pub focus_mode: bool,
pub marks: bool,
pub ruler: bool,
/// View › Show › Gridlines: the drawing grid over the page's text area (on screen only).
pub gridlines: bool,
/// Table Layout › View Gridlines: outlines of table cells (on screen only).
#[serde(default)]
pub table_gridlines: bool,
pub nav_pane: bool,
pub styles_pane: bool,
/// Style Inspector pane (Home › Styles).
#[serde(default)]
pub style_inspector: bool,
pub comments_pane: bool,
/// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut.
#[serde(default)]
pub clipboard_pane: bool,
pub multi_page: bool,
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
pub fit: String,
@ -66,9 +89,12 @@ impl Default for ViewState {
marks: false,
ruler: true,
gridlines: false,
table_gridlines: false,
nav_pane: false,
styles_pane: false,
style_inspector: false,
comments_pane: false,
clipboard_pane: false,
multi_page: false,
fit: String::new(),
web_width: 800.0,
@ -93,6 +119,11 @@ pub struct FindState {
#[serde(skip)]
pub results: Vec<(Pos, Pos)>,
pub current: usize,
/// Reading Highlight: mark every match of `query` in the body.
pub highlight: bool,
/// Highlighted matches, for the document revision they were found in.
#[serde(skip)]
pub highlights: Option<(u64, Vec<(Pos, Pos)>)>,
}
#[derive(Clone)]
@ -152,6 +183,8 @@ pub struct Session {
pub clipboard: Option<Fragment>,
/// Plain text mirror of the clipboard (for the system clipboard).
pub clipboard_text: String,
/// Items collected by Copy and Cut this session, for the Clipboard pane.
pub clip_history: crate::cmd::edit::ClipHistory,
pub find: FindState,
/// Page x the caret tries to keep on Up/Down.
pub goal_x: Option<f32>,
@ -210,6 +243,13 @@ pub struct Session {
pub math_latex: bool,
/// Text typed into equations is normal (non-math) text.
pub math_normal_text: bool,
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
/// for Group. Cleared by any edit or selection change other than adding to it.
pub also_selected: Vec<Pos>,
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
pub column: Option<ColumnBlock>,
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
pub column_mode: bool,
}
/// The equation being edited.
@ -250,6 +290,7 @@ impl Session {
dirty: false,
clipboard: None,
clipboard_text: String::new(),
clip_history: Default::default(),
find: FindState::default(),
goal_x: None,
page_hint: 0,
@ -281,11 +322,20 @@ impl Session {
math: None,
math_latex: false,
math_normal_text: false,
also_selected: Vec::new(),
prefs: Prefs::default(),
read_aloud: Default::default(),
column: None,
column_mode: false,
}
}
/// The line pieces of the current column selection, if there is one (the selection hasn't
/// moved since the block was made).
pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> {
self.column.as_ref().filter(|c| c.sel == self.sel).map(|c| c.segments.as_slice())
}
/// Document revision (bumped by every change).
pub fn rev(&self) -> u64 {
self.rev
@ -299,6 +349,18 @@ impl Session {
self.dirty = true;
}
/// Reading Highlight ranges for the current document (searched again after edits); empty when it's off.
pub fn find_highlights(&mut self) -> &[(Pos, Pos)] {
if !self.find.highlight {
return &[];
}
if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) {
let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default();
self.find.highlights = Some((self.rev, found));
}
self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[])
}
/// The current layout (recomputed when the document or view changed).
pub fn layout(&mut self) -> Arc<DocLayout> {
let ww = self.view.web_width;
@ -362,6 +424,18 @@ impl Session {
pub fn join_next_undo(&mut self) {
self.join_next = true;
}
/// Stamp the document's save metadata (modified time, last modified by, revision; created
/// when it has none) the way Word does on every save. `file.save` calls it, and so does any
/// front end that writes the bytes itself (the web build's download, #262).
pub fn stamp_save(&mut self) {
let core = &mut self.doc.core;
core.modified = crate::cmd::now_iso();
if core.created.is_empty() {
core.created = core.modified.clone();
}
core.last_modified_by = self.author.clone();
core.revision = core.revision.saturating_add(1);
}
/// Close an open typing group (caret moved, other command).
pub fn close_typing(&mut self) {
self.typing_open = false;
@ -415,6 +489,9 @@ impl Session {
self.doc.ensure_nonempty();
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
self.pending = None;
self.also_selected.clear();
self.column = None;
self.column_mode = false;
self.reset_history();
self.touch();
self.dirty = false;
@ -447,6 +524,7 @@ impl Session {
dirty,
clipboard: _,
clipboard_text: _,
clip_history: _,
find: _,
goal_x: _,
page_hint: _,
@ -481,6 +559,10 @@ impl Session {
math: _,
math_latex: _,
math_normal_text: _,
also_selected: _,
// Column selection, like `sel`'s shape: valid only while `sel` matches it.
column: _,
column_mode: _,
} = self;
let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() };
EditSnapshot {
@ -509,6 +591,7 @@ impl Session {
self.undo_evicted = evicted;
self.doc = doc;
self.sel = sel;
self.also_selected.clear();
self.history.truncate(history_len);
self.redo = redo;
self.typing_open = typing_open;
@ -581,7 +664,15 @@ impl Session {
// The undo stacks are snapshotted whole, not as a length: commands run nested commands,
// whose checkpoints, undos and redos (and evictions at the limit) must be put back too.
let before_doc = if spec.mutates {
Some((self.doc.clone(), self.sel.clone(), self.history.clone(), self.redo.clone(), self.typing_open, self.undo_evicted))
Some((
self.doc.clone(),
self.sel.clone(),
self.history.clone(),
self.redo.clone(),
self.typing_open,
self.undo_evicted,
self.column.clone(),
))
} else {
None
};
@ -599,8 +690,10 @@ impl Session {
} else if spec.id.starts_with("caret.") {
self.typing_open = false;
}
let sel_before = self.sel.clone();
let run = spec.run;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params)));
let mutates = spec.mutates;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
let result = match result {
Ok(r) => r,
Err(p) => {
@ -616,9 +709,13 @@ impl Session {
self.doc.prune_text_boxes();
}
self.clamp_selection();
// Extra selected objects last until something else is edited or selected.
if !matches!(id, "select.addObject" | "arrange.ungroup") && (spec.mutates || self.sel != sel_before) {
self.also_selected.clear();
}
}
Err(e) => {
if let Some((d, s, h, r, t, ev)) = before_doc {
if let Some((d, s, h, r, t, ev, c)) = before_doc {
self.doc = d;
self.sel = s;
self.history = h;
@ -626,6 +723,7 @@ impl Session {
self.typing_open = t;
// The steps the command pushed out are back, so they no longer count as gone.
self.undo_evicted = ev;
self.column = c;
}
self.status = e.to_string();
}

View File

@ -523,6 +523,151 @@ fn clipboard_round_trip() {
assert_eq!(text(&s), "plain\ntext");
}
fn bold_at(s: &Session, block: usize, off: usize) -> bool {
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
}
#[test]
fn paste_special_lists_formats_and_pastes_text_without_formatting() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Bold words"}));
run(&mut s, "select.text", json!({"text": "Bold"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.copy", json!({}));
// No format: the formats are listed (and a front end is asked to show the dialog).
let r = run(&mut s, "edit.pasteSpecial", json!({}));
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["formatted", "text"]);
assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial");
run(&mut s, "caret.docEnd", json!({}));
let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"}));
assert_eq!(r["pastedAs"], "text");
assert_eq!(text(&s), "Bold wordsBold");
assert!(bold_at(&s, 0, 0));
assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold");
// Formatted keeps it.
run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"}));
assert_eq!(text(&s), "Bold wordsBoldBold");
assert!(bold_at(&s, 0, 15));
// Text that someone else copied since: our rich copy is no longer offered.
let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"}));
assert_eq!(r["formats"].as_array().unwrap().len(), 1);
assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err());
}
#[test]
fn paste_special_html_and_rtf_keep_bold() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "x"}));
run(&mut s, "caret.docEnd", json!({}));
let html = "<p>plain <b>strong</b></p>";
let r = run(&mut s, "edit.pasteSpecial", json!({"html": html, "text": "plain strong"}));
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["html", "text"]);
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": html}));
assert_eq!(text(&s), "xplain strong");
assert!(!bold_at(&s, 0, 2));
assert!(bold_at(&s, 0, 8), "the <b> run stays bold");
// HTML source copied as text can be pasted as HTML too.
run(&mut s, "document.setText", json!({"text": ""}));
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "text": "<b>hi</b> there</p>"}));
assert_eq!(text(&s), "hi there");
assert!(bold_at(&s, 0, 0) && !bold_at(&s, 0, 4));
// RTF.
run(&mut s, "document.setText", json!({"text": ""}));
run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"}));
assert!(text(&s).starts_with("bold"), "{}", text(&s));
assert!(bold_at(&s, 0, 0));
// Asking for a format the clipboard lacks is an error, not a panic.
assert!(s.run("edit.pasteSpecial", &json!({"as": "rtf", "text": "plain"})).is_err());
assert!(s.run("edit.pasteSpecial", &json!({"as": "bogus", "text": "plain"})).is_err());
let huge = "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES + 1);
assert!(s.run("edit.pasteSpecial", &json!({"as": "text", "text": huge})).is_err());
}
#[test]
fn paste_special_respects_track_changes_and_brings_lists() {
let mut s = s();
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": "<ul><li>one</li><li>two</li></ul>"}));
assert!(text(&s).starts_with("one\ntwo"), "{}", text(&s));
let ch = run(&mut s, "review.changes", json!({}));
assert!(!ch.as_array().unwrap().is_empty(), "the paste is a tracked insertion");
let first = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
let n = first.props.numbering.expect("list kept");
assert!(s.doc.numbering.nums.iter().any(|x| x.id == n.num), "the list definition came along");
run(&mut s, "review.acceptAll", json!({}));
assert!(text(&s).starts_with("one\ntwo"));
}
#[test]
fn clipboard_pane_collects_copies_and_pastes_one_undoably() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one two three"}));
for (a, b) in [(0, 3), (4, 7), (8, 13)] {
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}}));
run(&mut s, "edit.copy", json!({}));
}
// Copying the same text again adds nothing.
run(&mut s, "edit.copy", json!({}));
let items = run(&mut s, "edit.clipboardItems", json!({}));
let previews: Vec<&str> = items.as_array().unwrap().iter().map(|i| i["preview"].as_str().unwrap()).collect();
assert_eq!(previews, ["three", "two", "one"], "newest first");
assert_eq!(run(&mut s, "edit.clipboardPane", json!({}))["value"], true);
assert!(s.view.clipboard_pane);
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "edit.pasteClipboardItem", json!({"index": 1}));
assert_eq!(text(&s), "one two threetwo");
assert_eq!(s.clipboard_text, "three", "the clipboard itself is unchanged");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one two three");
run(&mut s, "edit.pasteAllClipboard", json!({}));
assert_eq!(text(&s), "one two threeonetwothree", "Paste All goes in copy order");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one two three", "Paste All is one undo step");
let left = run(&mut s, "edit.deleteClipboardItem", json!({"index": 0}));
assert_eq!(left[0]["preview"], "two");
run(&mut s, "edit.clearClipboard", json!({}));
assert!(s.clip_history.is_empty());
assert!(s.run("edit.pasteClipboardItem", &json!({"index": 0})).is_err());
}
#[test]
fn clipboard_pane_is_capped_and_rejects_bad_indexes() {
let mut s = s();
let words: Vec<String> = (0..30).map(|i| format!("w{i}")).collect();
run(&mut s, "document.setText", json!({"text": words.join(" ")}));
// Cutting each word in turn leaves the spaces: word `off` starts at `off`.
for (off, w) in words.iter().enumerate() {
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": off}, "focus": {"block": 0, "off": off + w.len()}}));
run(&mut s, "edit.cut", json!({}));
}
let items = run(&mut s, "edit.clipboardItems", json!({}));
let items = items.as_array().unwrap();
assert_eq!(items.len(), crate::cmd::edit::CLIP_MAX_ITEMS);
assert_eq!(items[0]["preview"], "w29");
assert_eq!(items[23]["preview"], "w6", "the oldest drop out");
let before = text(&s);
for bad in [json!({"index": 24}), json!({"index": -1}), json!({"index": 1e300}), json!({"index": "x"}), json!({})] {
assert!(s.run("edit.pasteClipboardItem", &bad).is_err(), "{bad}");
assert!(s.run("edit.deleteClipboardItem", &bad).is_err(), "{bad}");
}
assert_eq!(text(&s), before);
assert_eq!(s.clip_history.len(), 24);
// A huge copy still reaches the clipboard but is not collected.
run(&mut s, "document.setText", json!({"text": "x".repeat(3 << 20)}));
run(&mut s, "select.all", json!({}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard_text.len(), 3 << 20);
assert_eq!(s.clip_history.items()[0].text, "w29");
}
#[test]
fn tables_commands() {
let mut s = s();
@ -1687,6 +1832,357 @@ fn styles_apply_rejected_on_protected_document() {
assert_eq!(s.undo_labels(), undo);
}
fn column_doc() -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "abcdef\nabcdef\nabcdef"}));
s
}
fn block(s: &mut Session) {
let a = serde_json::to_value(Pos::body(0, 2)).unwrap();
let f = serde_json::to_value(Pos::body(2, 4)).unwrap();
let r = run(s, "select.column", json!({"anchor": a, "focus": f}));
assert_eq!(r["rows"], json!(["cd", "cd", "cd"]));
}
#[test]
fn column_selection_copies_the_block() {
let mut s = column_doc();
block(&mut s);
let r = run(&mut s, "edit.copy", json!({}));
assert_eq!(r["text"], "cd\ncd\ncd");
assert_eq!(s.clipboard.as_ref().map(|f| f.blocks.len()), Some(3));
// A caret move drops the block; pasting the copy gives three paragraphs.
run(&mut s, "caret.docEnd", json!({}));
assert!(s.column_segments().is_none());
s.autocorrect_on = false;
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef\ncd\ncd\ncd");
}
#[test]
fn column_selection_deletes_only_the_block() {
let mut s = column_doc();
block(&mut s);
run(&mut s, "text.delete", json!({}));
assert_eq!(text(&s), "abef\nabef\nabef");
assert_eq!(s.sel, crate::Selection::caret(Pos::body(0, 2)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef");
// Cut copies and deletes; typing replaces the block.
block(&mut s);
let r = run(&mut s, "edit.cut", json!({}));
assert_eq!(r["text"], "cd\ncd\ncd");
assert_eq!(text(&s), "abef\nabef\nabef");
run(&mut s, "edit.undo", json!({}));
block(&mut s);
run(&mut s, "text.insert", json!({"text": "X"}));
assert_eq!(text(&s), "abXef\nabef\nabef");
}
#[test]
fn column_selection_formats_each_row() {
let mut s = column_doc();
block(&mut s);
run(&mut s, "format.bold", json!({}));
for i in 0..3 {
let p = s.doc.para_at(&Pos::body(i, 0)).unwrap();
assert_ne!(p.props_of_char(1).bold, Some(true));
assert_eq!(p.props_of_char(2).bold, Some(true));
assert_eq!(p.props_of_char(3).bold, Some(true));
assert_ne!(p.props_of_char(4).bold, Some(true));
}
// The block survives formatting, and a second Bold turns it off again.
assert!(s.column_segments().is_some());
run(&mut s, "format.bold", json!({}));
assert_ne!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props_of_char(2).bold, Some(true));
run(&mut s, "format.changeCase", json!({"mode": "upper"}));
assert_eq!(text(&s), "abCDef\nabCDef\nabCDef");
}
#[test]
fn column_mode_extends_with_the_arrow_keys() {
let mut s = column_doc();
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}}));
let r = run(&mut s, "select.column", json!({}));
assert_eq!(r["columnMode"], true);
run(&mut s, "caret.right", json!({}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "caret.down", json!({}));
let r = run(&mut s, "caret.down", json!({}));
assert_eq!(r["rows"], json!(["bc", "bc", "bc"]));
run(&mut s, "text.backspace", json!({}));
assert_eq!(text(&s), "adef\nadef\nadef");
assert!(!s.column_mode);
// Escape leaves the mode.
run(&mut s, "select.column", json!({}));
run(&mut s, "select.collapse", json!({}));
assert!(!s.column_mode);
}
#[test]
fn column_selection_from_points() {
let mut s = column_doc();
let l = s.layout();
let a = l.caret(&Pos::body(0, 1)).unwrap();
let b = l.caret(&Pos::body(1, 5)).unwrap();
let r = run(
&mut s,
"select.column",
json!({"from": {"page": a.page, "x": a.x, "y": a.top + 2.0}, "to": {"page": b.page, "x": b.x, "y": b.top + 2.0}}),
);
assert_eq!(r["rows"], json!(["bcde", "bcde"]));
for bad in [
json!({"from": {"page": 99, "x": 1, "y": 1}, "to": {"page": 0, "x": 1, "y": 1}}),
json!({"from": 1, "to": "x"}),
json!({"anchor": 1, "focus": {}}),
] {
assert!(s.run("select.column", &bad).is_err());
}
}
#[test]
fn style_inspector_reports_and_clears_levels() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Chapter one"}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({"value": true}));
run(&mut s, "para.alignCenter", json!({}));
let r = run(&mut s, "styles.inspect", json!({}));
assert_eq!(r["paragraph"]["style"], "Heading1");
assert_eq!(r["paragraph"]["styleName"], "Heading 1");
let pdirect = r["paragraph"]["direct"].as_array().unwrap();
assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}");
assert!(r["character"]["style"].is_null());
let cdirect = r["character"]["direct"].as_array().unwrap();
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true})]);
// Formatting that matches the style isn't a difference (Heading 1 is 20 pt).
run(&mut s, "format.size", json!({"size": 20}));
let r = run(&mut s, "styles.inspect", json!({}));
assert_eq!(r["character"]["direct"].as_array().unwrap().len(), 1, "{r}");
// Character style level.
run(&mut s, "format.charStyle", json!({"style": "Emphasis"}));
let r = run(&mut s, "styles.inspect", json!({}));
assert_eq!(r["character"]["style"], "Emphasis");
assert_eq!(r["character"]["styleName"], "Emphasis");
run(&mut s, "styles.inspectorClear", json!({"level": "characterStyle"}));
assert!(run(&mut s, "styles.inspect", json!({}))["character"]["style"].is_null());
// Clearing character formatting removes the bold and keeps the paragraph levels.
run(&mut s, "styles.inspectorClear", json!({"level": "characterFormatting"}));
let r = run(&mut s, "styles.inspect", json!({}));
assert!(r["character"]["direct"].as_array().unwrap().is_empty(), "{r}");
assert!(!run(&mut s, "format.state", json!({}))["bold"].as_bool().unwrap());
assert_eq!(r["paragraph"]["style"], "Heading1");
assert!(!r["paragraph"]["direct"].as_array().unwrap().is_empty());
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphFormatting"}));
let r = run(&mut s, "styles.inspect", json!({}));
assert!(r["paragraph"]["direct"].as_array().unwrap().is_empty(), "{r}");
assert_eq!(r["paragraph"]["style"], "Heading1");
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphStyle"}));
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Normal");
// Each clear is one undo step.
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Heading1");
assert_eq!(text(&s), "Chapter one");
}
#[test]
fn style_inspector_pane_and_hostile_params() {
let mut s = Session::new(crate::sample::sample_document());
let r = run(&mut s, "styles.inspector", json!({}));
assert_eq!(r["pane"], true);
assert!(s.view.style_inspector);
assert_eq!(r["paragraph"]["styleName"], "Title");
let r = run(&mut s, "styles.inspector", json!({"value": false}));
assert_eq!(r["pane"], false);
let before = text(&s);
for junk in [json!(null), json!({}), json!({"level": 5}), json!({"level": "everything"}), json!({"level": ""})] {
assert!(s.run("styles.inspectorClear", &junk).is_err(), "{junk}");
}
assert_eq!(text(&s), before);
assert!(s.run("styles.inspector", &json!({"value": "yes"})).is_ok());
// A selection reaching far outside the document.
s.sel.focus = Pos::body(9999, 99999);
s.sel.anchor = Pos::body(0, 0);
assert!(s.run("styles.inspect", &json!({})).is_ok());
assert!(s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).is_ok());
}
/// Home › Editing › Find › Advanced Find: every match, in the body or a selection, with Reading Highlight.
#[test]
fn advanced_find_lists_and_highlights_matches() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "cat dog cat"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Cat"}));
// From the UI it opens the dialog.
run(&mut s, "edit.advancedFind", json!({}));
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "find"})));
let r = run(&mut s, "edit.advancedFind", json!({"text": "cat", "highlight": true}));
assert_eq!(r["count"], 3);
assert_eq!(s.find_highlights().len(), 3);
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "matchCase": true}))["count"], 2);
// Highlights follow edits and the latest search.
assert_eq!(s.find_highlights().len(), 2);
run(&mut s, "text.insert", json!({"text": " cat"}));
assert_eq!(s.find_highlights().len(), 3);
// Only inside the selection.
run(&mut s, "select.text", json!({"text": "cat dog"}));
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "in": "selection"}))["count"], 1);
run(&mut s, "edit.advancedFind", json!({"highlight": false}));
assert!(s.find_highlights().is_empty());
// Hostile params.
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "elsewhere"})).is_err());
assert!(s.run("edit.advancedFind", &json!({"text": "(", "regex": true})).is_err());
run(&mut s, "select.all", json!({}));
run(&mut s, "text.delete", json!({}));
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "selection"})).is_err());
}
/// #146: a table style made by command styles the table at the caret (header row fill and bold
/// text, banded rows), can be modified, and saves as a table style the table refers to.
#[test]
fn custom_table_style_applies_modifies_and_saves() {
use wordcraft_doc::{Rgb, StyleKind, TextColor};
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
run(&mut s, "text.insert", json!({"text": "Head"}));
run(&mut s, "table.look", json!({"headerRow": true, "bandedRows": true}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
let r = run(
&mut s,
"table.newStyle",
json!({"name": "Thesis Table", "basedOn": "Table Grid",
"wholeTable": {"size": 10},
"headerRow": {"fill": "1F3864", "bold": true, "color": "FFFFFF", "borders": true},
"bandedRows": {"fill": "EEEEEE", "italic": true}}),
);
let id = r["id"].as_str().unwrap().to_string();
let table_style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
assert_eq!(table_style(&s).as_deref(), Some(id.as_str()), "applied to the current table");
assert!(s.run("table.newStyle", &json!({"name": "thesis table"})).is_err(), "names are unique");
assert!(s.run("table.newStyle", &json!({"name": "X", "basedOn": "Heading 1"})).is_err(), "bases are table styles");
assert!(s.run("table.style", &json!({"style": "Normal"})).is_err(), "only table styles apply to tables");
let fills = |s: &mut Session, c: Rgb| {
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
};
let head = |s: &mut Session| {
let mut p = tp.0.clone();
p.extend([0, 0, 0]);
s.layout().pages[0]
.items
.iter()
.find_map(
|i| if let crate::layout::Placed::Lines { path, para, .. } = i { (path.0 == p).then(|| para.styles[0].rc.clone()) } else { None },
)
.unwrap()
};
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 2, "header cells");
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 2, "first band");
let rc = head(&mut s);
assert!(rc.bold && rc.size == 10.0, "{rc:?}");
assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE));
// Modify the current table's style: the table follows.
run(&mut s, "table.modifyStyle", json!({"headerRow": {"fill": "C00000", "bold": false}, "bandSize": 2}));
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 0);
assert_eq!(fills(&mut s, Rgb(0xC0, 0, 0)), 2);
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 4, "two rows per band");
assert!(!head(&mut s).bold);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let st = back.styles.get(&id).unwrap();
assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid")));
let parts = st.table.as_ref().unwrap();
assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2)));
assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true)));
assert!(parts.header_borders.is_some_and(|b| b.any_visible()));
let t = back.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle");
}
#[test]
fn page_and_table_gridlines_toggle_independently() {
// #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell
// outlines) are separate view switches; neither touches the document or the undo stack.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let undo = s.undo_labels();
assert!(!s.view.gridlines && !s.view.table_gridlines);
assert_eq!(run(&mut s, "view.gridlines", json!({}))["value"], true);
assert!(s.view.gridlines && !s.view.table_gridlines);
// Works with the caret outside a table.
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], true);
assert!(s.view.gridlines && s.view.table_gridlines);
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
assert!(!s.view.gridlines && s.view.table_gridlines);
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
assert!(!s.view.gridlines);
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], false);
assert!(!s.view.gridlines && !s.view.table_gridlines);
assert_eq!(text(&s), "Hello");
assert_eq!(s.undo_labels(), undo);
let state = run(&mut s, "view.state", json!({}));
assert_eq!(state["gridlines"], false);
assert_eq!(state["tableGridlines"], false);
}
/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom
/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck
/// short of the 10%–500% limits.
#[test]
fn zoom_in_and_out_step_from_current_zoom_and_leave_fit_modes() {
let mut s = s();
let pct = |s: &Session| (s.view.zoom * 100.0).round() as i32;
run(&mut s, "view.zoomIn", json!({}));
assert_eq!(pct(&s), 110);
run(&mut s, "view.zoomOut", json!({}));
run(&mut s, "view.zoomOut", json!({}));
assert_eq!(pct(&s), 90);
for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] {
run(&mut s, fit, json!({}));
assert!(!s.view.fit.is_empty());
// What the canvas reports while a fit mode shows the page at 163%.
s.view.zoom = 1.63;
run(&mut s, "view.zoomIn", json!({}));
assert_eq!((pct(&s), s.view.fit.as_str(), s.view.multi_page), (170, "", false), "{fit}");
run(&mut s, fit, json!({}));
s.view.zoom = 1.63;
run(&mut s, "view.zoomOut", json!({}));
assert_eq!((pct(&s), s.view.fit.as_str()), (150, ""), "{fit}");
}
let mut last = pct(&s);
for _ in 0..60 {
run(&mut s, "view.zoomIn", json!({}));
assert!(pct(&s) > last || pct(&s) == 500);
last = pct(&s);
}
assert_eq!(last, 500);
for _ in 0..60 {
run(&mut s, "view.zoomOut", json!({}));
assert!(pct(&s) < last || pct(&s) == 10);
last = pct(&s);
}
assert_eq!(last, 10);
run(&mut s, "view.zoom100", json!({}));
assert_eq!(pct(&s), 100);
}
/// Comments, revisions and dates read the real clock (#188: the web stamped 2026-01-01).
#[test]
fn timestamps_come_from_the_clock() {
@ -1739,3 +2235,201 @@ fn shape_effects_apply_and_undo() {
run(&mut s, "text.insert", json!({"text": "x"}));
assert!(s.run("shape.effects", &json!({"shadow": null})).is_err());
}
#[test]
fn asian_typography_sets_flags_on_selected_paragraphs_and_undoes() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "一\n二\n三"}));
run(&mut s, "select.all", json!({}));
let r = run(&mut s, "para.asianTypography", json!({"kinsoku": false, "wordWrap": false, "topLinePunct": true}));
assert_eq!(r["asianTypography"]["kinsoku"], false);
assert_eq!(r["asianTypography"]["autoSpaceDE"], true, "a flag left out keeps its default");
for p in s.doc.body.iter().filter_map(|b| b.as_para()) {
assert_eq!(
(p.props.kinsoku, p.props.word_wrap, p.props.top_line_punct, p.props.overflow_punct),
(Some(false), Some(false), Some(true), None)
);
}
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.kinsoku.is_none() && p.props.word_wrap.is_none()));
// Without flags it only reports them.
let r = run(&mut s, "para.asianTypography", json!({}));
assert_eq!(r["asianTypography"]["kinsoku"], true);
}
#[test]
fn manage_styles_lists_paragraph_character_linked_and_table_styles() {
let mut s = s();
let list = run(&mut s, "styles.manage", json!({"sort": "alphabetical"}));
let list = list.as_array().unwrap();
let find = |name: &str| list.iter().find(|x| x["name"] == name).unwrap_or_else(|| panic!("{name} listed"));
assert_eq!(find("Normal")["type"], "paragraph");
assert_eq!(find("Normal")["builtIn"], true);
assert_eq!(find("Normal")["inGallery"], true);
assert_eq!(find("Heading 1")["type"], "linked");
assert_eq!(find("Heading 1")["basedOn"], "Normal");
assert!(find("Heading 1")["format"]["size"].is_number(), "description formatting");
assert_eq!(find("Table Grid")["type"], "table");
assert!(list.iter().any(|x| x["type"] == "character"));
// The character half of a linked pair is listed once, as the linked style.
assert!(!list.iter().any(|x| x["id"] == "Heading1Char"));
let names: Vec<String> = list.iter().map(|x| x["name"].as_str().unwrap().to_lowercase()).collect();
assert!(names.windows(2).all(|w| w[0] <= w[1]), "alphabetical");
// Recommended order starts with Normal (priority 0); listing is not an edit.
let rec = run(&mut s, "styles.manage", json!({}));
assert_eq!(rec[0]["name"], "Normal");
assert!(s.run("styles.manage", &json!({"sort": "sideways"})).is_err());
assert!(!s.dirty);
}
#[test]
fn deleting_a_style_falls_back_to_its_base_and_undoes() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Styled"}));
run(&mut s, "styles.create", json!({"name": "Mine", "basedOn": "Heading 1"}));
run(&mut s, "styles.create", json!({"name": "Child", "basedOn": "Mine", "fromSelection": false}));
run(&mut s, "styles.apply", json!({"style": "Mine"}));
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
let r = run(&mut s, "styles.delete", json!({"style": "Mine"}));
assert_eq!(r["restyled"], 1);
assert!(s.doc.styles.get("Mine").is_none());
assert_eq!(para_style(&s, 0).as_deref(), Some("Heading1"), "falls back to its base");
assert_eq!(s.doc.styles.get("Child").and_then(|c| c.based_on.clone()).as_deref(), Some("Heading1"), "rebased");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Mine").is_some());
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
// Built-in styles are refused, unknown ones are errors.
assert!(s.run("styles.delete", &json!({"style": "Heading 1"})).is_err());
assert!(s.run("styles.delete", &json!({"style": "No Such Style"})).is_err());
assert!(s.doc.styles.get("Heading1").is_some());
}
/// Manage Styles' Delete on a table style is Table Design's Delete Table Style: its tables take
/// Table Grid, styles based on it keep their look, and Word's built-in table styles are refused.
#[test]
fn deleting_a_table_style_from_manage_styles_uses_table_style_deletion() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false}));
let r = run(&mut s, "styles.delete", json!({"style": "Base"}));
assert_eq!((r["deleted"][0].as_str(), r["restyled"].as_u64()), (Some("Base"), Some(1)));
assert_eq!(s.doc.table(StoryRef::Body, &tp).unwrap().props.style.as_deref(), Some("TableGrid"));
assert_eq!(s.doc.styles.table_style("Child").unwrap().parts.header_fill, Some(Rgb(0xC0, 0, 0)), "the child keeps its look");
for st in s.doc.styles.styles.iter_mut() {
st.builtin = false;
}
assert!(s.run("styles.delete", &json!({"style": "Table Grid"})).is_err());
let list = run(&mut s, "styles.manage", json!({}));
let grid = list.as_array().unwrap().iter().find(|x| x["name"] == "Table Grid").unwrap();
assert_eq!(grid["builtIn"], true);
}
#[test]
fn style_visibility_round_trips_through_docx() {
let mut s = s();
run(&mut s, "styles.create", json!({"name": "Mine", "fromSelection": false}));
run(&mut s, "styles.setVisibility", json!({"style": "Heading 1", "gallery": false}));
run(&mut s, "styles.setVisibility", json!({"style": "Mine", "hidden": true}));
run(&mut s, "styles.setVisibility", json!({"style": "Heading 4", "gallery": true}));
assert!(s.run("styles.setVisibility", &json!({"style": "Mine"})).is_err());
let gallery: Vec<String> = s.doc.styles.gallery().iter().map(|x| x.id.clone()).collect();
assert!(!gallery.contains(&"Heading1".to_string()) && !gallery.contains(&"Mine".to_string()) && gallery.contains(&"Heading4".to_string()));
let bytes = crate::io::save_bytes("v.docx", &s.doc).unwrap();
let doc = crate::io::open_bytes("v.docx", &bytes).unwrap();
let st = |id: &str| doc.styles.get(id).cloned().unwrap_or_else(|| panic!("{id} kept"));
assert!(!st("Heading1").quick && !st("Heading1").hidden);
assert!(st("Mine").hidden && !st("Mine").builtin);
assert!(st("Heading4").quick);
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Heading4").is_some_and(|h| !h.quick), "undoable");
}
/// #146: the first/last column, total row and banded column regions apply in layout (later
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
#[test]
fn custom_table_style_column_and_total_regions() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 4}));
run(
&mut s,
"table.look",
json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}),
);
let r = run(
&mut s,
"table.newStyle",
json!({"name": "Ledger",
"firstColumn": {"fill": "112233", "bold": true},
"lastColumn": {"fill": "445566"},
"lastRow": {"fill": "778899", "borders": true},
"bandedColumns": {"fill": "ABCDEF"}}),
);
let id = r["id"].as_str().unwrap().to_string();
let fills = |s: &mut Session, c: Rgb| {
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
};
// 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the
// two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each.
assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row");
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column");
assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column");
assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band");
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let p = back.styles.get(&id).unwrap().table.clone().unwrap();
assert_eq!(
(p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill),
(Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF)))
);
assert!(p.total_borders.is_some_and(|b| b.any_visible()));
}
/// #146: deleting a custom table style re-bases the styles based on it (keeping their look),
/// puts its tables on Table Grid, refuses built-in styles, and undoes.
#[test]
fn delete_table_style_falls_back_and_undoes() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}}));
let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
assert_eq!(style(&s).as_deref(), Some("Base"));
assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in");
assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in");
let r = run(&mut s, "table.deleteStyle", json!({}));
assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1)));
assert!(s.doc.styles.get("Base").is_none());
assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid");
let child = s.doc.styles.table_style("Child").unwrap();
assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look");
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid"));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Base").is_some());
assert_eq!(style(&s).as_deref(), Some("Base"));
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base"));
}
/// #146: a .docx may mark Word's built-in table styles as custom; they still can't be deleted, so
/// no table is left pointing at a style that's gone.
#[test]
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
for st in s.doc.styles.styles.iter_mut() {
st.builtin = false;
}
for name in ["Table Grid", "Normal Table", "TableGrid"] {
assert!(s.run("table.deleteStyle", &json!({"style": name})).is_err(), "{name}");
}
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
let id = s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone().unwrap();
assert!(s.doc.styles.get(&id).is_some(), "the table's style still exists");
}

View File

@ -2,7 +2,8 @@
use wordcraft_doc::para::{InlineObject, ShapeKind};
use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline};
use wordcraft_doc::{Document, Path, StoryRef};
use wordcraft_doc::section::SectionStart;
use wordcraft_doc::{Block, Document, Path, StoryRef};
use wordcraft_fonts::FaceRef;
use wordcraft_geom::Rect;
@ -84,6 +85,14 @@ pub enum Draw {
turn: TextDirection,
items: Vec<Draw>,
},
/// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at
/// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark.
MarkText {
x: f32,
baseline: f32,
size: f32,
text: String,
},
}
impl Draw {
@ -345,26 +354,36 @@ fn lines(
if matches!(c.kind, ClKind::Text | ClKind::Space) {
let a = text.len();
// An object's cluster (a field, a note number) stands for the text it shows.
let mut shown = false;
if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _)| *i)
&& let Some((_, s)) = pl.shown.get(i)
let mut shown = None;
if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _, _)| *i)
&& let Some((_, s, r)) = pl.shown.get(i)
{
text.push_str(s);
shown = true;
shown = Some(r);
} else if let Some(p) = para {
text.push_str(p.text.get(c.start..c.end).unwrap_or(""));
}
let b = text.len();
let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]);
if shown && cg.len() > 1 {
// A whole field result is one cluster of many glyphs: give them a character
// each (the last takes any remainder), not the whole text as one ligature.
if let Some(shown) = shown
&& cg.len() > 1
{
// A whole field result is one cluster of many glyphs: each glyph gets the
// text it was shaped from (a ligature its letters), not the whole text as
// one ligature. Without that, a character each (the last takes any remainder).
let bounds: Vec<usize> = text.get(a..b).unwrap_or("").char_indices().map(|(i, _)| a + i).collect();
let known = shown.len() == cg.len();
for (n, g) in cg.iter().enumerate() {
let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a);
let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) };
let r = match shown.get(n).filter(|_| known) {
Some(r) => (a + r.start).min(b)..(a + r.end).min(b),
None => {
let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a);
let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) };
from..to
}
};
glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy));
ranges.push(from..to.max(from));
ranges.push(r.start..r.end.max(r.start));
}
k += 1;
continue;
@ -517,6 +536,23 @@ fn lines(
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. }) => {
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
}
Some(g @ InlineObject::Group { .. }) => {
for ([x, y, w, h], c) in g.group_rects(rect.x, rect.y, rect.w, rect.h) {
let rect = Rect::new(x, y, w, h);
match c {
InlineObject::Image { media, crop, .. } => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => out.push(Draw::Shape {
rect,
kind: *kind,
fill: *fill,
stroke: *stroke,
stroke_width: *stroke_width,
effects: *effects,
}),
_ => {}
}
}
}
Some(InlineObject::Equation { .. }) => {
if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) {
equation(&ml.items, cx, base, alpha, opts.placeholders, out);
@ -560,7 +596,9 @@ fn lines(
}
}
if line.end == LineEnd::Para {
let ex = x + line.end_x();
// The mark follows the line's visual end, past every glyph, even where the
// logically last text runs against the paragraph's direction.
let ex = x + line.visual_end_x();
let size = pl.lines.first().map(|_| msize).unwrap_or(msize);
// A right-to-left paragraph's mark sits at its end, on the left.
let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 };
@ -569,13 +607,75 @@ fn lines(
.and_then(|p| p.mark.ins.or(p.mark.del))
.filter(|_| opts.markup)
.map(|r| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0)));
out.push(Draw::Mark { x: mx, baseline: base, size, ch: '', color });
// The last paragraph of a section ends in a section break instead of a plain ¶.
if let Some(start) = section_break_after(doc, story, path) {
let (x0, x1) = if line.rtl { (x + line.left, ex - 2.0) } else { (ex + 2.0, x + line.right) };
section_break_mark(start, x0, x1, base, size, alpha, out);
} else {
out.push(Draw::Mark { x: mx, baseline: base, size, ch: '', color });
}
}
}
let _ = bottom;
}
}
/// The kind of section break that ends at body paragraph `path`: the start type of the section
/// that follows it. `None` for any other paragraph, and for the document's last section.
fn section_break_after(doc: &Document, story: StoryRef, path: &Path) -> Option<SectionStart> {
if story != StoryRef::Body || path.0.len() != 1 {
return None;
}
let i = *path.0.first()? as usize;
match doc.body.get(i).map(|b| &**b) {
Some(Block::Para(p)) if p.section.is_some() => {}
_ => return None,
}
let secs = doc.sections();
let k = secs.iter().position(|(end, _)| *end == i)?;
secs.get(k + 1).map(|(_, s)| s.start)
}
/// On-screen name of a section break.
pub fn section_break_label(start: SectionStart) -> &'static str {
match start {
SectionStart::NextPage => "Section Break (Next Page)",
SectionStart::Continuous => "Section Break (Continuous)",
SectionStart::EvenPage => "Section Break (Even Page)",
SectionStart::OddPage => "Section Break (Odd Page)",
SectionStart::NextColumn => "Section Break (Next Column)",
}
}
/// Width of a [`Draw::MarkText`] label.
pub fn mark_text_width(text: &str, size: f32) -> f32 {
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
let k = size / face.upem.max(1.0) as f32;
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
}
/// A section break mark between `x0` and `x1`: a dotted double rule with the break's name centred
/// in it (just the name when there is no room for the rule).
fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f32, alpha: f32, out: &mut Vec<Draw>) {
let text = section_break_label(start);
let lsize = (size * 0.8).clamp(6.0, 11.0);
let w = mark_text_width(text, lsize);
let gap = lsize * 0.4;
let mid = base - size * 0.3;
let color = Rgb(0x60, 0x60, 0x60);
if x1 - x0 <= w + 4.0 * gap {
out.push(Draw::MarkText { x: x0, baseline: base, size: lsize, text: text.into() });
return;
}
let tx = (x0 + x1 - w) / 2.0;
for (a, b) in [(x0, tx - gap), (tx + w + gap, x1)] {
for y in [mid - 1.2, mid + 1.2] {
out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha });
}
}
out.push(Draw::MarkText { x: tx, baseline: mid + lsize * 0.33, size: lsize, text: text.into() });
}
/// An equation's glyphs and rules with its origin at (`x`, `base`).
fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec<Draw>) {
for it in items {
@ -640,9 +740,6 @@ pub fn text_color(c: &TextColor, background: Option<Rgb>) -> Rgb {
/// Where inline object `obj` is drawn, given its cluster's box (`adv` × `obj_h` standing on the
/// baseline at `cx`): inside the room kept for its effects.
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect {
let [l, t, r, b] = match obj {
Some(InlineObject::Image { float, .. } | InlineObject::Shape { float, .. }) => float.effect_extent(),
_ => [0.0; 4],
};
let [l, t, r, b] = obj.and_then(InlineObject::frame).map_or([0.0; 4], |(_, _, float)| float.effect_extent());
Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0))
}

View File

@ -373,6 +373,40 @@ impl DocLayout {
None
}
/// Column (block) selection: the piece of every line from the line holding `a` to the line
/// holding `b` (document order) that lies between page x `left` and `right`. One `(start,
/// end)` pair per line, both in the same paragraph; lines shorter than `left` give an empty
/// pair. At most `max` lines.
pub fn column_segments(&self, doc: &Document, a: &Pos, b: &Pos, left: f32, right: f32, page_hint: usize, max: usize) -> Vec<(Pos, Pos)> {
let (a, b) = if a <= b { (a, b) } else { (b, a) };
let (left, right) = if left <= right { (left, right) } else { (right, left) };
let mut out = Vec::new();
if a.story != b.story || !left.is_finite() || !right.is_finite() {
return out;
}
for path in doc.paths_between(a, b) {
for (_, it) in self.pieces(a.story, &path, page_hint) {
let Placed::Lines { para, l0, l1, x, .. } = it else { continue };
let first = if path == a.path { para.line_of(a.off) } else { 0 };
let last = if path == b.path { para.line_of(b.off) } else { usize::MAX };
for li in *l0..*l1 {
if li < first || li > last {
continue;
}
if out.len() >= max {
return out;
}
let o0 = para.off_at_x(li, left - x);
let o1 = para.off_at_x(li, right - x);
let (o0, o1) = if o0 <= o1 { (o0, o1) } else { (o1, o0) };
let mk = |off| Pos { story: a.story, path: path.clone(), off };
out.push((mk(o0), mk(o1)));
}
}
}
out
}
/// Highlight rectangles for the selection `a..b`, per page.
pub fn selection_rects(&self, doc: &Document, a: &Pos, b: &Pos, page_hint: usize) -> Vec<(usize, Rect)> {
let (a, b) = if a <= b { (a, b) } else { (b, a) };

View File

@ -0,0 +1,103 @@
//! Asian typography line-breaking rules (the paragraph's `kinsoku` and `word_wrap` flags), applied
//! on top of the Unicode line-break opportunities (UAX #14).
//!
//! - Kinsoku on (the default): no line starts with closing East Asian punctuation (、。,)」…)
//! and none ends with opening punctuation ((「【…). UAX #14 already forbids most of these
//! breaks; the lists make sure of it for the standard Japanese/Chinese sets.
//! - Kinsoku off: a line may break between any two East Asian characters, punctuation included.
//! - Word wrap off: Latin words may break at any character.
//!
//! Only East Asian characters are in the lists, and the extra breaks need letters on both sides,
//! so text with no East Asian characters breaks exactly as before unless word wrap is off.
use unicode_segmentation::UnicodeSegmentation;
/// East Asian characters a line may not start with: closing brackets and quotes, the
/// ideographic comma and full stop, fullwidth and halfwidth punctuation, the middle dot,
/// iteration and sound marks.
const NO_START: &str = "、。,.・:;?!゛゜ヽヾゝゞ々〻〉》」』】〕〗〙〛〞〟)]}⦆。」、・゙゚%¢";
/// East Asian characters a line may not end with: opening brackets and quotes, currency signs.
const NO_END: &str = "〈《「『【〔〖〘〚〝([{⦅「$£¥";
/// Whether `c` is East Asian text: CJK ideographs, kana, bopomofo, CJK punctuation, and
/// fullwidth/halfwidth forms (not spaces, not combining sound marks).
pub fn is_east_asian(c: char) -> bool {
matches!(c as u32,
0x2E80..=0x2FFF // radicals, ideographic description
| 0x3001..=0x303F // CJK symbols and punctuation (not the ideographic space)
| 0x3040..=0x3098 // hiragana
| 0x309B..=0x30FF // sound marks, katakana
| 0x3100..=0x312F // bopomofo
| 0x3190..=0x31FF // kanbun, CJK strokes, katakana extensions
| 0x3400..=0x4DBF // CJK extension A
| 0x4E00..=0x9FFF // CJK unified ideographs
| 0xF900..=0xFAFF // compatibility ideographs
| 0xFE30..=0xFE4F // CJK compatibility forms
| 0xFF01..=0xFFEF // fullwidth and halfwidth forms
| 0x20000..=0x3FFFF) // supplementary ideographs
}
/// Adjust the break opportunities `opps` (byte offsets into `text`: a line may start there) for
/// the paragraph's kinsoku and word-wrap settings. The result is sorted and has no duplicates.
pub fn apply(text: &str, opps: &mut Vec<usize>, kinsoku: bool, word_wrap: bool) {
let next_char = |i: usize| text.get(i..).and_then(|t| t.chars().next());
let prev_char = |i: usize| text.get(..i).and_then(|t| t.chars().next_back());
if kinsoku {
opps.retain(|&i| !next_char(i).is_some_and(|c| NO_START.contains(c)) && !prev_char(i).is_some_and(|c| NO_END.contains(c)));
} else {
// Between any two East Asian characters.
for (i, c) in text.char_indices().skip(1) {
if is_east_asian(c) && prev_char(i).is_some_and(is_east_asian) {
opps.push(i);
}
}
}
if !word_wrap {
// Between any two letters or digits that are not East Asian (whole graphemes, so accents
// stay with their letters).
let latin = |g: &str| g.chars().next().is_some_and(|c| c.is_alphanumeric() && !is_east_asian(c));
let mut prev: Option<&str> = None;
for (i, g) in text.grapheme_indices(true) {
if prev.is_some_and(latin) && latin(g) {
opps.push(i);
}
prev = Some(g);
}
}
opps.sort_unstable();
opps.dedup();
}
#[cfg(test)]
mod tests {
use super::apply;
/// Where a line may start, as the text from there to the next opportunity.
fn breaks(text: &str, kinsoku: bool, word_wrap: bool) -> Vec<String> {
let mut opps: Vec<usize> = unicode_linebreak::linebreaks(text).map(|(i, _)| i).filter(|&i| i < text.len()).collect();
apply(text, &mut opps, kinsoku, word_wrap);
opps.iter().map(|&i| text.get(i..).and_then(|t| t.chars().next()).map(String::from).unwrap_or_default()).collect()
}
#[test]
fn kinsoku_and_word_wrap_break_opportunities() {
let ja = "日本語、「漢字」。テスト";
let on = breaks(ja, true, true);
for c in ["、", "」", "。", "漢"] {
assert!(!on.iter().any(|s| s == c), "kinsoku: no line starts with {c}: {on:?}");
}
assert!(on.iter().any(|s| s == "「"), "a line may start at an opening bracket: {on:?}");
assert!(on.iter().any(|s| s == "テ"), "{on:?}");
let off = breaks(ja, false, true);
for c in ["、", "」", "。", "漢"] {
assert!(off.iter().any(|s| s == c), "kinsoku off: a line may start with {c}: {off:?}");
}
// Latin text: unchanged by kinsoku, broken anywhere without word wrap.
let en = "Hello wonderful (world), 50%.";
assert_eq!(breaks(en, true, true), breaks(en, false, true));
assert_eq!(breaks(en, true, true), ["w", "(", "5"]);
let any = breaks("Hello café", true, false);
assert_eq!(any, ["e", "l", "l", "o", "c", "a", "f", "é"]);
}
}

View File

@ -11,6 +11,7 @@
pub mod display;
pub mod fields;
pub mod hit;
pub mod kinsoku;
pub mod math;
pub mod para;
mod table;
@ -1037,10 +1038,7 @@ struct PageFrame<'a> {
/// A floating (not inline) picture or shape: its size and placement.
fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> {
match o {
InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. } if float.wrap != Wrap::Inline => Some((*w, *h, float)),
_ => None,
}
o.frame().filter(|(_, _, float)| float.wrap != Wrap::Inline)
}
/// Where a floating object goes, in the coordinates of the box `frame` places on the page.
@ -1103,19 +1101,25 @@ fn rel_exclusions(excl: &[(Rect, bool)], x0: f32, y0: f32) -> Vec<para::Exclusio
.collect()
}
/// The item drawing floating object `o` at `rect`.
fn float_item(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: usize) -> Option<Placed> {
/// The items drawing floating object `o` at `rect` (a group: its members).
fn float_items(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: usize) -> Vec<Placed> {
match o {
InlineObject::Image { media, crop, .. } => {
Some(Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off })
vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off }]
}
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
vec![Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects }]
}
_ => None,
InlineObject::Group { .. } => group_members(o, rect).into_iter().flat_map(|(r, c)| float_items(c, r, story, path, off)).collect(),
_ => Vec::new(),
}
}
/// A group's members, each with its rectangle when the group is at `rect`.
fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect()
}
/// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in
/// the current column, add their wrap areas to the page (replacing any from an earlier attempt,
/// from `excl_mark` on) and lay the paragraph out around them, with its list `label`.
@ -1193,18 +1197,19 @@ fn place_objects(
if left_out(oi) {
continue;
}
let Some(obj @ (InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. })) = p.objects.get(oi) else { continue };
let Some(obj) = p.objects.get(oi) else { continue };
let Some((w, h, float)) = obj.frame() else { continue };
let floating = float.wrap != Wrap::Inline;
let rect = if floating {
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, *w, *h, float))
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, w, h, float))
} else {
let cx = x + line.cl_left(k).unwrap_or(0.0);
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h)
};
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
// Floating ones are drawn here; inline ones with their line.
if floating && let Some(it) = float_item(obj, rect, story, path, c.start) {
layer.push(it);
if floating {
layer.extend(float_items(obj, rect, story, path, c.start));
}
let text_box = match obj {
InlineObject::Shape { story: Some(id), .. } if ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox) => {
@ -1221,16 +1226,30 @@ fn place_objects(
wrap: float.wrap,
origin: Point::new(at.col.0, at.para_y),
});
// Its text, unless the box budget says no (a box inside itself, too deep, too many).
let Some(id) = text_box.filter(|id| ctx.boxes.enter(*id)) else { continue };
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
let (inner, _) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None);
ctx.boxes.leave();
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
// Text that doesn't fit inside the margins is hidden, as in Word.
for mut it in fit_box(inner, rect.h - 2.0 * BOX_INSET_Y) {
it.translate(rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y);
layer.push(it);
// Its text (a group: its text boxes'), unless the box budget says no (a box inside
// itself, too deep, too many).
let boxes: Vec<(u32, Rect)> = match obj {
InlineObject::Group { .. } => group_members(obj, rect)
.into_iter()
.filter_map(|(r, c)| {
c.text_box().filter(|id| ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox)).map(|id| (id, r))
})
.collect(),
_ => text_box.map(|id| (id, rect)).into_iter().collect(),
};
for (id, rect) in boxes {
if !ctx.boxes.enter(id) {
continue;
}
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
let (inner, _) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None);
ctx.boxes.leave();
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
// Text that doesn't fit inside the margins is hidden, as in Word.
for mut it in fit_box(inner, rect.h - 2.0 * BOX_INSET_Y) {
it.translate(rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y);
layer.push(it);
}
}
}
}
@ -1241,12 +1260,14 @@ fn place_objects(
/// (on the box's cluster), as (cluster, note id).
fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32)> {
let mut notes = pl.notes.clone();
if p.objects.iter().any(|o| matches!(o, InlineObject::Shape { story: Some(_), .. })) {
if p.objects.iter().any(|o| !o.text_boxes().is_empty()) {
for (ci, c) in pl.clusters.iter().enumerate() {
if let para::ClKind::Object(oi) = c.kind
&& let Some(InlineObject::Shape { story: Some(id), .. }) = p.objects.get(oi)
&& let Some(o) = p.objects.get(oi)
{
notes.extend(doc.notes_in_text_box(*id).into_iter().map(|n| (ci, n)));
for id in o.text_boxes() {
notes.extend(doc.notes_in_text_box(id).into_iter().map(|n| (ci, n)));
}
}
}
}
@ -1692,10 +1713,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
/// Is an object floating (not laid out inline)?
pub fn is_floating(o: &InlineObject) -> bool {
match o {
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
_ => false,
}
o.is_floating()
}
/// Milliseconds since an arbitrary epoch (monotonic on native; the browser's clock on the web).

View File

@ -168,6 +168,14 @@ impl Line {
None => self.xs.last().copied().unwrap_or(self.left),
}
}
/// x of the line's visual end for the paragraph's direction, where its paragraph mark goes:
/// the right edge of the rightmost cluster in a left-to-right paragraph, the left edge of the
/// leftmost in a right-to-left one. Differs from [`Line::end_x`] when the logically last text
/// runs the other way (Hebrew ending a left-to-right paragraph, Latin ending a right-to-left one).
pub fn visual_end_x(&self) -> f32 {
let end = self.end_x();
if self.rtl { self.vis.iter().map(|v| v.x).fold(end, f32::min) } else { self.vis.iter().map(|v| v.x + v.w).fold(end, f32::max) }
}
/// x where the hyphen of a hyphenated line (advance `adv`) is drawn.
pub fn hyphen_x(&self, adv: f32) -> f32 {
match self.vis.last() {
@ -248,8 +256,9 @@ pub struct ParaLayout {
/// tracked deletions in the final text. Resolved as laid out, table formatting included.
pub left_out: Vec<std::ops::Range<usize>>,
/// The text drawn by clusters that stand for an object (field results, note numbers,
/// equations) rather than for the paragraph's own text, by cluster index, sorted.
pub shown: Vec<(usize, String)>,
/// equations) rather than for the paragraph's own text, by cluster index, sorted, with each of
/// the cluster's glyphs' byte range in that text (a ligature's glyph spans its letters).
pub shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
}
/// What a table style gives the text of a cell: its paragraph and run formatting, with the
@ -302,7 +311,7 @@ struct Builder<'a> {
/// Bidi level per byte of the paragraph text (empty: all left to right).
levels: Vec<u8>,
/// Text drawn by clusters that stand for an object, by cluster index (see `ParaLayout::shown`).
shown: Vec<(usize, String)>,
shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
}
/// What a piece of text is shaped as: one face, case, script formatting and direction.
@ -564,18 +573,28 @@ impl<'a> Builder<'a> {
});
return;
};
// Merge into one cluster: rebase glyph dx onto the first cluster.
// Merge into one cluster: rebase glyph dx onto the first cluster, and keep each glyph's
// text (a ligature's glyph takes the letters of the clusters after it that have none).
let mut x = 0.0;
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
for c in &added {
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dx += x;
}
x += c.adv;
let (a, b) = (c.start.saturating_sub(start), c.end.saturating_sub(start));
if c.g1 <= c.g0 {
let from = ranges.last().map(|r| r.start);
for r in ranges.iter_mut().rev().take_while(|r| Some(r.start) == from) {
r.end = b;
}
}
ranges.extend((c.g0..c.g1).map(|_| a..b));
}
let g0 = first.g0;
let style = first.style;
let g1 = added.last().map(|c| c.g1).unwrap_or(g0);
self.shown.push((self.clusters.len(), text.to_string()));
self.shown.push((self.clusters.len(), text.to_string(), ranges));
self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false });
}
}
@ -713,8 +732,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let k = obj_index;
obj_index += 1;
match p.objects.get(k) {
Some(InlineObject::Image { w, h, float, .. }) | Some(InlineObject::Shape { w, h, float, .. }) => {
if float.wrap == wordcraft_doc::para::Wrap::Inline {
Some(o) if o.is_drawing() => {
if let Some((w, h, float)) = o.frame()
&& float.wrap == wordcraft_doc::para::Wrap::Inline
{
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0);
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
let s = if w > maxw { maxw / w } else { 1.0 };
@ -803,19 +824,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let to_para = |i: usize| -> Option<usize> {
if left.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) }
};
let mut opps = std::collections::HashSet::new();
let mut found = Vec::new();
for (i, o) in unicode_linebreak::linebreaks(&text) {
// Word keeps "and/or" and web addresses whole: no break right after a slash (a word
// too long for the line still breaks anywhere).
let after_slash =
text.get(..i).is_some_and(|t| t.ends_with('/')) && text.get(i..).and_then(|t| t.chars().next()).is_some_and(char::is_alphanumeric);
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len())
&& !after_slash
&& let Some(end) = to_para(i)
{
opps.insert(end);
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len()) && !after_slash {
found.push(i);
}
}
// Asian typography: kinsoku and breaking Latin words anywhere.
crate::kinsoku::apply(&text, &mut found, rp.kinsoku, rp.word_wrap);
let opps: std::collections::HashSet<usize> = found.into_iter().filter_map(to_para).collect();
for c in &mut b.clusters {
c.break_after = opps.contains(&c.end) || matches!(c.kind, ClKind::Object(_) | ClKind::Tab);
}

View File

@ -4,7 +4,7 @@
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
use wordcraft_doc::styles::TableStyleProps;
use wordcraft_doc::styles::{TableStyleParts, TableStyleProps};
use wordcraft_doc::{StoryRef, Table};
use wordcraft_geom::Rect;
@ -40,8 +40,9 @@ struct Turned {
across: f32,
/// List counters and equation count before the cell, to lay it out again the same way.
snap: (Counters, u32),
/// Its formatting region (header row, total row, first column) and cell index in the row.
region: (bool, bool, bool),
/// Its formatting region (header row, total row, first column, last column, row band, column
/// band) and cell index in the row.
region: (bool, bool, bool, bool, bool, bool),
ci: usize,
/// Top margin plus top border band.
top: f32,
@ -94,11 +95,54 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 {
t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1]
}
/// A cell's borders from the table style's conditional regions it is in (`regions`: header row,
/// total row, first column, last column, row band, column band), later regions winning. Row
/// regions give their left/right edges at the table's sides and `inside_v` between cells; column
/// regions their top/bottom edges at the table's top and bottom and `between` between rows.
fn region_borders(p: &TableStyleParts, (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool), regions: [bool; 6]) -> Borders {
let [header, total, first_col, last_col, band, col_band] = regions;
let row = |b: Option<Borders>| {
b.map(|b| Borders {
top: b.top,
bottom: b.bottom,
left: if first_g { b.left } else { b.inside_v },
right: if last_g { b.right } else { b.inside_v },
between: None,
inside_v: None,
})
};
let col = |b: Option<Borders>| {
b.map(|b| Borders {
top: if ri == 0 { b.top } else { b.between },
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
left: b.left,
right: b.right,
between: None,
inside_v: None,
})
};
let mut out = Borders::default();
for (on, b) in [
(col_band, col(p.col_band_borders)),
(band, row(p.band_borders)),
(first_col, col(p.first_col_borders)),
(last_col, col(p.last_col_borders)),
(header, row(p.header_borders)),
(total, row(p.total_borders)),
] {
if on && let Some(b) = b {
out.overlay(&b);
}
}
out
}
pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout {
let style = table_style(ctx, t);
let parts = style.as_ref().map(|s| &s.parts);
// Cell text formatting per (header row, total row, first column) region, built once per table.
let mut cell_text: Vec<((bool, bool, bool), CellText)> = Vec::new();
// Cell text formatting per region (header row, total row, first column, last column, row
// band, column band), built once per table.
let mut cell_text: Vec<((bool, bool, bool, bool, bool, bool), CellText)> = Vec::new();
let ncols = t.cols().max(1);
// Column widths.
let mut grid: Vec<f32> = if t.grid.len() == ncols {
@ -152,6 +196,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
};
let nrows = t.rows.len();
let header_rows = t.props.look.header_row;
let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize;
// First pass: lay out every cell's content.
struct CellBox {
items: Vec<Placed>,
@ -178,7 +223,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let mut row_insets = 0.0f32;
let is_header = header_rows && ri == 0;
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
let band = t.props.look.banded_rows && !is_header && (ri - usize::from(header_rows)) % 2 == 0;
let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2);
for (ci, cell) in row.cells.iter().enumerate() {
let span = cell.span();
let x0 = colx.get(g).copied().unwrap_or(acc);
@ -186,15 +231,34 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let margins = cell.props.margins.unwrap_or(margins_def);
let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0);
let mut fill = cell.props.shading;
let region = (is_header, is_total, t.props.look.first_column && g == 0);
let first_col = t.props.look.first_column && g == 0;
let last_col = t.props.look.last_column && g + span >= ncols && ncols > 1;
let first_cols = usize::from(t.props.look.first_column);
let col_band = t.props.look.banded_columns && !first_col && g.saturating_sub(first_cols).is_multiple_of(2);
let region = (is_header, is_total, first_col, last_col, band, col_band);
// Conditional formatting, later regions winning (ECMA-376 §17.7.6): whole table,
// column bands, row bands, first/last column, header/total row.
if let Some(p) = parts {
if is_header {
fill = fill.or(p.header_fill);
}
if band && fill.is_none() {
fill = p.band_fill;
}
fill = fill.or(p.fill);
let row_fill = if is_header {
p.header_fill
} else if is_total {
p.total_fill
} else {
None
};
let col_fill = if first_col {
p.first_col_fill
} else if last_col {
p.last_col_fill
} else {
None
};
fill = fill
.or(row_fill)
.or(col_fill)
.or(if band { p.band_fill } else { None })
.or(if col_band { p.col_band_fill } else { None })
.or(p.fill);
}
if let Some(st) = &style
&& !cell_text.iter().any(|(r, _)| *r == region)
@ -202,9 +266,18 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
// The whole table's formatting, then column, then row conditional formatting
// (later regions win, ECMA-376 §17.7.6).
let mut chr = st.chr.clone();
if region.2 {
if col_band {
chr.overlay(&st.parts.col_band_chr);
}
if band {
chr.overlay(&st.parts.band_chr);
}
if first_col {
chr.overlay(&st.parts.first_col_chr);
}
if last_col {
chr.overlay(&st.parts.last_col_chr);
}
if is_header {
chr.overlay(&st.parts.header_chr);
} else if is_total {
@ -221,15 +294,22 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let edge =
|own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| own.or(if outer { outer_b } else { inner_b });
let cb = cell.props.borders.unwrap_or_default();
let (first_g, last_g) = (g == 0, g + span >= ncols);
let rb = parts
.map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band]))
.unwrap_or_default();
let mut borders = Borders {
top: edge(cb.top, ri == 0, tb.top, tb.between),
bottom: edge(cb.bottom, ri + 1 == nrows, tb.bottom, tb.between),
left: edge(cb.left, g == 0, tb.left, tb.inside_v),
right: edge(cb.right, g + span >= ncols, tb.right, tb.inside_v),
top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between),
bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between),
left: edge(cb.left.or(if first_g { rb.left } else { rb.inside_v }), first_g, tb.left, tb.inside_v),
right: edge(cb.right.or(if last_g { rb.right } else { rb.inside_v }), last_g, tb.right, tb.inside_v),
between: None,
inside_v: None,
};
if is_total && let Some(b) = parts.and_then(|p| p.total_border_top) {
if is_total
&& parts.is_none_or(|p| p.total_borders.is_none_or(|b| b.top.is_none()))
&& let Some(b) = parts.and_then(|p| p.total_border_top)
{
borders.top = Some(b);
}
// Word keeps the cell's text clear of its top border (and the last row's of its bottom

View File

@ -261,10 +261,14 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() {
#[test]
fn fragmented_hidden_text_lays_out_like_the_visible_text() {
// Visible words with a hidden multi-byte char after every visible one, formatted in alternating
// runs (bold on and off) so no two left-out runs are adjacent or merge.
// runs (bold on and off) so no two left-out runs are adjacent or merge. The plain paragraph has
// the same visible runs without the hidden chars, so both lay out the same widths whatever font
// the default font resolves to.
let words = "Hyphenation wraps international words across narrow columns of text ".repeat(6);
let mut d = Document::from_text("");
d.settings.auto_hyphenation = true;
let mut plain = Document::from_text("");
plain.settings.auto_hyphenation = true;
let mut text = String::new();
let mut kept = Vec::new();
for (i, c) in words.chars().enumerate() {
@ -277,10 +281,11 @@ fn fragmented_hidden_text_lays_out_like_the_visible_text() {
d.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap();
let hidden = wordcraft_doc::CharProps { hidden: Some(true), bold: Some(i % 2 == 1), ..Default::default() };
d.insert_text(&Pos::body(0, at), "é", &hidden).unwrap();
let n = plain.para(StoryRef::Body, &Path::top(0)).unwrap().len();
plain.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap();
}
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, text);
let mut plain = Document::from_text(&words);
plain.settings.auto_hyphenation = true;
assert_eq!(plain.para(StoryRef::Body, &Path::top(0)).unwrap().text, words);
for doc in [&mut d, &mut plain] {
doc.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 90.0)).unwrap();
}
@ -657,6 +662,20 @@ fn selection_rects_cover_range() {
assert!(r[0].1.w > 20.0);
}
#[test]
fn column_segments_take_the_same_x_range_on_every_line() {
let d = Document::from_text("abcdef\nabcdef\nab");
let l = lay(&d);
let left = l.caret(&Pos::body(0, 2)).unwrap().x;
let right = l.caret(&Pos::body(0, 4)).unwrap().x;
let segs = l.column_segments(&d, &Pos::body(2, 2), &Pos::body(0, 2), right, left, 0, 100);
let offs: Vec<(u32, usize, usize)> = segs.iter().map(|(a, b)| (a.path.0[0], a.off, b.off)).collect();
assert_eq!(offs, vec![(0, 2, 4), (1, 2, 4), (2, 2, 2)]);
// Capped, and junk x gives nothing.
assert_eq!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), left, right, 0, 2).len(), 2);
assert!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), f32::NAN, right, 0, 100).is_empty());
}
#[test]
fn display_has_glyphs_and_marks() {
let mut d = Document::from_text("Hello\tworld");
@ -685,6 +704,63 @@ fn a_tracked_paragraph_mark_shows_in_its_authors_colour() {
assert_eq!(marks(false), [None, None]);
}
#[test]
fn section_breaks_show_with_formatting_marks() {
use wordcraft_doc::section::{SectionProps, SectionStart};
// Two sections: "One" ends the first; the second starts as `start`.
let doc = |start: SectionStart| {
let mut d = Document::from_text("One\nTwo");
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
d.last_section.start = start;
d
};
let labels = |d: &Document, marks: bool| -> (Vec<String>, usize) {
let l = lay(d);
let items = display::page_display(d, &l.pages[0], &display::DisplayOptions { marks, ..Default::default() });
let texts = items.iter().filter_map(|i| if let display::Draw::MarkText { text, .. } = i { Some(text.clone()) } else { None }).collect();
let pilcrows = items.iter().filter(|i| matches!(i, display::Draw::Mark { ch: '', .. })).count();
(texts, pilcrows)
};
let d = doc(SectionStart::Continuous);
// The break replaces the first paragraph's ¶; the last paragraph keeps its own.
assert_eq!(labels(&d, true), (vec!["Section Break (Continuous)".to_string()], 1));
assert_eq!(labels(&d, false), (vec![], 0));
assert_eq!(labels(&doc(SectionStart::NextPage), true).0, ["Section Break (Next Page)"]);
assert_eq!(labels(&doc(SectionStart::EvenPage), true).0, ["Section Break (Even Page)"]);
assert_eq!(labels(&doc(SectionStart::OddPage), true).0, ["Section Break (Odd Page)"]);
// A document with one section has no break.
assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2));
}
#[test]
fn paragraph_marks_sit_past_text_running_the_other_way() {
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
// logically last letters are at the wrong edge, but the ¶ still goes past every glyph.
let mut d = Document::from_text("Hello \u{5e9}\u{5dc}\u{5d5}\u{5dd}\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} abc");
let at = Pos::body(1, 0);
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap();
let l = lay(&d);
// Leftmost and rightmost glyph edges (page x) of each paragraph's single line.
let extents: Vec<(f32, f32)> = l.pages[0]
.items
.iter()
.filter_map(|it| if let Placed::Lines { para, x, .. } = it { Some((para, *x)) } else { None })
.map(|(pl, x)| {
let ln = &pl.lines[0];
let edges = (ln.c0..ln.c1).filter_map(|k| Some((ln.cl_left(k)?, ln.cl_right(k)?)));
edges.fold((f32::MAX, f32::MIN), |(a, b), (l, r)| (a.min(x + l), b.max(x + r)))
})
.collect();
let marks: Vec<(f32, f32)> = display::page_display(&d, &l.pages[0], &display::DisplayOptions { marks: true, ..Default::default() })
.into_iter()
.filter_map(|i| if let display::Draw::Mark { ch: '', x, size, .. } = i { Some((x, size)) } else { None })
.collect();
assert_eq!((extents.len(), marks.len()), (2, 2));
let ((ltr_x, _), (rtl_x, size)) = (marks[0], marks[1]);
assert!(ltr_x >= extents[0].1, "LTR ¶ at {ltr_x} should be right of all glyphs {:?}", extents[0]);
assert!(rtl_x + size * 0.5 <= extents[1].0, "RTL ¶ at {rtl_x} should be left of all glyphs {:?}", extents[1]);
}
fn border_lines(d: &Document) -> Vec<(f32, f32, f32, f32)> {
border_lines_in(d, &lay(d))
}
@ -2325,3 +2401,25 @@ fn hidden_float_in_a_text_box_is_not_placed() {
let (hidden_n, hidden_areas) = count(false);
assert_eq!((shown - hidden_n, shown_areas - hidden_areas), (1, 1), "shown {shown}/{shown_areas}, hidden {hidden_n}/{hidden_areas}");
}
#[test]
fn kinsoku_keeps_a_manual_line_break_before_a_closing_bracket() {
// #269: kinsoku forbids a line starting with 」, but a manual line break (Shift+Enter) right
// before one still ends the line there.
let text = "日本語\n」テスト";
let mut d = Document::from_text("");
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
let l = lay(&d);
let mut lines = Vec::new();
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { para, l0, l1, .. } = it {
lines.extend(para.lines[*l0..*l1].iter().map(|line| (line.start, line.end)));
}
}
}
let bracket = text.find('」').unwrap();
assert_eq!(lines.len(), 2, "{lines:?}");
assert_eq!(lines[0].1, LineEnd::LineBreak, "{lines:?}");
assert_eq!(lines[1].0, bracket, "the second line starts at the bracket: {lines:?}");
}

View File

@ -303,24 +303,38 @@ fn arabic_face() -> Option<wordcraft_fonts::FaceRef> {
wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ'))
}
/// The glyph ids of each cluster, in order. A font may draw a letter as several glyphs (Noto
/// Naskh Arabic draws ب as its dotless form plus the dot), so letters are compared by cluster.
fn cluster_glyphs(glyphs: &[wordcraft_fonts::ShapedGlyph]) -> Vec<Vec<u32>> {
let mut out: Vec<(usize, Vec<u32>)> = Vec::new();
for g in glyphs {
match out.last_mut() {
Some((c, ids)) if *c == g.cluster => ids.push(g.gid),
_ => out.push((g.cluster, vec![g.gid])),
}
}
out.into_iter().map(|(_, ids)| ids).collect()
}
#[test]
fn persian_letters_join_and_brackets_mirror() {
let Some(face) = arabic_face() else {
eprintln!("skipped: no font with Persian letters installed");
return;
};
let isolated = wordcraft_fonts::shape_run(&face, "ب", &[], |c| c, true);
let joined = wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true);
assert_eq!(joined.len(), 3);
assert!(joined.iter().all(|g| g.gid != 0), "the font has the letters");
let isolated = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ب", &[], |c| c, true));
assert_eq!(isolated.len(), 1, "{isolated:?}");
let shaped = wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true);
let joined = cluster_glyphs(&shaped);
assert_eq!(joined.len(), 3, "one cluster per letter: {joined:?}");
assert!(shaped.iter().all(|g| g.gid != 0), "the font has the letters");
// Initial, medial and final forms differ from the isolated letter.
assert!(joined.iter().any(|g| g.gid != isolated[0].gid), "cursive joining picks contextual forms");
assert!(joined.iter().any(|g| *g != isolated[0]), "cursive joining picks contextual forms: {joined:?} {isolated:?}");
// Clusters come back in logical order.
assert!(joined.windows(2).all(|w| w[0].cluster <= w[1].cluster));
assert!(shaped.windows(2).all(|w| w[0].cluster <= w[1].cluster));
// ZWNJ (نیم‌فاصله) breaks the join: "می‌خواهم" keeps می separate.
let with_zwnj = wordcraft_fonts::shape_run(&face, "ب\u{200C}ب", &[], |c| c, true);
let first = with_zwnj.first().map(|g| g.gid);
assert_eq!(first, Some(isolated[0].gid), "a letter before ZWNJ takes its isolated form");
let with_zwnj = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ب\u{200C}ب", &[], |c| c, true));
assert_eq!(with_zwnj.first(), isolated.first(), "a letter before ZWNJ takes its isolated form");
// In right-to-left text, "(" is drawn with the mirrored glyph.
let open = wordcraft_fonts::shape_run(&face, "(", &[], |c| c, true);
let close = wordcraft_fonts::shape_run(&face, ")", &[], |c| c, false);
@ -354,6 +368,10 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
let s = "سلام WordCraft می‌خواهم";
let d = doc_of(&[(s, true)]);
let l = lay(&d);
let lam_alef_ligature = arabic_face().is_some_and(|f| {
let g = wordcraft_fonts::shape_run(&f, "لا", &[], |c| c, true);
!g.is_empty() && g.iter().all(|g| g.cluster == 0)
});
let mut all = String::new();
for it in display::page_display(&d, &l.pages[0], &Default::default()) {
if let display::Draw::Glyphs { glyphs, text, ranges, .. } = it {
@ -361,8 +379,9 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
for r in &ranges {
assert!(text.get(r.clone()).is_some_and(|t| !t.is_empty()), "{r:?} in {text:?}");
}
// A ligature glyph (لا) shows both its letters (when a Persian font draws it).
for r in ranges.iter().filter(|_| arabic_face().is_some()) {
// A ligature glyph (لا) shows both its letters (when the Persian font draws one: some,
// such as Noto Naskh Arabic, draw the lam and the alef as glyphs of their own).
for r in ranges.iter().filter(|_| lam_alef_ligature) {
if text.get(r.clone()) == Some("ل") {
panic!("the lam of لا is drawn as one glyph with the alef: {text:?} {ranges:?}");
}

View File

@ -707,7 +707,7 @@ impl Exporter<'_> {
s.set_fill(None);
s.set_stroke(None);
}
Draw::Mark { .. } => {}
Draw::Mark { .. } | Draw::MarkText { .. } => {}
Draw::Turned { x, y, turn, items } => {
if !ok(*x) || !ok(*y) {
return;

View File

@ -539,6 +539,23 @@ fn field_results_are_extractable_text() {
assert!(squash(&text).contains("See Section 3.01 for details."), "{text:?}");
}
#[test]
fn field_result_ligatures_extract_all_their_letters() {
// A field result is shaped as one cluster; a ligature in it (Carlito, the Calibri substitute,
// joins "ti") used to get only its first letter, shifting the rest: "Sectio 3.01". Bundled
// Source Sans 3 joins "fi" and "ffi", so this doesn't depend on the installed fonts.
let font = CharProps { font: Some("Source Sans 3".into()), ..Default::default() };
let result = "the first office";
let face = wordcraft_fonts::FontDb::global().face("Source Sans 3", "Regular");
assert!(wordcraft_fonts::shape(&face, result, &[], |c| c).len() < result.chars().count(), "the result has ligatures");
let mut d = Document::new();
let mut p = Paragraph::with_text("See here.", font.clone());
p.insert_object(4, InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: result.into(), locked: false }, &font).unwrap();
d.body = vec![para_block(p)];
let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat();
assert!(squash(&text).contains("See the first office here."), "{text:?}");
}
#[test]
fn synthetic_bold_text_is_extracted_once() {
// JetBrains Mono ships without a bold face, so its bold is filled and stroked: the text

View File

@ -382,6 +382,25 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.set_transform(view * Affine::translate((*x as f64, *baseline as f64)) * Affine::scale(k));
ctx.fill_path(&o);
}
Draw::MarkText { x, baseline, size, text } => {
let base_face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
ctx.set_paint(color(opts.ink(opts.mark_color), 0.9));
let mut gx = *x as f64;
for ch in text.chars() {
let mut face = base_face;
if face.glyph_for(ch) == 0
&& let Some(f) = FontDb::global().fallback_for(ch, face.id())
{
face = wordcraft_fonts::FaceRef::of(&f);
}
let gid = face.glyph_for(ch);
let k = *size as f64 / face.upem.max(1.0);
let o = FontDb::global().outline(&face, gid);
ctx.set_transform(view * Affine::translate((gx, *baseline as f64)) * Affine::scale(k));
ctx.fill_path(&o);
gx += face.advance(gid) * k;
}
}
Draw::Image { rect, media, crop, alpha } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
if !r.overlaps(*visible) {

View File

@ -20,6 +20,8 @@ egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# Pictures pasted from the system clipboard arrive as pixels and are inserted as PNG (#45).
image = { workspace = true }
# The system's preferred interface languages (#8). Native: OS APIs; wasm: none (English).
sys-locale = "0.3"
@ -29,7 +31,6 @@ js-sys = "0.3"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
image = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }

View File

@ -270,17 +270,35 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
ui.columns(2, |cols| {
let ui = &mut cols[0];
ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0)));
// The fields edit a copy of the document's properties that is read afresh every frame,
// so each change goes into the document as it is typed (#260): waiting for the field to
// lose focus dropped the keystrokes, and leaving File with Escape never loses it at all.
// The keystrokes of one visit to a field are a single undo step.
let mut props = app.session.doc.core.clone();
let mut changed = false;
let mut edited = None;
let mut focused = None;
egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] {
ui.label(tl!(l));
changed |= ui.text_edit_singleline(v).lost_focus();
let label = ui.label(tl!(l));
let r = ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id);
if r.changed() {
edited = Some(l);
}
if r.has_focus() {
focused = Some(l);
}
ui.end_row();
}
});
if changed {
let _ = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category}));
if let Some(field) = edited {
if app.info_editing == Some((field, app.session.rev())) {
app.session.join_next_undo();
}
let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok();
app.info_editing = ok.then(|| (field, app.session.rev()));
}
if focused != app.info_editing.map(|(f, _)| f) {
app.info_editing = None;
}
let ui = &mut cols[1];
ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0)));
@ -445,4 +463,66 @@ mod tests {
assert!(h.query_by_label("Report").is_some(), "New shows every template");
assert!(h.query_by_label("Recent").is_none(), "New has no Recent list");
}
/// #260: File › Info's Title and Author fields dropped every keystroke (they edited a copy
/// re-read each frame and only wrote it back when the field lost focus), so a save had no
/// title or author. Typing now lands in the document as it happens, one undo step per field,
/// and the browser's Save downloads it with the save stamps advanced (#262).
#[test]
fn info_fields_keep_typed_properties_through_a_web_save() {
use egui::accesskit::Role;
let got: std::rc::Rc<std::cell::RefCell<Vec<(String, Vec<u8>)>>> = Default::default();
let sink = got.clone();
let services = Services {
download: Some(Box::new(move |n: &str, b: &[u8]| {
sink.borrow_mut().push((n.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("XYZ")), services);
a.session.author = "GAMMA".into();
a.run("ui.backstage", json!({"value": true, "page": "info"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(4);
for (field, text) in [("Title", "ALPHA"), ("Author", "BETA")] {
h.get_by_role_and_label(Role::TextInput, field).click();
h.run_steps(2);
for c in text.chars() {
h.get_by_role_and_label(Role::TextInput, field).type_text(&c.to_string());
h.run_steps(2);
}
assert_eq!(h.get_by_role_and_label(Role::TextInput, field).value().as_deref(), Some(text), "{field} shows what was typed");
}
let core = h.state().session.doc.core.clone();
assert_eq!((core.title.as_str(), core.creator.as_str()), ("ALPHA", "BETA"));
assert!(h.state().session.dirty, "the property edits are unsaved changes");
// Save (web: a download) writes them into docProps/core.xml, with fresh save stamps.
let a = h.state_mut();
let created = a.session.doc.core.created.clone();
a.run("file.save", json!({})).unwrap();
a.run("file.save", json!({})).unwrap();
let (name, bytes) = got.borrow_mut().pop().unwrap();
let back = wordcraft_engine::io::open_bytes(&name, &bytes).unwrap();
assert_eq!((back.core.title.as_str(), back.core.creator.as_str()), ("ALPHA", "BETA"));
assert_eq!(back.core.last_modified_by, "GAMMA");
assert_eq!(back.core.revision, core.revision + 2, "each save advances the revision");
assert!(created.is_empty() && !back.core.created.is_empty(), "the first save sets the creation time");
assert!(!back.core.modified.is_empty());
assert!(!a.session.dirty);
// Each field's typing is one undo step.
a.session.run("edit.undo", &json!({})).unwrap();
assert_eq!((a.session.doc.core.title.as_str(), a.session.doc.core.creator.as_str()), ("ALPHA", ""));
a.session.run("edit.undo", &json!({})).unwrap();
assert_eq!(a.session.doc.core.title, "");
}
}

View File

@ -23,6 +23,8 @@ pub struct CanvasState {
pub scroll_to_caret: bool,
pub caret_visible_since: f64,
dragging: bool,
/// Alt+drag: where the column (block) selection started (page, x, y) and its last end.
column_drag: Option<((usize, f32, f32), (usize, f32, f32))>,
pub last_highlight: String,
pub last_font_color: String,
pub last_shading: String,
@ -44,8 +46,15 @@ pub struct CanvasState {
pub mini_anchor: Option<Rect>,
/// A picture, shape or text box being dragged by its frame.
pub(crate) obj_drag: Option<crate::objects::ObjectDrag>,
/// Wheel/touchpad scrolling (smooth notches, touchpad momentum).
pub(crate) wheel: crate::scroll::CanvasScroll,
/// The scroll offset and its maximum at the end of last frame.
pub(crate) scroll_offset: egui::Vec2,
scroll_max: egui::Vec2,
/// Pages per row last frame; when it changes the caret's page is scrolled back into view.
pub cols: usize,
/// A paste event arrived since the last Mod+V release (see `keys::canvas_events`).
pub(crate) pasted: bool,
}
impl CanvasState {
@ -62,6 +71,7 @@ impl Default for CanvasState {
scroll_to_caret: true,
caret_visible_since: 0.0,
dragging: false,
column_drag: None,
last_highlight: "yellow".into(),
last_font_color: "C00000".into(),
last_shading: "FFF2CC".into(),
@ -78,6 +88,10 @@ impl Default for CanvasState {
obj_drag: None,
context_menu_open: false,
mini_anchor: None,
pasted: false,
wheel: Default::default(),
scroll_offset: egui::Vec2::ZERO,
scroll_max: egui::Vec2::ZERO,
cols: 1,
}
}
@ -194,6 +208,18 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry {
Geometry { rects, size, scale, cols }
}
/// While a fit mode (Page Width, One Page, Multiple Pages) sizes the page, keep the session's zoom
/// equal to what is shown, as Word does. Zoom In/Out, the Zoom dialog and `view.state` then start
/// from the visible zoom; before, they stepped from the stale manual zoom, so Zoom In from a 163%
/// Page Width jumped to 110% (issue #67).
pub fn sync_fit_zoom(app: &mut WordApp, scale: f32) {
let v = &mut app.session.view;
if v.fit.is_empty() || v.read_mode || v.mode != wordcraft_layout::ViewMode::Print || !scale.is_finite() {
return;
}
v.zoom = (scale / PX_PER_PT).clamp(0.1, 5.0);
}
/// Fingerprint of a page's content for the texture cache.
fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
@ -236,6 +262,43 @@ fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 {
if page.w > 0.0 { rect.width() / page.w } else { fallback }
}
/// Spacing of the View › Gridlines drawing grid: 0.5 cm, in points.
const DRAWING_GRID_PT: f32 = 72.0 * 0.5 / 2.54;
/// Closest the drawing grid's lines get on screen; zoomed far out, every other line is skipped.
const DRAWING_GRID_MIN_PX: f32 = 4.0;
/// Most grid lines drawn each way on one page (page sizes come from files).
const DRAWING_GRID_MAX_LINES: usize = 2000;
/// The View › Gridlines drawing grid over a page's text area, in document points: the x of each
/// vertical line and the y of each horizontal line, starting at the margins and `step` apart,
/// never outside the margins. Drawn on screen only — never printed or exported.
fn drawing_grid(body: wordcraft_geom::Rect, step: f32) -> (Vec<f32>, Vec<f32>) {
let axis = |start: f32, len: f32| -> Vec<f32> {
if !(step.is_finite() && step > 0.0 && start.is_finite() && len.is_finite() && len >= 0.0) {
return Vec::new();
}
let end = start + len + 0.01;
(0..DRAWING_GRID_MAX_LINES).map(|k| start + k as f32 * step).take_while(|p| *p <= end).collect()
};
(axis(body.x, body.w), axis(body.y, body.h))
}
/// The drawing grid's spacing in points at a screen scale: the default 0.5 cm, doubled until the
/// lines are at least [`DRAWING_GRID_MIN_PX`] apart on screen.
fn drawing_grid_step(scale: f32) -> f32 {
let mut step = DRAWING_GRID_PT;
if !(scale.is_finite() && scale > 0.0) {
return step;
}
for _ in 0..16 {
if step * scale >= DRAWING_GRID_MIN_PX {
break;
}
step *= 2.0;
}
step
}
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let layout = app.session.layout();
@ -262,6 +325,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0));
app.canvas.scale = geo.scale;
sync_fit_zoom(app, geo.scale);
// Zooming or resizing reflowed the pages into a different number of columns: the caret's page
// moved, so bring it back into view rather than leave the reader somewhere else.
if geo.cols != app.canvas.cols {
@ -288,7 +352,28 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.canvas.scroll_to_caret = false;
let mut origin = area.min;
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
let out = egui::ScrollArea::both().id_salt("canvas_scroll").auto_shrink([false, false]).show_viewport(&mut ui_area, |ui, viewport| {
// The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with
// momentum, wheel notches eased in. Applied before drawing, so it shows this frame.
let hovered = ui.rect_contains_pointer(area) && ui.ctx().dragged_id().is_none();
let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT);
let opts = ui.ctx().options(|o| o.input_options);
let delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height()));
let mut scroll_area = egui::ScrollArea::both()
.id_salt("canvas_scroll")
.auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() });
if delta != egui::Vec2::ZERO {
let before = app.canvas.scroll_offset;
let after = (before - delta).clamp(egui::Vec2::ZERO, app.canvas.scroll_max);
if after == before {
app.canvas.wheel.hit_edge();
}
scroll_area = scroll_area.scroll_offset(after);
}
if app.canvas.wheel.is_animating() {
ui.ctx().request_repaint();
}
let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| {
origin = ui.min_rect().min - viewport.min.to_vec2();
let content = Rect::from_min_size(ui.min_rect().min, geo.size);
let resp = ui.allocate_rect(content, Sense::click_and_drag());
@ -365,8 +450,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
}
// Table gridlines.
if app.session.view.gridlines {
// View › Gridlines: the drawing grid over the text area (Print Layout, on screen only).
if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode {
let (xs, ys) = drawing_grid(page.body, drawing_grid_step(page_scale));
let stroke = Stroke::new(1.0, t.blue.linear_multiply(0.22));
let (x0, x1) = (visual_sr.min.x + page.body.x * page_scale, visual_sr.min.x + page.body.right() * page_scale);
let (y0, y1) = (visual_sr.min.y + page.body.y * page_scale, visual_sr.min.y + page.body.bottom() * page_scale);
for x in xs {
let sx = visual_sr.min.x + x * page_scale;
painter.line_segment([pos2(sx, y0), pos2(sx, y1)], stroke);
}
for y in ys {
let sy = visual_sr.min.y + y * page_scale;
painter.line_segment([pos2(x0, sy), pos2(x1, sy)], stroke);
}
}
// Table Layout › View Gridlines: table cell outlines (on screen only).
if app.session.view.table_gridlines {
for it in &page.items {
if let Placed::Cell { rect, .. } = it {
let r = Rect::from_min_size(
@ -386,18 +486,35 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.canvas.textures.retain(|k, _| *k < n);
app.canvas.page_rects = rects.clone();
balloons(app, ui, &painter, &rects, &layout, geo.scale);
// Selection (a selected object shows its frame instead).
if !app.session.sel.is_collapsed() && crate::objects::selected(app).is_none() {
let (a, b) = app.session.sel.ordered();
let mut first: Option<Rect> = None;
for (pi, r) in layout.selection_rects(&app.session.doc, &a, &b, app.session.page_hint) {
// Find › Reading Highlight.
let marks: Vec<(Pos, Pos)> = app.session.find_highlights().iter().take(5000).cloned().collect();
for (a, b) in &marks {
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
if let Some(pr) = rects.get(pi) {
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
painter.rect_filled(sr, 0.0, t.selection);
// Track the topmost rect: the mini toolbar anchors on the selection's first line.
if first.is_none_or(|f| sr.min.y < f.min.y) {
first = Some(sr);
let hr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
painter.rect_filled(hr, 0.0, t.find_highlight);
}
}
}
// Selection (a selected object shows its frame instead).
if !app.session.sel.is_collapsed() && crate::objects::selected(app).is_none() {
// A column selection highlights each row's piece.
let pieces: Vec<(Pos, Pos)> = match app.session.column_segments() {
Some(segs) => segs.to_vec(),
None => vec![app.session.sel.ordered()],
};
let mut first: Option<Rect> = None;
for (a, b) in &pieces {
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
if let Some(pr) = rects.get(pi) {
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
painter.rect_filled(sr, 0.0, t.selection);
// Track the topmost rect: the mini toolbar anchors on the selection's first line.
if first.is_none_or(|f| sr.min.y < f.min.y) {
first = Some(sr);
}
}
}
}
@ -491,6 +608,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale);
(resp, rects)
});
app.canvas.scroll_offset = out.state.offset;
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
let (resp, rects) = out.inner;
// Focus: the canvas takes keyboard focus on click and keeps Tab/arrows.
if resp.clicked() || resp.drag_started() || app.canvas.want_focus {
@ -757,6 +876,15 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
return;
}
let pj = serde_json::to_value(&pos).unwrap_or_default();
// Alt+drag selects a column (block) of text.
if mods.alt && !mods.shift && !mods.command {
let _ = app.run("caret.set", json!({"pos": pj}));
app.session.page_hint = page;
app.canvas.column_drag = Some(((page, x, y), (page, x, y)));
app.canvas.dragging = true;
return;
}
app.canvas.column_drag = None;
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
if mods.command && !mods.shift {
let _ = app.run("select.sentence", json!({}));
@ -764,6 +892,18 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
app.session.page_hint = page;
app.canvas.dragging = true;
// Format painter applies on mouse up.
} else if app.canvas.dragging
&& ui.input(|i| i.pointer.primary_down())
&& let Some((from, last)) = app.canvas.column_drag
{
if last != (page, x, y) {
app.canvas.column_drag = Some((from, (page, x, y)));
let story = app.session.sel.focus.story;
let _ = app.run(
"select.column",
json!({"from": {"page": from.0, "x": from.1, "y": from.2}, "to": {"page": page, "x": x, "y": y}, "story": story}),
);
}
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) {
if let Some(pos) = layout.hit(page, x, y, story)
&& pos != app.session.sel.focus
@ -775,6 +915,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
// Auto-scroll near the edges is handled by egui's scroll area drag.
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) {
app.canvas.dragging = false;
app.canvas.column_drag = None;
if app.session.painter.is_some() && !app.session.sel.is_collapsed() {
let _ = app.run("edit.pasteFormat", json!({}));
}
@ -1240,6 +1381,47 @@ mod tests {
assert_eq!(visual.size(), vec2(80.5, 120.5));
}
#[test]
fn drawing_grid_starts_at_the_margins_and_stays_inside_them() {
// Letter page with 1" margins: the text area is 468 x 648 pt from (72, 72).
let body = wordcraft_geom::Rect::new(72.0, 72.0, 468.0, 648.0);
let (xs, ys) = drawing_grid(body, DRAWING_GRID_PT);
assert_eq!(xs.first().copied(), Some(72.0));
assert_eq!(ys.first().copied(), Some(72.0));
assert!(xs.iter().all(|x| (72.0..=540.0).contains(x)), "{xs:?}");
assert!(ys.iter().all(|y| (72.0..=720.0).contains(y)), "{ys:?}");
// 0.5 cm apart, the same both ways: 468 pt holds 33 steps, 648 pt holds 45.
assert_eq!(xs.len(), 34);
assert_eq!(ys.len(), 46);
for w in xs.windows(2).chain(ys.windows(2)) {
assert!((w[1] - w[0] - DRAWING_GRID_PT).abs() < 0.01, "{w:?}");
}
// A grid line that lands on the margin is kept.
let (xs, _) = drawing_grid(wordcraft_geom::Rect::new(0.0, 0.0, 100.0, 10.0), 25.0);
assert_eq!(xs, vec![0.0, 25.0, 50.0, 75.0, 100.0]);
}
#[test]
fn drawing_grid_survives_hostile_sizes() {
let r = wordcraft_geom::Rect::new;
for (body, step) in [
(r(72.0, 72.0, 468.0, 648.0), 0.0),
(r(72.0, 72.0, 468.0, 648.0), -5.0),
(r(72.0, 72.0, 468.0, 648.0), f32::NAN),
(wordcraft_geom::Rect { x: f32::NAN, y: 0.0, w: f32::INFINITY, h: -10.0 }, DRAWING_GRID_PT),
] {
let (xs, ys) = drawing_grid(body, step);
assert!(xs.is_empty() && ys.is_empty(), "{body:?} {step}");
}
let (xs, ys) = drawing_grid(r(0.0, 0.0, 1.0e9, 1.0e9), DRAWING_GRID_PT);
assert_eq!((xs.len(), ys.len()), (DRAWING_GRID_MAX_LINES, DRAWING_GRID_MAX_LINES));
// Zoomed far out, lines thin out instead of filling the page.
assert_eq!(drawing_grid_step(PX_PER_PT), DRAWING_GRID_PT);
assert!(drawing_grid_step(0.1) * 0.1 >= DRAWING_GRID_MIN_PX);
assert_eq!(drawing_grid_step(0.0), DRAWING_GRID_PT);
assert_eq!(drawing_grid_step(f32::NAN), DRAWING_GRID_PT);
}
#[test]
fn texel_aligned_rect_keeps_integer_pixel_pages_unchanged() {
let layout = Rect::from_min_size(pos2(10.0, 20.0), vec2(80.0, 120.0));

View File

@ -1,8 +1,8 @@
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
//! Word Count, Zoom, Watermark, New/Modify Style, Command search, About, Save Changes, and the
//! mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules, Match Fields and
//! Check for Errors. Every dialog ends by running a command (or shows one's result), so agents get
//! the same result without the dialog.
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Command
//! search, Paste Special, About, Save Changes, and the mail-merge Recipient List, Insert Merge
//! Field, Find Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends by
//! running a command (or shows one's result), so agents get the same result without the dialog.
use egui::{Sense, Ui, vec2};
use serde::Serialize;
@ -45,6 +45,15 @@ pub enum Dialog {
keep_lines: bool,
page_break: bool,
widow: bool,
/// Page: 0 Indents and Spacing, 1 Line and Page Breaks, 2 Asian Typography.
tab: u8,
/// Asian Typography as the model has it (`para.asianTypography`): kinsoku, word wrap,
/// hanging punctuation, compress punctuation at line start, space between Asian and
/// Latin text, space between Asian text and numbers.
asian: [bool; 6],
/// `asian` when the dialog opened: OK sets only the flags that changed.
#[serde(skip)]
asian_was: [bool; 6],
},
Find {
query: String,
@ -89,8 +98,13 @@ pub enum Dialog {
NewStyle {
name: String,
based_on: String,
/// Opened from Manage Styles, which comes back when this closes.
#[serde(skip)]
back: bool,
},
ModifyStyle {
#[serde(skip)]
back: bool,
id: String,
name: String,
font: String,
@ -101,9 +115,38 @@ pub enum Dialog {
before: f32,
after: f32,
},
/// Manage Styles: every style with its preview and description; modify, create, delete and
/// show or hide them.
ManageStyles {
alphabetical: bool,
selected: String,
},
/// Table Design › New / Modify Table Style: one formatting region at a time.
TableStyle {
/// The style being modified; `None` creates one.
id: Option<String>,
name: String,
/// Style id.
based_on: String,
/// Index into `regions` (see [`REGION_LABELS`]).
region: usize,
regions: Box<[TableRegion; 7]>,
/// What the regions showed when the dialog opened (or the base style changed): only
/// changes are sent, so everything else stays inherited.
#[serde(skip)]
basis: Box<[TableRegion; 7]>,
},
Commands {
query: String,
},
/// Paste Special: the clipboard's formats and the chosen one. The clipboard payload stays out
/// of the serialized dialog state (it can be megabytes).
PasteSpecial {
formats: Vec<String>,
choice: usize,
#[serde(skip)]
payload: Value,
},
/// Tabs: 0 About, 1 Contributors, 2 Models.
About {
tab: u8,
@ -211,6 +254,215 @@ pub fn recipient_table(fields: &[String], rows: &[Vec<String>]) -> Result<Value,
Ok(json!({"fields": fields, "rows": rows}))
}
/// One table style region in the Table Style dialog: colours are hex, empty for none.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableRegion {
pub borders: bool,
pub fill: String,
pub bold: bool,
pub color: String,
}
/// The Table Style dialog's regions, in the order of `table_style::REGIONS` (the param names).
const REGION_LABELS: [&str; 7] = ["Whole Table", "Header Row", "Total Row", "First Column", "Last Column", "Banded Rows", "Banded Columns"];
/// The regions of table style `id` as resolved through its based-on chain.
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 7]> {
let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() };
let p = &st.parts;
let hex = |c: Option<wordcraft_doc::Rgb>| c.map(|c| c.hex()).unwrap_or_default();
let text = |c: &wordcraft_doc::CharProps| match c.color {
Some(wordcraft_doc::TextColor::Rgb(c)) => c.hex(),
_ => String::new(),
};
let lines = |b: Option<wordcraft_doc::props::Borders>| b.is_some_and(|b| b.any_visible());
// A region's text: the whole table's formatting with the region's over it.
let region = |borders: Option<wordcraft_doc::props::Borders>, fill: Option<wordcraft_doc::Rgb>, chr: &wordcraft_doc::CharProps| {
let mut c = st.chr.clone();
c.overlay(chr);
TableRegion { borders: lines(borders), fill: hex(fill), bold: c.bold.unwrap_or(false), color: text(&c) }
};
let none = wordcraft_doc::CharProps::default();
Box::new([
region(p.borders, p.fill, &none),
region(p.header_borders, p.header_fill, &p.header_chr),
region(p.total_borders, p.total_fill, &p.total_chr),
region(p.first_col_borders, p.first_col_fill, &p.first_col_chr),
region(p.last_col_borders, p.last_col_fill, &p.last_col_chr),
region(p.band_borders, p.band_fill, &p.band_chr),
region(p.col_band_borders, p.col_band_fill, &p.col_band_chr),
])
}
/// The params `table.newStyle` / `table.modifyStyle` take for the Table Style dialog's state:
/// only what changed from `basis`, so the rest stays inherited.
fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 7], basis: &[TableRegion; 7]) -> Value {
let mut v = json!({"name": name.trim(), "basedOn": based_on});
for ((key, r), b) in wordcraft_engine::cmd::table_style::REGIONS.into_iter().zip(regions.iter()).zip(basis.iter()) {
let ch = region_changes(r, b);
if ch.as_object().is_some_and(|o| !o.is_empty()) {
v[key] = ch;
}
}
if let Some(id) = id {
v["style"] = json!(id);
}
v
}
/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total
/// row), showing the regions `look` turns on: the Table Style dialog's live preview.
fn table_style_preview(ui: &mut Ui, style: Option<&wordcraft_doc::styles::TableStyleProps>, look: wordcraft_doc::props::TableLook) {
use wordcraft_doc::props::{Border, Borders};
const COLS: usize = 5;
const ROWS: usize = 5;
const TEXT: [[&str; COLS]; ROWS] = [
["", "Mon", "Tue", "Wed", "Sum"],
["North", "4", "7", "2", "13"],
["South", "6", "1", "5", "12"],
["West", "3", "8", "4", "15"],
["Total", "13", "16", "11", "40"],
];
let (rect, _) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::hover());
let painter = ui.painter_at(rect);
// The page is white whatever the interface theme.
painter.rect_filled(rect, 2.0, egui::Color32::WHITE);
let t = Tokens::get(ui.ctx());
painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let Some(st) = style else { return };
let p = &st.parts;
let table = rect.shrink2(vec2(14.0, 10.0));
let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32);
let rgb = |c: wordcraft_doc::Rgb| egui::Color32::from_rgb(c.0, c.1, c.2);
let tb = p.borders.unwrap_or_default();
for (ri, row) in TEXT.iter().enumerate() {
let header = look.header_row && ri == 0;
let total = look.total_row && ri + 1 == ROWS;
let band = look.banded_rows
&& !header
&& (ri.saturating_sub(usize::from(look.header_row)) / p.band_size.unwrap_or(1).clamp(1, 1000) as usize).is_multiple_of(2);
for (ci, txt) in row.iter().enumerate() {
let first = look.first_column && ci == 0;
let last = look.last_column && ci + 1 == COLS;
let col_band = look.banded_columns && !first && ci.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
let cell = egui::Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh));
// Regions from lowest to highest priority, as layout applies them.
let regions = [
(col_band, p.col_band_fill, &p.col_band_chr, p.col_band_borders),
(band, p.band_fill, &p.band_chr, p.band_borders),
(first, p.first_col_fill, &p.first_col_chr, p.first_col_borders),
(last, p.last_col_fill, &p.last_col_chr, p.last_col_borders),
(header, p.header_fill, &p.header_chr, p.header_borders),
(total, p.total_fill, &p.total_chr, p.total_borders),
];
let mut fill = p.fill;
let mut chr = st.chr.clone();
let mut b = Borders {
top: if ri == 0 { tb.top } else { tb.between },
bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between },
left: if ci == 0 { tb.left } else { tb.inside_v },
right: if ci + 1 == COLS { tb.right } else { tb.inside_v },
between: None,
inside_v: None,
};
for (on, f, c, rb) in regions {
if !on {
continue;
}
fill = f.or(fill);
chr.overlay(c);
if let Some(rb) = rb {
b.overlay(&Borders { between: None, inside_v: None, ..rb });
}
}
if total && p.total_borders.is_none_or(|x| x.top.is_none()) {
b.top = p.total_border_top.or(b.top);
}
if let Some(f) = fill {
painter.rect_filled(cell, 0.0, rgb(f));
}
let edge = |from: egui::Pos2, to: egui::Pos2, e: Option<Border>| {
if let Some(e) = e.filter(Border::is_visible) {
let color = e.color.map_or(egui::Color32::BLACK, rgb);
painter.line_segment([from, to], egui::Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color));
}
};
edge(cell.left_top(), cell.right_top(), b.top);
edge(cell.left_bottom(), cell.right_bottom(), b.bottom);
edge(cell.left_top(), cell.left_bottom(), b.left);
edge(cell.right_top(), cell.right_bottom(), b.right);
let color = match chr.color {
Some(wordcraft_doc::TextColor::Rgb(c)) => rgb(c),
_ => egui::Color32::BLACK,
};
let font = egui::FontId::proportional(11.0);
let pos = cell.left_center() + vec2(4.0, 0.0);
painter.text(pos, egui::Align2::LEFT_CENTER, *txt, font.clone(), color);
if chr.bold.unwrap_or(false) {
// A second pass a hair to the right reads as bold at this size.
painter.text(pos + vec2(0.6, 0.0), egui::Align2::LEFT_CENTER, *txt, font, color);
}
}
}
}
/// The style of the table at the caret, when it is a table style.
fn current_table_style(app: &WordApp) -> Option<String> {
let s = &app.session;
let (tp, _, _) = s.sel.focus.path.cell()?;
let id = s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()?;
s.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.id.clone())
}
/// The params `table.newStyle` / `table.modifyStyle` need for what changed in one region.
fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value {
let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) };
let mut v = json!({});
if r.borders != basis.borders {
v["borders"] = json!(r.borders);
}
if r.fill != basis.fill {
v["fill"] = color(&r.fill);
}
if r.bold != basis.bold {
v["bold"] = json!(r.bold);
}
if r.color != basis.color {
v["color"] = color(&r.color);
}
v
}
/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none.
fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
let c = wordcraft_doc::Rgb::parse(value);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
let t = Tokens::get(ui.ctx());
match c {
Some(c) => {
ui.painter().rect_filled(r, 2.0, crate::theme::c32(c));
}
None => {
ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim));
}
}
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
let label = if c.is_some() { value.clone() } else { tl!("No Color").to_string() };
ui.menu_button(label, |ui| {
if ui.button(tl!("No Color")).clicked() {
value.clear();
ui.close();
}
if let Some(hex) = crate::widgets::color_grid(ui, theme) {
*value = hex;
ui.close();
}
});
});
}
impl Dialog {
pub fn name(&self) -> &'static str {
match self {
@ -228,7 +480,11 @@ impl Dialog {
Dialog::Watermark { .. } => "watermark",
Dialog::NewStyle { .. } => "newStyle",
Dialog::ModifyStyle { .. } => "modifyStyle",
Dialog::ManageStyles { .. } => "manageStyles",
Dialog::TableStyle { id: None, .. } => "newTableStyle",
Dialog::TableStyle { .. } => "modifyTableStyle",
Dialog::Commands { .. } => "commands",
Dialog::PasteSpecial { .. } => "pasteSpecial",
Dialog::About { .. } => "about",
Dialog::SaveChanges { .. } => "saveChanges",
Dialog::RecipientList { .. } => "recipientList",
@ -277,7 +533,7 @@ impl Dialog {
color: s("color"),
spacing: 0.0,
},
"paragraph" => {
"paragraph" | "asianTypography" => {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
let rp = rp?;
let line = match rp.line_spacing {
@ -298,6 +554,9 @@ impl Dialog {
keep_lines: rp.keep_lines,
page_break: rp.page_break_before,
widow: rp.widow_control,
tab: if name == "asianTypography" { 2 } else { 0 },
asian: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
asian_was: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
}
}
"find" | "replace" => Dialog::Find {
@ -326,8 +585,24 @@ impl Dialog {
"wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() },
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() },
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into(), back: false },
"manageStyles" => Dialog::ManageStyles { alphabetical: false, selected: s("style") },
"newTableStyle" => {
let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into());
let styles = &app.session.doc.styles;
let name = (1..1000).map(|i| format!("Table Style {i}")).find(|n| styles.find(n).is_none()).unwrap_or_default();
let regions = table_regions(app, &based_on);
Dialog::TableStyle { id: None, name, based_on, region: 0, basis: regions.clone(), regions }
}
"modifyTableStyle" => {
let id = current_table_style(app)?;
let st = app.session.doc.styles.get(&id)?;
let (name, based_on) = (st.name.clone(), st.based_on.clone().unwrap_or_default());
let regions = table_regions(app, &id);
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
}
"commands" => Dialog::Commands { query: String::new() },
"pasteSpecial" => Dialog::paste_special(app, &json!({})),
"about" => Dialog::About { tab: 0 },
"contributors" => Dialog::About { tab: 1 },
"models" => Dialog::About { tab: 2 },
@ -366,6 +641,18 @@ impl Dialog {
})
}
/// Paste Special for a clipboard payload (`text`, `html`, `rtf`; empty = WordCraft's own copy).
pub fn paste_special(app: &WordApp, payload: &Value) -> Dialog {
let formats: Vec<String> = wordcraft_engine::cmd::paste::available(&app.session, payload).into_iter().map(str::to_string).collect();
let mut kept = json!({});
for k in ["text", "html", "rtf"] {
if let Some(t) = payload.get(k).and_then(Value::as_str) {
kept[k] = json!(t);
}
}
Dialog::PasteSpecial { formats, choice: 0, payload: kept }
}
pub fn modify_style(app: &WordApp, id: &str) -> Option<Dialog> {
let st = app.session.doc.styles.get(id)?;
let rc = app.session.doc.styles.resolve_char(
@ -377,6 +664,7 @@ impl Dialog {
);
let rp = app.session.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: Some(id.into()), ..Default::default() });
Some(Dialog::ModifyStyle {
back: false,
id: id.into(),
name: st.name.clone(),
font: rc.font,
@ -449,7 +737,11 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::Watermark { .. } => "Custom Watermark",
Dialog::NewStyle { .. } => "Create New Style",
Dialog::ModifyStyle { .. } => "Modify Style",
Dialog::ManageStyles { .. } => "Manage Styles",
Dialog::TableStyle { id: None, .. } => "New Table Style",
Dialog::TableStyle { .. } => "Modify Table Style",
Dialog::Commands { .. } => "Search Commands",
Dialog::PasteSpecial { .. } => "Paste Special",
Dialog::About { .. } => "About WordCraft",
Dialog::SaveChanges { .. } => "WordCraft",
Dialog::RecipientList { .. } => "Recipient List",
@ -578,48 +870,94 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow } => {
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
Dialog::Paragraph {
rtl,
align,
left,
right,
first,
before,
after,
line,
keep_next,
keep_lines,
page_break,
widow,
tab,
asian,
asian_was,
} => {
ui.horizontal(|ui| {
ui.label(tl!("Direction:"));
ui.radio_value(rtl, true, tl!("Right-to-left"));
ui.radio_value(rtl, false, tl!("Left-to-right"));
});
egui::ComboBox::from_label(tl!("Alignment")).selected_text(align.clone()).show_ui(ui, |ui| {
for a in ["left", "center", "right", "justify"] {
ui.selectable_value(align, a.to_string(), a);
for (k, name) in ["Indents and Spacing", "Line and Page Breaks", "Asian Typography"].into_iter().enumerate() {
if ui.selectable_label(*tab as usize == k, tl!(name)).clicked() {
*tab = k as u8;
}
}
});
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
ui.label(tl!("Left:"));
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
ui.label(tl!("Right:"));
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
ui.label(tl!("First line:"));
ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
});
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
ui.label(tl!("Before:"));
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
ui.label(tl!("Line spacing:"));
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
ui.end_row();
ui.label(tl!("After:"));
ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
ui.end_row();
});
ui.label(egui::RichText::new(tl!("Line and Page Breaks")).font(semibold(12.5)));
ui.checkbox(widow, tl!("Widow/Orphan control"));
ui.checkbox(keep_next, tl!("Keep with next"));
ui.checkbox(keep_lines, tl!("Keep lines together"));
ui.checkbox(page_break, tl!("Page break before"));
ui.separator();
match *tab {
0 => {
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
ui.horizontal(|ui| {
ui.label(tl!("Direction:"));
ui.radio_value(rtl, true, tl!("Right-to-left"));
ui.radio_value(rtl, false, tl!("Left-to-right"));
});
egui::ComboBox::from_label(tl!("Alignment")).selected_text(align.clone()).show_ui(ui, |ui| {
for a in ["left", "center", "right", "justify"] {
ui.selectable_value(align, a.to_string(), a);
}
});
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
ui.label(tl!("Left:"));
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
ui.label(tl!("Right:"));
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
ui.label(tl!("First line:"));
ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2));
ui.end_row();
});
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
ui.label(tl!("Before:"));
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
ui.label(tl!("Line spacing:"));
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
ui.end_row();
ui.label(tl!("After:"));
ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
ui.end_row();
});
}
1 => {
ui.label(egui::RichText::new(tl!("Line and Page Breaks")).font(semibold(12.5)));
ui.checkbox(widow, tl!("Widow/Orphan control"));
ui.checkbox(keep_next, tl!("Keep with next"));
ui.checkbox(keep_lines, tl!("Keep lines together"));
ui.checkbox(page_break, tl!("Page break before"));
}
_ => {
let [kinsoku, word_wrap, overflow, top_line, de, dn] = asian;
ui.label(egui::RichText::new(tl!("Line breaking")).font(semibold(12.5)));
ui.checkbox(kinsoku, tl!("Apply Asian line-breaking rules (kinsoku)"));
// The model's flag is "wrap whole words"; the box offers the opposite.
let mut mid_word = !*word_wrap;
if ui.checkbox(&mut mid_word, tl!("Allow Latin text to wrap in the middle of a word")).changed() {
*word_wrap = !mid_word;
}
ui.checkbox(overflow, tl!("Allow hanging punctuation"));
ui.label(egui::RichText::new(tl!("Character Spacing")).font(semibold(12.5)));
ui.checkbox(top_line, tl!("Compress punctuation at the start of a line"));
ui.checkbox(de, tl!("Add space between Asian and Latin text"));
ui.checkbox(dn, tl!("Add space between Asian text and numbers"));
}
}
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
apply_paragraph(app, *rtl, align, *left, *right, *first, *before, *after, *line, [*keep_next, *keep_lines, *page_break, *widow]);
apply_asian(app, *asian, *asian_was);
}
ok || cancel
}
@ -647,10 +985,24 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.checkbox(whole_word, tl!("Whole words"));
ui.checkbox(regex, tl!("Wildcards (regex)"));
});
let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex});
if !*replace_mode {
ui.horizontal(|ui| {
let lit = app.session.find.highlight;
if ui.selectable_label(lit, tl!("Reading Highlight")).clicked() {
let mut v = opts.clone();
v["highlight"] = json!(!lit);
match app.run("edit.advancedFind", v) {
Ok(r) if !lit => *message = crate::i18n::fmt(tl!("{count} items highlighted"), &[("count", &r["count"].to_string())]),
Ok(_) => message.clear(),
Err(e) => *message = e,
}
}
});
}
if !message.is_empty() {
ui.label(egui::RichText::new(message.as_str()).weak());
}
let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex});
let mut close = false;
ui.horizontal(|ui| {
if *replace_mode {
@ -790,7 +1142,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.radio_value(percent, p, format!("{p}%"));
}
});
ui.add(egui::Slider::new(percent, 10.0..=500.0).suffix("%"));
// Logarithmic like the status bar slider, and wide enough to aim: the default 100 pt
// linear slider jumped from 100% to 480% within a short drag (issue #67).
ui.spacing_mut().slider_width = 220.0;
ui.add(egui::Slider::new(percent, 10.0..=500.0).logarithmic(true).step_by(1.0).suffix("%"));
let mut fit = None;
ui.horizontal(|ui| {
if ui.button(tl!("Page width")).clicked() {
@ -828,7 +1183,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::NewStyle { name, based_on } => {
Dialog::NewStyle { name, based_on, back } => {
egui::Grid::new("ns").num_columns(2).show(ui, |ui| {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
@ -839,12 +1194,17 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
ui.label(egui::RichText::new(tl!("The new style takes the formatting of the current paragraph.")).small().weak());
let (ok, cancel) = buttons(ui, tl!("OK"));
let mut created = None;
if ok {
let _ = app.run("styles.create", json!({"name": name, "basedOn": based_on}));
created =
app.run("styles.create", json!({"name": name, "basedOn": based_on})).ok().and_then(|v| v.get("id")?.as_str().map(str::to_string));
}
if (ok || cancel) && *back {
app.dialog = Some(Dialog::ManageStyles { alphabetical: false, selected: created.unwrap_or_default() });
}
ok || cancel
}
Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after } => {
Dialog::ModifyStyle { back, id, name, font, size, bold, italic, color, before, after } => {
egui::Grid::new("ms").num_columns(2).show(ui, |ui| {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
@ -878,6 +1238,101 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let _ =
app.run("styles.modify", json!({"style": id, "name": name, "chr": chr, "para": {"spaceBefore": *before, "spaceAfter": *after}}));
}
if (ok || cancel) && *back {
app.dialog = Some(Dialog::ManageStyles { alphabetical: false, selected: id.clone() });
}
ok || cancel
}
Dialog::ManageStyles { alphabetical, selected } => manage_styles(app, ui, alphabetical, selected),
Dialog::TableStyle { id, name, based_on, region, regions, basis } => {
let styles: Vec<(String, String)> = app
.session
.doc
.styles
.styles
.iter()
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && Some(&s.id) != id.as_ref())
.map(|s| (s.id.clone(), s.name.clone()))
.collect();
let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone());
let mut rebased = false;
egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
ui.end_row();
ui.label(tl!("Style based on:"));
egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| {
for (sid, sname) in &styles {
if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on {
*based_on = sid.clone();
rebased = true;
}
}
});
ui.end_row();
ui.label(tl!("Apply formatting to:"));
egui::ComboBox::from_id_salt("tstyle_region")
.width(200.0)
.selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table")))
.show_ui(ui, |ui| {
for (i, n) in REGION_LABELS.iter().enumerate() {
ui.selectable_value(region, i, tl!(n));
}
});
ui.end_row();
});
// A new style shows its new base's formatting; a modified one keeps its own.
if rebased && id.is_none() {
*regions = table_regions(app, based_on);
*basis = regions.clone();
}
ui.separator();
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(r) = regions.get_mut(*region) {
ui.horizontal(|ui| {
ui.checkbox(&mut r.borders, tl!("Borders"));
ui.checkbox(&mut r.bold, tl!("Bold"));
});
egui::Grid::new("tstyle_colors").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Fill color:"));
color_menu(ui, &theme, &mut r.fill);
ui.end_row();
ui.label(tl!("Font color:"));
color_menu(ui, &theme, &mut r.color);
ui.end_row();
});
}
// Live preview: the style as OK would leave it, on the current table's options with
// the region being edited turned on.
let v = table_style_params(id.as_deref(), name, based_on, regions, basis);
let mut look = app
.session
.sel
.focus
.path
.cell()
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look))
.unwrap_or_default();
match *region {
1 => look.header_row = true,
2 => look.total_row = true,
3 => look.first_column = true,
4 => look.last_column = true,
5 => look.banded_rows = true,
6 => look.banded_columns = true,
_ => {}
}
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!("Preview")).small().weak());
table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look);
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok {
let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" };
if let Err(e) = app.run(cmd, v) {
app.status(e);
return false;
}
}
ok || cancel
}
Dialog::Commands { query } => {
@ -909,6 +1364,34 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
close || ui.input(|i| i.key_pressed(egui::Key::Escape))
}
Dialog::PasteSpecial { formats, choice, payload } => {
ui.label(egui::RichText::new(tl!("Paste as:")).font(semibold(12.5)));
if formats.is_empty() {
ui.label(egui::RichText::new(tl!("The clipboard is empty.")).weak());
}
for (i, f) in formats.iter().enumerate() {
let label = wordcraft_engine::cmd::paste::FORMATS.iter().find(|(id, _)| id == f).map(|(_, l)| *l).unwrap_or(f.as_str());
ui.radio_value(choice, i, tl!(label));
}
if let Some(f) = formats.get(*choice) {
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("Result")).font(semibold(12.5)));
let hint = match f.as_str() {
"formatted" => "Keeps the fonts and formatting of the copied text.",
"rtf" => "Reads the clipboard's rich text and keeps its formatting.",
"html" => "Reads the clipboard's web content and keeps its formatting.",
_ => "Drops all formatting and pastes plain text.",
};
ui.label(egui::RichText::new(tl!(hint)).weak());
}
let (ok, cancel) = buttons(ui, tl!("OK"));
if ok && let Some(f) = formats.get(*choice) {
let mut params = payload.clone();
params["as"] = json!(f);
let _ = app.run("edit.pasteSpecial", params);
}
ok || cancel
}
Dialog::About { tab } => {
ui.set_width(660.0);
ui.horizontal(|ui| {
@ -1217,6 +1700,175 @@ fn apply_paragraph(
);
}
/// The Paragraph dialog's Asian Typography: sets the flags that changed since it opened, so an
/// untouched tab adds no direct formatting.
fn apply_asian(app: &mut WordApp, asian: [bool; 6], was: [bool; 6]) {
let keys = ["kinsoku", "wordWrap", "overflowPunct", "topLinePunct", "autoSpaceDE", "autoSpaceDN"];
let changed: serde_json::Map<String, Value> =
keys.iter().zip(asian.iter().zip(was)).filter(|(_, (now, was))| **now != *was).map(|(k, (now, _))| (k.to_string(), json!(now))).collect();
if !changed.is_empty() {
let _ = app.run("para.asianTypography", Value::Object(changed));
}
}
/// Manage Styles' body. Reads the list from `styles.manage`'s data (never re-running the command,
/// which would ask to open this dialog again) and acts through commands.
fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, selected: &mut String) -> bool {
let list = wordcraft_engine::cmd::para::manage_list(&app.session, *alphabetical);
let list = list.as_array().cloned().unwrap_or_default();
let str_of = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
if !list.iter().any(|x| str_of(x, "id") == *selected) {
*selected = list.first().map(|x| str_of(x, "id")).unwrap_or_default();
}
ui.horizontal(|ui| {
ui.label(tl!("Sort order:"));
egui::ComboBox::from_id_salt("manage_styles_sort")
.selected_text(if *alphabetical { tl!("Alphabetical") } else { tl!("As Recommended") })
.show_ui(ui, |ui| {
ui.selectable_value(alphabetical, false, tl!("As Recommended"));
ui.selectable_value(alphabetical, true, tl!("Alphabetical"));
});
});
let t = Tokens::get(ui.ctx());
let shown = egui::Id::new("manage_styles_shown");
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("manage_styles_list").max_height(200.0).auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(380.0);
for st in &list {
let id = str_of(st, "id");
let glyph = match st.get("type").and_then(Value::as_str) {
Some("character") => "a",
Some("linked") => "¶a",
Some("table") => "▦",
_ => "",
};
let hidden = st.get("hidden").and_then(Value::as_bool).unwrap_or(false);
let mut name = egui::RichText::new(format!("{glyph} {}", str_of(st, "name")));
if hidden {
name = name.weak();
}
let r = ui.selectable_label(*selected == id, name);
// Bring the selection into view once, when it changes (opening, New Style…).
if *selected == id && ui.data(|d| d.get_temp::<String>(shown)).as_deref() != Some(id.as_str()) {
r.scroll_to_me(Some(egui::Align::Center));
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
}
if r.clicked() {
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
*selected = id.clone();
}
if r.double_clicked() {
*selected = id;
if let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) =
Dialog::modify_style(app, selected)
{
app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after });
}
}
}
});
});
let leave = |ui: &mut Ui| {
ui.data_mut(|d| d.remove::<String>(shown));
true
};
if app.dialog.is_some() {
return leave(ui);
}
let Some(cur) = list.iter().find(|x| str_of(x, "id") == *selected).cloned() else { return close_button(ui) && leave(ui) };
let ty = str_of(&cur, "type");
ui.add_space(6.0);
crate::previews::style_preview(app, ui, selected, &ty, vec2(388.0, if ty == "table" { 64.0 } else { 40.0 }));
ui.add_space(4.0);
ui.add(egui::Label::new(egui::RichText::new(describe(&cur)).small()).wrap());
ui.add_space(6.0);
let builtin = cur.get("builtIn").and_then(Value::as_bool).unwrap_or(true);
if ty != "table" {
let mut gallery = cur.get("inGallery").and_then(Value::as_bool).unwrap_or(false);
if ui.checkbox(&mut gallery, tl!("Show in the Styles gallery")).changed() {
let _ = app.run("styles.setVisibility", json!({"style": selected, "gallery": gallery}));
}
}
let mut hidden = cur.get("hidden").and_then(Value::as_bool).unwrap_or(false);
if ui.checkbox(&mut hidden, tl!("Hide from style lists")).changed() {
let _ = app.run("styles.setVisibility", json!({"style": selected, "hidden": hidden}));
}
ui.add_space(4.0);
ui.horizontal(|ui| {
// Table styles are edited from Table Design.
if ui.add_enabled(ty != "table", egui::Button::new(tl!("Modify…"))).clicked()
&& let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) = Dialog::modify_style(app, selected)
{
app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after });
}
if ui.button(tl!("New Style…")).clicked() {
app.dialog = Some(Dialog::NewStyle { name: "Style1".into(), based_on: str_of(&cur, "name"), back: true });
}
let del = ui.add_enabled(!builtin, egui::Button::new(tl!("Delete")));
let del = if builtin { del.on_disabled_hover_text(tl!("Built-in styles can't be deleted.")) } else { del };
if del.clicked() {
let _ = app.run("styles.delete", json!({"style": selected}));
selected.clear();
}
});
(app.dialog.is_some() || close_button(ui)) && leave(ui)
}
/// A dialog's single Close button (Escape too).
fn close_button(ui: &mut Ui) -> bool {
ui.add_space(8.0);
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close |= ui.add(egui::Button::new(egui::RichText::new(tl!("Close")).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked();
});
close
}
/// A style's description: its type, the formatting it sets and what it is based on.
fn describe(st: &Value) -> String {
let f = st.get("format").cloned().unwrap_or_default();
let fs = |k: &str| f.get(k).and_then(Value::as_str);
let fnum = |k: &str| f.get(k).and_then(Value::as_f64);
let pt = |x: f64| format!("{}", (x * 10.0).round() / 10.0);
let mut parts: Vec<String> = vec![
match st.get("type").and_then(Value::as_str) {
Some("character") => tl!("Character style"),
Some("linked") => tl!("Linked style (paragraph and character)"),
Some("table") => tl!("Table style"),
_ => tl!("Paragraph style"),
}
.to_string(),
];
if st.get("builtIn").and_then(Value::as_bool).unwrap_or(false) {
parts.push(tl!("Built-in style").to_string());
}
if let Some(x) = fs("font") {
parts.push(crate::i18n::fmt(tl!("Font: {font}"), &[("font", x)]));
}
if let Some(x) = fnum("size") {
parts.push(crate::i18n::fmt(tl!("{size} pt"), &[("size", &pt(x))]));
}
if f.get("bold").and_then(Value::as_bool) == Some(true) {
parts.push(tl!("Bold").to_string());
}
if f.get("italic").and_then(Value::as_bool) == Some(true) {
parts.push(tl!("Italic").to_string());
}
if let Some(x) = fs("color") {
parts.push(crate::i18n::fmt(tl!("Color: {color}"), &[("color", &format!("#{x}"))]));
}
if let Some(x) = fnum("spaceBefore") {
parts.push(crate::i18n::fmt(tl!("Space before: {pt} pt"), &[("pt", &pt(x))]));
}
if let Some(x) = fnum("spaceAfter") {
parts.push(crate::i18n::fmt(tl!("Space after: {pt} pt"), &[("pt", &pt(x))]));
}
if let Some(b) = st.get("basedOn").and_then(Value::as_str) {
parts.push(crate::i18n::fmt(tl!("Based on: {style}"), &[("style", b)]));
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
@ -1235,7 +1887,7 @@ mod tests {
/// Open the Paragraph dialog and press OK without changing anything.
fn ok_unchanged(app: &mut WordApp) {
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow }) =
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, .. }) =
Dialog::open("paragraph", app)
else {
panic!("no paragraph dialog")

View File

@ -24,7 +24,10 @@
Accept This Change Aceptar este cambio
Accessibility Accesibilidad
Add Añadir
Add Object to Selection Añadir objeto a la selección
Add Space Before Paragraph Añadir espacio antes del párrafo
Add space between Asian and Latin text Añadir espacio entre texto asiático y latino
Add space between Asian text and numbers Añadir espacio entre texto asiático y números
Add Text Añadir texto
Add a Table Añadir una tabla
Add a comment Añadir un comentario
@ -35,6 +38,8 @@
Address\nBlock Bloque de\ndirección
Adjust Ajustar
Advanced Avanzado
Advanced Find Búsqueda avanzada
Advanced Find… Búsqueda avanzada…
After: Después:
Agents Agentes
Align Alinear
@ -45,13 +50,17 @@
All Caps Mayúsculas
All Markup Todas las marcas
All caps Mayúsculas
Allow hanging punctuation Permitir puntuación colgante
Allow Latin text to wrap in the middle of a word Permitir que el texto latino se divida en mitad de una palabra
Alt Text Texto alternativo
Apply Asian line-breaking rules (kinsoku) Aplicar las reglas asiáticas de división de líneas (kinsoku)
Apply Character Style Aplicar estilo de carácter
Apply Style Aplicar estilo
Arrange Organizar
Arrange\nAll Organizar\ntodo
Arrow Flecha
Artistic Effects Efectos artísticos
Asian Typography Tipografía asiática
Author Autor
AutoCorrect Options Opciones de autocorrección
AutoFit Ajustar automáticamente
@ -109,6 +118,7 @@
Character Scale Escala del carácter
Character Shading Sombreado de carácter
Character Spacing Espaciado de caracteres
Characters Caracteres
Characters (no spaces) Caracteres (sin espacios)
Characters (with spaces) Caracteres (con espacios)
Chart Gráfico
@ -120,8 +130,21 @@
Citation Style Estilo de cita
Citations & Bibliography Citas y bibliografía
Clear Borrar
Clear All Borrar todo
Clear All Formatting Borrar todo el formato
Clear Character Formatting Borrar formato de carácter
Clear Character Style Borrar estilo de carácter
Clear Formatting Level Borrar nivel de formato
Clear Paragraph Formatting Borrar formato de párrafo
Clipboard Portapapeles
Clipboard Pane Panel del portapapeles
Clipboard Items Elementos del portapapeles
Paste Clipboard Item Pegar elemento del portapapeles
Paste All Pegar todo
Delete Clipboard Item Eliminar elemento del portapapeles
Click an item to paste it. Haz clic en un elemento para pegarlo.
Nothing collected yet. Items you copy or cut appear here. Aún no hay nada. Lo que copies o cortes aparecerá aquí.
(picture or object) (imagen u objeto)
Close Cerrar
Close Header and Footer Cerrar encabezado y pie de página
Collapse Selection Contraer selección
@ -140,6 +163,7 @@
Compact Compacto
Compare Comparar
Compress Pictures Comprimir imágenes
Compress punctuation at the start of a line Comprimir la puntuación al inicio de la línea
Continuous Continuo
Convert Convertir
Convert Text to Table Convertir texto en tabla
@ -187,6 +211,7 @@
Dictate Dictar
Different First Page Primera página diferente
Different Odd & Even Pages Páginas pares e impares diferentes
Direct formatting Formato directo
Discord Discord
Discord community Comunidad de Discord
Display Mostrar
@ -214,6 +239,7 @@
Drawing Tools Herramientas de dibujo
Drawing\nCanvas Lienzo de\ndibujo
Drop Cap Letra capital
Drops all formatting and pastes plain text. Quita todo el formato y pega texto sin formato.
Edit Footer Editar pie de página
Edit Header Editar encabezado
Edit Recipient List Editar lista de destinatarios
@ -269,11 +295,13 @@
Format Page Numbers Formato de números de página
Format Page Numbers… Formato de números de página…
Format Painter Pincel de formato
Formatted Text Texto con formato
Formatting at Selection Formato en la selección
Formula Fórmula
General General
Go To Ir a
Go To (⌘⌥G) Ir a (⌘⌥G)
Go To… Ir a…
Go to Ir a
Grab bag Bolsa de datos adicionales
Greeting Line Línea de saludo
@ -281,6 +309,7 @@
Gridlines Líneas de cuadrícula
Group Agrupar
Grow Font 1 Point Aumentar fuente 1 punto
HTML Format Formato HTML
Hanging Indent Sangría francesa
Header Encabezado
Header & Footer Encabezado y pie de página
@ -311,6 +340,7 @@
Indent Sangría
Indentation Sangría
Indents Sangrías
Indents and Spacing Sangría y espaciado
Index Índice
Info Información
Ink to\nMath Tinta a\nfórmula
@ -340,6 +370,7 @@
Insert\nFootnote Insertar nota\nal pie
Inside Borders Bordes interiores
Inspect Document Inspeccionar documento
Inspect Styles at Selection Inspeccionar estilos de la selección
Interface language: Idioma de la interfaz:
Italic Cursiva
Join the ArtCraft community on Discord Únete a la comunidad de ArtCraft en Discord
@ -350,6 +381,7 @@
Keep lines together Mantener líneas juntas
Keep with Next Mantener con el siguiente
Keep with next Mantener con el siguiente
Keeps the fonts and formatting of the copied text. Conserva las fuentes y el formato del texto copiado.
Labels Etiquetas
Landscape Horizontal
Language Idioma
@ -364,6 +396,7 @@
Letter Carta
Line Línea
Line Break Salto de línea
Line breaking División de líneas
Line End Fin de línea
Line Numbers Números de línea
Line Spacing Interlineado
@ -494,6 +527,9 @@
Paste Pegar
Paste (⌘V) Pegar (⌘V)
Paste Formatting Pegar formato
Paste Special Pegado especial
Paste Special… Pegado especial…
Paste as: Pegar como:
Paste: Keep Text Only Pegar: mantener solo el texto
Paste: Merge Formatting Pegar: combinar formato
Pen Pluma
@ -527,6 +563,9 @@
Read Mode Modo lectura
Read\nAloud Leer en\nvoz alta
Read\nMode Modo\nlectura
Reads the clipboard's rich text and keeps its formatting. Lee el texto enriquecido del portapapeles y conserva su formato.
Reads the clipboard's web content and keeps its formatting. Lee el contenido web del portapapeles y conserva su formato.
Reading Highlight Resaltado de lectura
Recent Recientes
Record Macro Grabar macro
Recover Unsaved Documents Recuperar documentos sin guardar
@ -557,6 +596,7 @@
Research Investigación
Researcher Investigador
Reset Picture Restablecer imagen
Reset to Normal Restablecer a Normal
Resolve Resolver
Resolved Resuelto
Restart Each Page Reiniciar en cada página
@ -564,12 +604,14 @@
Restart at 1 Reiniciar en 1
Restrict Editing Restringir edición
Restrict\nEditing Restringir\nedición
Result Resultado
Results Resultados
Reverse the order Invertir el orden
Reviewing Revisión
Reviewing Pane Panel de revisión
Reviewing — edits become suggestions Revisión — las ediciones pasan a ser sugerencias
Rich Text Format (*.rtf) Formato de texto enriquecido (*.rtf)
Rich Text Format (RTF) Formato de texto enriquecido (RTF)
Right Derecha
Right Border Borde derecho
Right-to-Left Text Direction Dirección de texto de derecha a izquierda
@ -669,6 +711,7 @@
Studio cover Portada de Studio
Studio handbook (sample) Manual de Studio (ejemplo)
Style Estilo
Style Inspector Inspector de estilos
Style Set Conjunto de estilos
Style based on: Estilo basado en:
Style: APA Estilo: APA
@ -708,6 +751,7 @@
Text: Texto:
Text\nBox Cuadro de\ntexto
Text\nDirection Dirección del\ntexto
The clipboard is empty. El portapapeles está vacío.
The new style takes the formatting of the current paragraph. El nuevo estilo toma el formato del párrafo actual.
Theme Colors Colores del tema
Theme Fonts Fuentes del tema
@ -730,11 +774,13 @@
Two Dos
Type Text Escribir texto
Type a command, e.g. "insert table" Escribe un comando, p. ej. "insertar tabla"
Ungroup Desagrupar
UPPERCASE MAYÚSCULAS
Underline Subrayado
Underline Words Only Subrayar solo las palabras
Undo Deshacer
Undo {action} Deshacer {action}
Unformatted Text Texto sin formato
Up Arriba
Update Field Actualizar campo
Update Index Actualizar índice
@ -895,6 +941,42 @@
Opening print dialog… Abriendo el cuadro de diálogo de impresión…
Print failed: {error} Error al imprimir: {error}
Couldn't open {path}: {error} No se pudo abrir {path}: {error}
Column Selection Selección de columna
New Table Style Nuevo estilo de tabla
Modify Table Style Modificar estilo de tabla
New Table Style… Nuevo estilo de tabla…
Modify Table Style… Modificar estilo de tabla…
Apply formatting to: Aplicar formato a:
Whole Table Toda la tabla
Fill color: Color de relleno:
{count} items highlighted {count} elementos resaltados
{n} pt {n} pt
Not {name} Sin {name}
Level {n} Nivel {n}
Body Text Texto independiente
{n} lines {n} líneas
At least {n} Mínimo {n}
Exactly {n} Exacto {n}
Centered Centrado
Character spacing Espaciado de caracteres
Don't add space between paragraphs of the same style No agregar espacio entre párrafos del mismo estilo
Don't hyphenate No separar con guiones
Double strikethrough Doble tachado
Drop cap Letra capital
First line Primera línea
Font color Color de fuente
Highlight Resaltado
Justified Justificado
Left indent Sangría izquierda
Line spacing Interlineado
Outline level Nivel de esquema
Raised/lowered Elevado/bajado
Right indent Sangría derecha
Scale Escala
Space after Espacio posterior
Space before Espacio anterior
Suppress line numbers Suprimir números de línea
Underline color Color de subrayado
Letters Cartas
E-mail Messages Mensajes de correo electrónico
Directory Directorio
@ -966,3 +1048,25 @@
Offset: Top Right Desplazamiento: arriba a la derecha
Offset: Top Desplazamiento: arriba
Offset: Top Left Desplazamiento: arriba a la izquierda
Manage Styles Administrar estilos
Show or Hide Style Mostrar u ocultar estilo
Manage Styles… Administrar estilos…
Sort order: Orden:
As Recommended Recomendado
Alphabetical Alfabético
Font: {font} Fuente: {font}
{size} pt {size} pt
Color: {color} Color: {color}
Space before: {pt} pt Espacio antes: {pt} pt
Space after: {pt} pt Espacio después: {pt} pt
Based on: {style} Basado en: {style}
Paragraph style Estilo de párrafo
Character style Estilo de carácter
Linked style (paragraph and character) Estilo vinculado (párrafo y carácter)
Table style Estilo de tabla
Built-in style Estilo integrado
Show in the Styles gallery Mostrar en la galería de estilos
Hide from style lists Ocultar de las listas de estilos
Built-in styles can't be deleted. Los estilos integrados no se pueden eliminar.
Delete Table Style Eliminar estilo de tabla
Preview Vista previa

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -12,7 +12,10 @@
Add 追加
Add a comment コメントを追加
Add a Table 表を追加
Add Object to Selection オブジェクトを選択範囲に追加
Add Space Before Paragraph 段落前に間隔を追加
Add space between Asian and Latin text アジア文字と欧文の間隔を空ける
Add space between Asian text and numbers アジア文字と数字の間隔を空ける
Add Text テキストの追加
Add to Dictionary 辞書に追加
Add to Style Gallery スタイル ギャラリーに追加
@ -31,7 +34,10 @@
All Caps すべて大文字
All caps すべて大文字
All Markup すべての変更履歴
Allow hanging punctuation 句読点のぶら下げを行う
Allow Latin text to wrap in the middle of a word 欧文を単語の途中で折り返す
Alt Text 代替テキスト
Apply Asian line-breaking rules (kinsoku) アジア文字の禁則処理を行う
Apply Character Style 文字スタイルを適用
Apply Style スタイルを適用
Apply Styles スタイルを選んで適用
@ -39,6 +45,7 @@
Arrange\nAll 整列
Arrow 矢印
Artistic Effects アート効果
Asian Typography アジア文字の組版
Author 作成者
AutoCorrect Options オートコレクトのオプション
AutoFit 自動調整
@ -52,7 +59,10 @@
AutoSave 自動保存
AutoSave documents you have saved in WordCraft WordCraft で保存した文書を自動保存する
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動保存できませんでした: {error}。文書を保存し直すと、自動保存が再開します。
Compress punctuation at the start of a line 行頭の約物を詰める
Dark page (white text on black) ダーク ページ (黒地に白い文字)
Indents and Spacing インデントと間隔
Line breaking 行の分割
More templates → その他のテンプレート →
No templates match your search. 検索に一致するテンプレートはありません。
Search for templates テンプレートを検索
@ -117,6 +127,15 @@
Clear クリア
Clear All Formatting すべての書式をクリア
Clipboard クリップボード
Clipboard Pane クリップボード ウィンドウ
Clipboard Items クリップボードの項目
Paste Clipboard Item クリップボードの項目を貼り付け
Paste All すべて貼り付け
Delete Clipboard Item クリップボードの項目を削除
Clear All すべてクリア
Click an item to paste it. 項目をクリックすると貼り付けます。
Nothing collected yet. Items you copy or cut appear here. まだ何もありません。コピーまたは切り取った項目がここに表示されます。
(picture or object) (画像またはオブジェクト)
Close 閉じる
Close Header and Footer ヘッダーとフッターを閉じる
Collapse Selection 選択範囲を縮小
@ -735,6 +754,7 @@
Underline Words Only 単語のみ下線
Undo 元に戻す
Undo {action} 元に戻す: {action}
Ungroup グループ解除
Up 上
Update Field フィールドの更新
Update Index 索引の更新
@ -878,6 +898,68 @@
Opening print dialog… 印刷ダイアログを開いています…
Print failed: {error} 印刷できませんでした: {error}
Couldn't open {path}: {error} {path} を開けませんでした: {error}
Column Selection 矩形選択
Style Inspector スタイル インスペクター
Inspect Styles at Selection 選択箇所のスタイルを調べる
Clear Formatting Level 書式のレベルをクリア
Characters 文字
Direct formatting 直接の書式
Reset to Normal 標準に戻す
Clear Paragraph Formatting 段落の書式をクリア
Clear Character Style 文字スタイルをクリア
Clear Character Formatting 文字の書式をクリア
Paste Special 形式を選択して貼り付け
Paste Special… 形式を選択して貼り付け…
Paste as: 貼り付ける形式:
The clipboard is empty. クリップボードは空です。
Formatted Text 書式付きテキスト
Rich Text Format (RTF) リッチ テキスト形式 (RTF)
HTML Format HTML 形式
Unformatted Text 書式なしテキスト
Result 結果
Keeps the fonts and formatting of the copied text. コピーしたテキストのフォントと書式を保持します。
Reads the clipboard's rich text and keeps its formatting. クリップボードの RTF を読み込み、書式を保持します。
Reads the clipboard's web content and keeps its formatting. クリップボードの Web コンテンツを読み込み、書式を保持します。
Drops all formatting and pastes plain text. 書式をすべて取り除き、プレーン テキストとして貼り付けます。
Advanced Find 高度な検索
Advanced Find… 高度な検索…
Go To… ジャンプ…
Reading Highlight 検索結果を強調表示
New Table Style 新しい表スタイル
Modify Table Style 表スタイルの変更
New Table Style… 新しい表スタイル…
Modify Table Style… 表スタイルの変更…
Apply formatting to: 書式の適用先:
Whole Table 表全体
Fill color: 塗りつぶしの色:
{count} items highlighted {count} 件を強調表示しました
{n} pt {n} pt
Not {name} {name} なし
Level {n} レベル {n}
Body Text 本文
{n} lines {n} 行
At least {n} 最小値 {n}
Exactly {n} 固定値 {n}
Centered 中央揃え
Character spacing 文字間隔
Don't add space between paragraphs of the same style 同じスタイルの段落間に間隔を追加しない
Don't hyphenate ハイフネーションなし
Double strikethrough 二重取り消し線
Drop cap ドロップ キャップ
First line 最初の行
Font color フォントの色
Highlight 蛍光ペン
Justified 両端揃え
Left indent 左インデント
Line spacing 行間
Outline level アウトライン レベル
Raised/lowered 上げ/下げ
Right indent 右インデント
Scale 倍率
Space after 段落後の間隔
Space before 段落前の間隔
Suppress line numbers 行番号を表示しない
Underline color 下線の色
Letters レター
E-mail Messages 電子メール
Directory ディレクトリ
@ -949,3 +1031,25 @@
Offset: Top Right オフセット: 右上
Offset: Top オフセット: 上
Offset: Top Left オフセット: 左上
Manage Styles スタイルの管理
Show or Hide Style スタイルの表示/非表示
Manage Styles… スタイルの管理…
Sort order: 並べ替え順:
As Recommended 推奨順
Alphabetical アルファベット順
Font: {font} フォント: {font}
{size} pt {size} pt
Color: {color} 色: {color}
Space before: {pt} pt 前の間隔: {pt} pt
Space after: {pt} pt 後の間隔: {pt} pt
Based on: {style} 基準: {style}
Paragraph style 段落スタイル
Character style 文字スタイル
Linked style (paragraph and character) リンク スタイル (段落と文字)
Table style 表スタイル
Built-in style 組み込みスタイル
Show in the Styles gallery スタイル ギャラリーに表示
Hide from style lists スタイルの一覧に表示しない
Built-in styles can't be deleted. 組み込みスタイルは削除できません。
Delete Table Style 表スタイルの削除
Preview プレビュー

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -47,7 +47,7 @@ pub struct LangInfo {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 7] = [
pub static LANGUAGES: [LangInfo; 9] = [
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`).
LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() },
@ -60,6 +60,12 @@ pub static LANGUAGES: [LangInfo; 7] = [
// Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no
// catalog yet (the European vocabulary differs), so they stay in English.
LangInfo { code: "pt-br", name: "Português (Brasil)", source: include_str!("pt-br.tsv"), prefer_hans: false, catalog: OnceLock::new() },
// Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`,
// `sr-Cyrl-*` resolve here.
LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, catalog: OnceLock::new() },
// Serbian, Latin script; `sr-Latn-*` resolves here (see `candidates`'s generic prefix
// matching — no special-casing needed, unlike Chinese's script-by-region fallback).
LangInfo { code: "sr-latn", name: "Srpski (latinica)", source: include_str!("sr-latn.tsv"), prefer_hans: false, catalog: OnceLock::new() },
];
impl LangInfo {

View File

@ -23,7 +23,10 @@
Add Adicionar
Add a comment Adicionar um comentário
Add a Table Adicionar uma tabela
Add Object to Selection Adicionar objeto à seleção
Add Space Before Paragraph Adicionar espaço antes do parágrafo
Add space between Asian and Latin text Adicionar espaço entre texto asiático e latino
Add space between Asian text and numbers Adicionar espaço entre texto asiático e números
Add Text Adicionar texto
Add to Dictionary Adicionar ao dicionário
Add to Style Gallery Adicionar à galeria de estilos
@ -42,13 +45,17 @@
All Caps Tudo em maiúsculas
All caps Tudo em maiúsculas
All Markup Todas as marcações
Allow hanging punctuation Permitir pontuação pendente
Allow Latin text to wrap in the middle of a word Permitir que o texto latino quebre no meio de uma palavra
Alt Text Texto alternativo
Apply Asian line-breaking rules (kinsoku) Aplicar as regras asiáticas de quebra de linha (kinsoku)
Apply Character Style Aplicar estilo de caractere
Apply Style Aplicar estilo
Arrange Organizar
Arrange\nAll Organizar\ntudo
Arrow Seta
Artistic Effects Efeitos artísticos
Asian Typography Tipografia asiática
Author Autor
AutoCorrect Options Opções de autocorreção
AutoFit Autoajuste
@ -119,6 +126,15 @@
Clear Limpar
Clear All Formatting Limpar toda a formatação
Clipboard Área de transferência
Clipboard Pane Painel da área de transferência
Clipboard Items Itens da área de transferência
Paste Clipboard Item Colar item da área de transferência
Paste All Colar tudo
Delete Clipboard Item Excluir item da área de transferência
Clear All Limpar tudo
Click an item to paste it. Clique em um item para colá-lo.
Nothing collected yet. Items you copy or cut appear here. Nada coletado ainda. Os itens que você copiar ou recortar aparecem aqui.
(picture or object) (imagem ou objeto)
Close Fechar
Close Header and Footer Fechar cabeçalho e rodapé
Collapse Selection Recolher seleção
@ -137,6 +153,7 @@
Compact Compacto
Compare Comparar
Compress Pictures Compactar imagens
Compress punctuation at the start of a line Comprimir a pontuação no início da linha
Continuous Contínua
Convert Converter
Convert Text to Table Converter texto em tabela
@ -179,6 +196,8 @@
Delete Style Excluir estilo
Delete Table Excluir tabela
Design Design
Indents and Spacing Recuos e espaçamento
Line breaking Quebra de linhas
More templates → Mais modelos →
No templates match your search. Nenhum modelo corresponde à pesquisa.
Search for templates Pesquisar modelos
@ -735,6 +754,7 @@
Underline Words Only Sublinhar somente palavras
Undo Desfazer
Undo {action} Desfazer {action}
Ungroup Desagrupar
Up Para cima
Update Field Atualizar campo
Update Index Atualizar índice
@ -890,6 +910,68 @@
Opening print dialog… Abrindo a caixa de diálogo de impressão…
Print failed: {error} Falha na impressão: {error}
Couldn't open {path}: {error} Não foi possível abrir {path}: {error}
Column Selection Seleção de coluna
Style Inspector Inspetor de estilos
Inspect Styles at Selection Inspecionar estilos na seleção
Clear Formatting Level Limpar nível de formatação
Characters Caracteres
Direct formatting Formatação direta
Reset to Normal Redefinir para Normal
Clear Paragraph Formatting Limpar formatação do parágrafo
Clear Character Style Limpar estilo de caractere
Clear Character Formatting Limpar formatação de caractere
Paste Special Colar especial
Paste Special… Colar especial…
Paste as: Colar como:
The clipboard is empty. A área de transferência está vazia.
Formatted Text Texto formatado
Rich Text Format (RTF) Rich Text Format (RTF)
HTML Format Formato HTML
Unformatted Text Texto sem formatação
Result Resultado
Keeps the fonts and formatting of the copied text. Mantém as fontes e a formatação do texto copiado.
Reads the clipboard's rich text and keeps its formatting. Lê o rich text da área de transferência e mantém a formatação.
Reads the clipboard's web content and keeps its formatting. Lê o conteúdo web da área de transferência e mantém a formatação.
Drops all formatting and pastes plain text. Remove toda a formatação e cola texto simples.
Advanced Find Localização avançada
Advanced Find… Localização avançada…
Go To… Ir para…
Reading Highlight Realçar resultados
New Table Style Novo estilo de tabela
Modify Table Style Modificar estilo de tabela
New Table Style… Novo estilo de tabela…
Modify Table Style… Modificar estilo de tabela…
Apply formatting to: Aplicar formatação a:
Whole Table Tabela inteira
Fill color: Cor de preenchimento:
{count} items highlighted {count} itens realçados
{n} pt {n} pt
Not {name} Sem {name}
Level {n} Nível {n}
Body Text Corpo do texto
{n} lines {n} linhas
At least {n} Pelo menos {n}
Exactly {n} Exatamente {n}
Centered Centralizado
Character spacing Espaçamento entre caracteres
Don't add space between paragraphs of the same style Não adicionar espaço entre parágrafos do mesmo estilo
Don't hyphenate Não hifenizar
Double strikethrough Tachado duplo
Drop cap Capitular
First line Primeira linha
Font color Cor da fonte
Highlight Realce
Justified Justificado
Left indent Recuo à esquerda
Line spacing Espaçamento entre linhas
Outline level Nível de tópicos
Raised/lowered Elevado/rebaixado
Right indent Recuo à direita
Scale Escala
Space after Espaço depois
Space before Espaço antes
Suppress line numbers Suprimir números de linha
Underline color Cor do sublinhado
Letters Cartas
E-mail Messages Mensagens de email
Directory Diretório
@ -961,3 +1043,25 @@
Offset: Top Right Deslocamento: superior direito
Offset: Top Deslocamento: superior
Offset: Top Left Deslocamento: superior esquerdo
Manage Styles Gerenciar estilos
Show or Hide Style Mostrar ou ocultar estilo
Manage Styles… Gerenciar estilos…
Sort order: Ordem:
As Recommended Recomendada
Alphabetical Alfabética
Font: {font} Fonte: {font}
{size} pt {size} pt
Color: {color} Cor: {color}
Space before: {pt} pt Espaço antes: {pt} pt
Space after: {pt} pt Espaço depois: {pt} pt
Based on: {style} Baseado em: {style}
Paragraph style Estilo de parágrafo
Character style Estilo de caractere
Linked style (paragraph and character) Estilo vinculado (parágrafo e caractere)
Table style Estilo de tabela
Built-in style Estilo integrado
Show in the Styles gallery Mostrar na galeria de estilos
Hide from style lists Ocultar das listas de estilos
Built-in styles can't be deleted. Estilos integrados não podem ser excluídos.
Delete Table Style Excluir estilo de tabela
Preview Visualização

Can't render this file because it contains an unexpected character in line 6 and column 25.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,11 @@ fn system_locales_map_to_languages() {
assert_eq!(lang_from_tag("fr-FR"), None);
assert_eq!(lang_from_tag(""), None);
assert_eq!(lang_from_tag("_"), None);
assert_eq!(lang_from_tag("sr"), Some(lang("sr")), "Cyrillic is the default script");
assert_eq!(lang_from_tag("sr-RS"), Some(lang("sr")));
assert_eq!(lang_from_tag("sr-Cyrl-RS"), Some(lang("sr")));
assert_eq!(lang_from_tag("sr-Latn-RS"), Some(lang("sr-latn")));
assert_eq!(lang_from_tag("sr-Latn"), Some(lang("sr-latn")));
}
#[test]
@ -209,6 +214,69 @@ fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() {
}
}
#[test]
fn serbian_covers_the_entire_existing_interface_catalog() {
use std::collections::HashSet;
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
let reference = keys(lang("zh-hans").0.source);
assert_eq!(keys(lang("sr").0.source), reference);
assert_eq!(keys(lang("sr-latn").0.source), reference);
let (cyr, lat) = (lang("sr"), lang("sr-latn"));
assert_eq!(tr(cyr, "Home"), "Почетак");
assert_eq!(tr(lat, "Home"), "Početak");
assert_eq!(tr(cyr, "unknown future label"), "unknown future label");
assert_eq!(fmt(tr(lat, "Exported {path}"), &[("path", "draft-1.docx")]), "Izvezeno: draft-1.docx");
}
#[test]
fn bundled_interface_fonts_cover_serbian_cyrillic_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("sr").0.source);
assert!(errors.is_empty());
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
for ch in &chars {
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}");
}
}
}
#[test]
fn bundled_interface_fonts_cover_serbian_latin_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("sr-latn").0.source);
assert!(errors.is_empty());
// Serbian Latin's letters beyond plain ASCII: š č ć ž đ (and their capitals).
let extra = ['š', 'č', 'ć', 'ž', 'đ', 'Š', 'Č', 'Ć', 'Ž', 'Đ'];
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
for ch in &chars {
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}");
}
}
}
#[test]
fn serbian_locales_resolve_by_script() {
let cyr = lang("sr");
let lat = lang("sr-latn");
for tag in ["sr", "sr-RS", "sr_RS.UTF-8", "SR", "sr-Cyrl", "sr-Cyrl-RS"] {
assert_eq!(lang_from_tag(tag), Some(cyr), "{tag}");
}
for tag in ["sr-Latn", "sr-Latn-RS", "sr-latn-me"] {
assert_eq!(lang_from_tag(tag), Some(lat), "{tag}");
}
assert_eq!(first_supported(["fr-FR", "sr-Latn-RS", "en-US"]), Some(lat));
assert_eq!(tr(cyr, "File"), "Фајл");
assert_eq!(tr(lat, "File"), "Fajl");
}
#[test]
fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_document() {
let pt = lang("pt-br");

View File

@ -23,7 +23,10 @@
Add Додати
Add a comment Додати коментар
Add a Table Додати таблицю
Add Object to Selection Додати об'єкт до виділення
Add Space Before Paragraph Додати інтервал перед абзацом
Add space between Asian and Latin text Додавати інтервал між азійським і латинським текстом
Add space between Asian text and numbers Додавати інтервал між азійським текстом і числами
Add Text Додати текст
Add to Dictionary Додати до словника
Add to Style Gallery Додати до колекції стилів
@ -32,6 +35,8 @@
Address\nBlock Блок\nадреси
Adjust Налаштувати
Advanced Додатково
Advanced Find Розширений пошук
Advanced Find… Розширений пошук…
After: Після:
Agents Агенти
Align Вирівняти
@ -42,13 +47,17 @@
All Caps Усі великі літери
All caps Усі великі літери
All Markup Усі позначки
Allow hanging punctuation Дозволити висячу пунктуацію
Allow Latin text to wrap in the middle of a word Дозволити розрив латинського тексту посеред слова
Alt Text Альтернативний текст
Apply Asian line-breaking rules (kinsoku) Застосовувати азійські правила розриву рядків (кінсоку)
Apply Character Style Застосувати стиль символів
Apply Style Застосувати стиль
Arrange Упорядкувати
Arrange\nAll Упорядкувати\nвсі
Arrow Стрілка
Artistic Effects Художні ефекти
Asian Typography Азійська типографіка
Author Автор
AutoCorrect Options Параметри автовиправлення
AutoFit Добір розміру
@ -106,6 +115,7 @@
Character Scale Масштаб символів
Character Shading Заливка символів
Character Spacing Інтервал між символами
Characters Символи
Characters (no spaces) Символи (без пробілів)
Characters (with spaces) Символи (з пробілами)
Chart Діаграма
@ -117,8 +127,21 @@
Citation Style Стиль цитування
Citations & Bibliography Цитати й бібліографія
Clear Очистити
Clear All Очистити все
Clear All Formatting Очистити все форматування
Clear Character Formatting Очистити форматування символів
Clear Character Style Очистити стиль символів
Clear Formatting Level Очистити рівень форматування
Clear Paragraph Formatting Очистити форматування абзацу
Clipboard Буфер обміну
Clipboard Pane Панель буфера обміну
Clipboard Items Елементи буфера обміну
Paste Clipboard Item Вставити елемент буфера обміну
Paste All Вставити все
Delete Clipboard Item Видалити елемент буфера обміну
Click an item to paste it. Клацніть елемент, щоб вставити його.
Nothing collected yet. Items you copy or cut appear here. Поки що порожньо. Тут з'являться елементи, які ви скопіюєте або виріжете.
(picture or object) (зображення або об'єкт)
Close Закрити
Close Header and Footer Закрити колонтитули
Collapse Selection Зняти виділення
@ -137,6 +160,7 @@
Compact Компактний
Compare Порівняти
Compress Pictures Стиснути зображення
Compress punctuation at the start of a line Стискати розділові знаки на початку рядка
Continuous Безперервний
Convert Перетворити
Convert Text to Table Перетворити текст на таблицю
@ -179,6 +203,8 @@
Delete Style Видалити стиль
Delete Table Видалити таблицю
Design Дизайн
Indents and Spacing Відступи й інтервали
Line breaking Розрив рядків
More templates → Більше шаблонів →
No templates match your search. Немає шаблонів, що відповідають пошуку.
Search for templates Пошук шаблонів
@ -188,6 +214,7 @@
Dictate Диктувати
Different First Page Інша перша сторінка
Different Odd & Even Pages Різні парні й непарні сторінки
Direct formatting Пряме форматування
Discord Discord
Discord community Спільнота Discord
Display Відображення
@ -215,6 +242,7 @@
Drawing Tools Знаряддя малювання
Drawing\nCanvas Полотно\nмалювання
Drop Cap Буквиця
Drops all formatting and pastes plain text. Відкидає все форматування й вставляє звичайний текст.
Edit Footer Редагувати нижній колонтитул
Edit Header Редагувати верхній колонтитул
Edit Recipient List Редагувати список одержувачів
@ -271,12 +299,14 @@
Format Page Numbers Формат номерів сторінок
Format Page Numbers… Формат номерів сторінок…
Format Painter Пензель форматування
Formatted Text Форматований текст
Formatting at Selection Форматування виділення
Formula Формула
General Загальні
Go To Перейти
Go to Перейти
Go To (⌘⌥G) Перейти (⌘⌥G)
Go To… Перейти…
Grab bag Різне
Greeting Line Рядок привітання
Greeting\nLine Рядок\nпривітання
@ -301,6 +331,7 @@
Home Основне
Horizontal Горизонтальна
Horizontal Line Горизонтальна лінія
HTML Format Формат HTML
Hyphenation Перенесення слів
Icons Піктограми
Ignore All Ігнорувати всі
@ -342,6 +373,7 @@
Insert\nFootnote Вставити\nвиноску
Inside Borders Внутрішні межі
Inspect Document Перевірити документ
Inspect Styles at Selection Переглянути стилі у виділенні
Interface language: Мова інтерфейсу:
Italic Курсив
Join the ArtCraft community on Discord Приєднатися до спільноти ArtCraft у Discord
@ -352,6 +384,7 @@
Keep Text Only Зберегти лише текст
Keep with Next Не відривати від наступного
Keep with next Не відривати від наступного
Keeps the fonts and formatting of the copied text. Зберігає шрифти й форматування скопійованого тексту.
Labels Етикетки
Landscape Альбомна
Language Мова
@ -493,7 +526,10 @@
Paragraphs Абзаци
Paste Вставити
Paste (⌘V) Вставити (⌘V)
Paste as: Вставити як:
Paste Formatting Вставити форматування
Paste Special Спеціальна вставка
Paste Special… Спеціальна вставка…
Paste: Keep Text Only Вставлення: зберегти лише текст
Paste: Merge Formatting Вставлення: об'єднати форматування
PDF document (*.pdf) Документ PDF (*.pdf)
@ -528,6 +564,9 @@
Read Mode Режим читання
Read\nAloud Читати\nвголос
Read\nMode Режим\nчитання
Reads the clipboard's rich text and keeps its formatting. Читає форматований текст (RTF) із буфера обміну та зберігає його форматування.
Reads the clipboard's web content and keeps its formatting. Читає вебвміст із буфера обміну та зберігає його форматування.
Reading Highlight Виділення під час читання
Recent Останні
Record Macro Записати макрос
Recover Unsaved Documents Відновити незбережені документи
@ -558,6 +597,7 @@
Research Дослідження
Researcher Дослідник
Reset Picture Скинути зображення
Reset to Normal Скинути до звичайного
Resolve Вирішити
Resolved Вирішено
Restart at 1 Почати з 1
@ -565,6 +605,7 @@
Restart Each Section Починати в кожному розділі
Restrict Editing Обмежити редагування
Restrict\nEditing Обмежити\nредагування
Result Результат
Results Результати
Reverse the order Зворотний порядок
Review Рецензування
@ -572,6 +613,7 @@
Reviewing Pane Панель рецензування
Reviewing — edits become suggestions Рецензування — зміни стають пропозиціями
Rich Text Format (*.rtf) Форматований текст (*.rtf)
Rich Text Format (RTF) Формат RTF (Rich Text Format)
Right Праворуч
Right Border Права межа
Right-to-Left Text Direction Напрямок тексту справа наліво
@ -671,6 +713,7 @@
Studio handbook (sample) Посібник студії (приклад)
Style Стиль
Style based on: Стиль на основі:
Style Inspector Інспектор стилів
Style Set Набір стилів
Style: APA Стиль: APA
Styles Стилі
@ -709,6 +752,7 @@
Text: Текст:
Text\nBox Текстове\nполе
Text\nDirection Напрямок\nтексту
The clipboard is empty. Буфер обміну порожній.
The new style takes the formatting of the current paragraph. Новий стиль використовує форматування поточного абзацу.
Theme Colors Кольори теми
Theme Fonts Шрифти теми
@ -735,6 +779,8 @@
Underline Words Only Підкреслити лише слова
Undo Скасувати
Undo {action} Скасувати {action}
Ungroup Розгрупувати
Unformatted Text Неформатований текст
Up Угору
Update Field Оновити поле
Update Index Оновити покажчик
@ -890,6 +936,42 @@
Opening print dialog… Відкриття діалогу друку…
Print failed: {error} Не вдалося надрукувати: {error}
Couldn't open {path}: {error} Не вдалося відкрити {path}: {error}
Column Selection Виділення стовпця
New Table Style Новий стиль таблиці
Modify Table Style Змінити стиль таблиці
New Table Style… Новий стиль таблиці…
Modify Table Style… Змінити стиль таблиці…
Apply formatting to: Застосувати форматування до:
Whole Table Уся таблиця
Fill color: Колір заливки:
{count} items highlighted Виділено елементів: {count}
{n} pt {n} пт
Not {name} Без: {name}
Level {n} Рівень {n}
Body Text Основний текст
{n} lines Рядків: {n}
At least {n} Щонайменше {n}
Exactly {n} Точно {n}
Centered По центру
Character spacing Інтервал між символами
Don't add space between paragraphs of the same style Не додавати інтервал між абзацами одного стилю
Don't hyphenate Без перенесення слів
Double strikethrough Подвійне закреслення
Drop cap Буквиця
First line Перший рядок
Font color Колір шрифту
Highlight Виділення кольором
Justified За шириною
Left indent Відступ ліворуч
Line spacing Міжрядковий інтервал
Outline level Рівень плану
Raised/lowered Піднято/опущено
Right indent Відступ праворуч
Scale Масштаб
Space after Інтервал після
Space before Інтервал перед
Suppress line numbers Без номерів рядків
Underline color Колір підкреслення
Letters Листи
E-mail Messages Повідомлення електронної пошти
Directory Каталог
@ -961,3 +1043,25 @@
Offset: Top Right Зміщення: вгору праворуч
Offset: Top Зміщення: вгору
Offset: Top Left Зміщення: вгору ліворуч
Manage Styles Керування стилями
Show or Hide Style Показати або сховати стиль
Manage Styles… Керування стилями…
Sort order: Порядок сортування:
As Recommended Як рекомендовано
Alphabetical За абеткою
Font: {font} Шрифт: {font}
{size} pt {size} пт
Color: {color} Колір: {color}
Space before: {pt} pt Інтервал перед: {pt} пт
Space after: {pt} pt Інтервал після: {pt} пт
Based on: {style} На основі: {style}
Paragraph style Стиль абзацу
Character style Стиль символів
Linked style (paragraph and character) Зв’язаний стиль (абзац і символи)
Table style Стиль таблиці
Built-in style Вбудований стиль
Show in the Styles gallery Показувати в колекції стилів
Hide from style lists Сховати зі списків стилів
Built-in styles can't be deleted. Вбудовані стилі не можна видалити.
Delete Table Style Видалити стиль таблиці
Preview Попередній перегляд

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -23,7 +23,10 @@
Add 添加
Add a comment 添加批注
Add a Table 添加表格
Add Object to Selection 将对象添加到所选内容
Add Space Before Paragraph 在段前添加间距
Add space between Asian and Latin text 在亚洲文字与西文之间添加间距
Add space between Asian text and numbers 在亚洲文字与数字之间添加间距
Add Text 添加文字
Add to Dictionary 添加到词典
Add to Style Gallery 添加到样式库
@ -42,7 +45,10 @@
All Caps 全部大写
All caps 全部大写
All Markup 所有标记
Allow hanging punctuation 允许标点溢出边界
Allow Latin text to wrap in the middle of a word 允许西文在单词中间换行
Alt Text 替代文字
Apply Asian line-breaking rules (kinsoku) 应用亚洲文字的断行规则(禁则)
Apply Character Style 应用字符样式
Apply Style 应用样式
Apply Styles 选择并应用样式
@ -50,6 +56,7 @@
Arrange\nAll 全部重排
Arrow 箭头
Artistic Effects 艺术效果
Asian Typography 亚洲版式
Author 作者
AutoCorrect Options 自动更正选项
AutoFit 自动调整
@ -63,7 +70,10 @@
AutoSave 自动保存
AutoSave documents you have saved in WordCraft 自动保存在 WordCraft 中保存过的文档
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自动保存失败:{error}。重新保存文档后将恢复自动保存。
Compress punctuation at the start of a line 压缩行首标点
Dark page (white text on black) 深色页面(黑底白字)
Indents and Spacing 缩进和间距
Line breaking 断行
More templates → 更多模板 →
No templates match your search. 没有与搜索匹配的模板。
Search for templates 搜索模板
@ -128,6 +138,15 @@
Clear 清除
Clear All Formatting 清除所有格式
Clipboard 剪贴板
Clipboard Pane 剪贴板窗格
Clipboard Items 剪贴板项目
Paste Clipboard Item 粘贴剪贴板项目
Paste All 全部粘贴
Delete Clipboard Item 删除剪贴板项目
Clear All 全部清除
Click an item to paste it. 单击项目即可粘贴。
Nothing collected yet. Items you copy or cut appear here. 尚无内容。复制或剪切的项目会显示在这里。
(picture or object) (图片或对象)
Close 关闭
Close Header and Footer 关闭页眉和页脚
Collapse Selection 折叠选定内容
@ -746,6 +765,7 @@
Underline Words Only 仅给字词加下划线
Undo 撤消
Undo {action} 撤消{action}
Ungroup 取消组合
Up 上
Update Field 更新域
Update Index 更新索引
@ -889,6 +909,68 @@
Opening print dialog… 正在打开打印对话框…
Print failed: {error} 打印失败:{error}
Couldn't open {path}: {error} 无法打开 {path}:{error}
Column Selection 矩形选择
Style Inspector 样式检查器
Inspect Styles at Selection 检查所选内容的样式
Clear Formatting Level 清除格式级别
Characters 字符
Direct formatting 直接格式
Reset to Normal 重置为正文
Clear Paragraph Formatting 清除段落格式
Clear Character Style 清除字符样式
Clear Character Formatting 清除字符格式
Paste Special 选择性粘贴
Paste Special… 选择性粘贴…
Paste as: 粘贴为:
The clipboard is empty. 剪贴板为空。
Formatted Text 带格式文本
Rich Text Format (RTF) RTF 格式
HTML Format HTML 格式
Unformatted Text 无格式文本
Result 结果
Keeps the fonts and formatting of the copied text. 保留所复制文本的字体和格式。
Reads the clipboard's rich text and keeps its formatting. 读取剪贴板中的 RTF 内容并保留其格式。
Reads the clipboard's web content and keeps its formatting. 读取剪贴板中的网页内容并保留其格式。
Drops all formatting and pastes plain text. 去除所有格式,粘贴纯文本。
Advanced Find 高级查找
Advanced Find… 高级查找…
Go To… 定位…
Reading Highlight 突出显示查找结果
New Table Style 新建表格样式
Modify Table Style 修改表格样式
New Table Style… 新建表格样式…
Modify Table Style… 修改表格样式…
Apply formatting to: 将格式应用于:
Whole Table 整个表格
Fill color: 填充颜色:
{count} items highlighted 已突出显示 {count} 项
{n} pt {n} 磅
Not {name} 非{name}
Level {n} {n} 级
Body Text 正文文本
{n} lines {n} 行
At least {n} 最小值 {n}
Exactly {n} 固定值 {n}
Centered 居中
Character spacing 字符间距
Don't add space between paragraphs of the same style 相同样式的段落间不添加间距
Don't hyphenate 不断字
Double strikethrough 双删除线
Drop cap 首字下沉
First line 首行
Font color 字体颜色
Highlight 突出显示
Justified 两端对齐
Left indent 左缩进
Line spacing 行距
Outline level 大纲级别
Raised/lowered 提升/降低
Right indent 右缩进
Scale 缩放
Space after 段后间距
Space before 段前间距
Suppress line numbers 取消行号
Underline color 下划线颜色
Letters 信函
E-mail Messages 电子邮件
Directory 目录
@ -960,3 +1042,25 @@
Offset: Top Right 偏移:右上
Offset: Top 偏移:上
Offset: Top Left 偏移:左上
Manage Styles 管理样式
Show or Hide Style 显示或隐藏样式
Manage Styles… 管理样式…
Sort order: 排序方式:
As Recommended 按推荐
Alphabetical 按字母顺序
Font: {font} 字体:{font}
{size} pt {size} 磅
Color: {color} 颜色:{color}
Space before: {pt} pt 段前间距:{pt} 磅
Space after: {pt} pt 段后间距:{pt} 磅
Based on: {style} 基于:{style}
Paragraph style 段落样式
Character style 字符样式
Linked style (paragraph and character) 链接样式(段落和字符)
Table style 表格样式
Built-in style 内置样式
Show in the Styles gallery 在样式库中显示
Hide from style lists 在样式列表中隐藏
Built-in styles can't be deleted. 内置样式不能删除。
Delete Table Style 删除表格样式
Preview 预览

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -13,7 +13,10 @@
Add 新增
Add a comment 新增註解
Add a Table 新增表格
Add Object to Selection 將物件加入選取範圍
Add Space Before Paragraph 在段落前新增間距
Add space between Asian and Latin text 在亞洲文字與西文之間加入間距
Add space between Asian text and numbers 在亞洲文字與數字之間加入間距
Add Text 新增文字
Add to Dictionary 新增至字典
Add to Style Gallery 新增至樣式庫
@ -32,7 +35,10 @@
All Caps 全部大寫
All caps 全部大寫
All Markup 所有標記
Allow hanging punctuation 允許標點溢出邊界
Allow Latin text to wrap in the middle of a word 允許西文在單字中間換行
Alt Text 替代文字
Apply Asian line-breaking rules (kinsoku) 套用亞洲文字的斷行規則(禁則)
Apply Character Style 套用字元樣式
Apply Style 套用樣式
Apply Styles 選擇並套用樣式
@ -40,6 +46,7 @@
Arrange\nAll 全部排列
Arrow 箭號
Artistic Effects 美術效果
Asian Typography 亞洲版式
Author 作者
AutoCorrect Options 自動校正選項
AutoFit 自動調整
@ -53,7 +60,10 @@
AutoSave 自動儲存
AutoSave documents you have saved in WordCraft 自動儲存在 WordCraft 中儲存過的文件
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動儲存失敗:{error}。重新儲存文件後將恢復自動儲存。
Compress punctuation at the start of a line 壓縮行首標點
Dark page (white text on black) 深色頁面(黑底白字)
Indents and Spacing 縮排和間距
Line breaking 斷行
More templates → 更多範本 →
No templates match your search. 沒有符合搜尋的範本。
Search for templates 搜尋範本
@ -118,6 +128,15 @@
Clear 清除
Clear All Formatting 清除所有格式設定
Clipboard 剪貼簿
Clipboard Pane 剪貼簿窗格
Clipboard Items 剪貼簿項目
Paste Clipboard Item 貼上剪貼簿項目
Paste All 全部貼上
Delete Clipboard Item 刪除剪貼簿項目
Clear All 全部清除
Click an item to paste it. 按一下項目即可貼上。
Nothing collected yet. Items you copy or cut appear here. 尚無內容。複製或剪下的項目會顯示在這裡。
(picture or object) (圖片或物件)
Close 關閉
Close Header and Footer 關閉頁首及頁尾
Collapse Selection 摺疊選取範圍
@ -736,6 +755,7 @@
Underline Words Only 只在文字下加底線
Undo 復原
Undo {action} 復原{action}
Ungroup 取消群組
Up 上
Update Field 更新功能變數
Update Index 更新索引
@ -879,6 +899,68 @@
Opening print dialog… 正在開啟列印對話方塊…
Print failed: {error} 列印失敗:{error}
Couldn't open {path}: {error} 無法開啟 {path}:{error}
Column Selection 矩形選取
Style Inspector 樣式檢查器
Inspect Styles at Selection 檢查選取範圍的樣式
Clear Formatting Level 清除格式層級
Characters 字元
Direct formatting 直接格式設定
Reset to Normal 重設為內文
Clear Paragraph Formatting 清除段落格式設定
Clear Character Style 清除字元樣式
Clear Character Formatting 清除字元格式設定
Paste Special 選擇性貼上
Paste Special… 選擇性貼上…
Paste as: 貼上為:
The clipboard is empty. 剪貼簿是空的。
Formatted Text 格式化文字
Rich Text Format (RTF) RTF 格式
HTML Format HTML 格式
Unformatted Text 純文字
Result 結果
Keeps the fonts and formatting of the copied text. 保留所複製文字的字型和格式。
Reads the clipboard's rich text and keeps its formatting. 讀取剪貼簿中的 RTF 內容並保留其格式。
Reads the clipboard's web content and keeps its formatting. 讀取剪貼簿中的網頁內容並保留其格式。
Drops all formatting and pastes plain text. 移除所有格式,貼上純文字。
Advanced Find 進階尋找
Advanced Find… 進階尋找…
Go To… 移至…
Reading Highlight 醒目提示尋找結果
New Table Style 新增表格樣式
Modify Table Style 修改表格樣式
New Table Style… 新增表格樣式…
Modify Table Style… 修改表格樣式…
Apply formatting to: 將格式套用至:
Whole Table 整個表格
Fill color: 填滿色彩:
{count} items highlighted 已醒目提示 {count} 個項目
{n} pt {n} 點
Not {name} 非{name}
Level {n} 第 {n} 階
Body Text 本文
{n} lines {n} 行
At least {n} 最小行高 {n}
Exactly {n} 固定行高 {n}
Centered 置中
Character spacing 字元間距
Don't add space between paragraphs of the same style 相同樣式的段落之間不新增間距
Don't hyphenate 不斷字
Double strikethrough 雙刪除線
Drop cap 首字放大
First line 第一行
Font color 字型色彩
Highlight 醒目提示
Justified 左右對齊
Left indent 左縮排
Line spacing 行距
Outline level 大綱階層
Raised/lowered 上移/下移
Right indent 右縮排
Scale 縮放
Space after 段落後間距
Space before 段落前間距
Suppress line numbers 隱藏行號
Underline color 底線色彩
Letters 信函
E-mail Messages 電子郵件
Directory 目錄
@ -950,3 +1032,25 @@
Offset: Top Right 位移:右上
Offset: Top 位移:上
Offset: Top Left 位移:左上
Manage Styles 管理樣式
Show or Hide Style 顯示或隱藏樣式
Manage Styles… 管理樣式…
Sort order: 排序方式:
As Recommended 依建議
Alphabetical 依字母順序
Font: {font} 字型:{font}
{size} pt {size} 點
Color: {color} 色彩:{color}
Space before: {pt} pt 段落前間距:{pt} 點
Space after: {pt} pt 段落後間距:{pt} 點
Based on: {style} 根據:{style}
Paragraph style 段落樣式
Character style 字元樣式
Linked style (paragraph and character) 連結樣式(段落和字元)
Table style 表格樣式
Built-in style 內建樣式
Show in the Styles gallery 在樣式庫中顯示
Hide from style lists 從樣式清單中隱藏
Built-in styles can't be deleted. 無法刪除內建樣式。
Delete Table Style 刪除表格樣式
Preview 預覽

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -868,6 +868,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.line(&[(6.0, 8.5), (11.0, 8.5)]);
pen.line(&[(8.5, 6.0), (8.5, 11.0)]);
}
"zoomIn" | "zoomOut" => {
pen.circle(8.5, 8.5, 5.5, c);
pen.line_c(&[(12.5, 12.5), (17.5, 17.5)], a);
pen.line(&[(6.0, 8.5), (11.0, 8.5)]);
if name == "zoomIn" {
pen.line(&[(8.5, 6.0), (8.5, 11.0)]);
}
}
"zoom100" => pen.text(10.0, 10.0, 7.5, "100", a, true),
"onePage" => pen.page(5.0, 2.0, 15.0, 18.0),
"multiplePages" => {

View File

@ -38,6 +38,7 @@ pub fn key_name(k: Key) -> Option<&'static str> {
Key::F3 => "F3",
Key::F5 => "F5",
Key::F7 => "F7",
Key::F8 => "F8",
Key::F9 => "F9",
Key::F12 => "F12",
Key::A => "A",
@ -132,6 +133,13 @@ fn equation_key(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
true
}
/// Whether releasing `key` with `modifiers` is the plain Paste shortcut (Mod+V or the Paste
/// key) — not Mod+Shift+V (Paste Formatting) or Mod+Alt+V (Paste Special) — so a clipboard
/// picture may be pasted on it (#45).
fn is_paste_release(key: Key, modifiers: egui::Modifiers) -> bool {
(modifiers.command || key == Key::Paste) && !modifiers.alt && !modifiers.shift
}
/// Events for the focused canvas: text, editing keys, clipboard, IME. While keytips are showing
/// every key belongs to them (letters pick badges, Escape cancels), so nothing here runs.
pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
@ -140,6 +148,24 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
}
let events = ctx.input(|i| i.events.clone());
for e in events {
if let egui::Event::Paste(t) = &e {
app.canvas.pasted = true;
// Copied picture files arrive as their paths: insert the pictures (#45).
if app.session.math.is_none() && !ctx.input(|i| i.modifiers.alt) && crate::paste_picture::paste_named_files(app, t) {
continue;
}
}
// Desktop: Mod+V with no text on the clipboard sends no paste event, only the key's
// release; paste the clipboard's picture then (#45). A paste event before the release
// means it was text. (The web reads pasted pictures itself.)
if let egui::Event::Key { key: key @ (Key::V | Key::Paste), pressed: false, modifiers, .. } = &e {
let paste_keys = is_paste_release(*key, *modifiers);
let pasted = std::mem::take(&mut app.canvas.pasted);
if !pasted && paste_keys && app.session.math.is_none() && app.services.clipboard_picture.is_some() {
let _ = app.run("edit.paste", json!({}));
}
continue;
}
// Editing an equation: text and editing keys go into it.
if app.session.math.is_some() {
match &e {
@ -185,7 +211,13 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
}
}
egui::Event::Paste(t) => {
let id = if ctx.input(|i| i.modifiers.shift && i.modifiers.alt) { "edit.pasteText" } else { "edit.paste" };
// Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special.
let m = ctx.input(|i| i.modifiers);
let id = match (m.alt, m.shift) {
(true, true) => "edit.pasteText",
(true, false) => "edit.pasteSpecial",
_ => "edit.paste",
};
let _ = app.run(id, json!({"text": t}));
}
egui::Event::Copy | egui::Event::Cut => {
@ -240,3 +272,18 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_plain_paste_releases_paste_a_clipboard_picture() {
let m = |command, shift, alt| egui::Modifiers { command, shift, alt, ..Default::default() };
assert!(is_paste_release(Key::V, m(true, false, false)), "Mod+V");
assert!(is_paste_release(Key::Paste, m(false, false, false)), "the Paste key");
assert!(!is_paste_release(Key::V, m(true, true, false)), "Mod+Shift+V is Paste Formatting");
assert!(!is_paste_release(Key::V, m(true, false, true)), "Mod+Alt+V is Paste Special");
assert!(!is_paste_release(Key::V, m(false, false, false)), "a plain V");
}
}

View File

@ -333,7 +333,7 @@ fn controls(tab: &str) -> Vec<Control> {
}
"Table Layout" => vec![
m("Select", "table.selectCell"),
c("View Gridlines", "view.gridlines"),
c("View Gridlines", "table.viewGridlines"),
c("Properties", "table.properties"),
m("Delete", "table.deleteCells"),
c("Insert Above", "table.insertRowAbove"),
@ -590,20 +590,36 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) {
let mut alt_released = false;
let mut f10 = false;
for e in &events {
if let egui::Event::Key { key, pressed, modifiers, .. } = e {
if is_alt(*key) {
if *pressed && modifiers.any() {
// Alt combined with another modifier is a shortcut, not a keytip chord.
match e {
egui::Event::Key { key, pressed, repeat, modifiers, .. } => {
if is_alt(*key) {
if *pressed {
// A new Alt press starts a fresh chord (auto-repeat keeps the current one).
if !*repeat {
app.ui.alt_chord_used = false;
}
if modifiers.any() {
// Alt combined with another modifier is a shortcut, not a keytip chord.
other_key = true;
}
} else if !modifiers.any() {
// Alt+click and Alt+drag (column selection), or Alt+key held across frames,
// end with a bare Alt release too; only an untouched Alt tap counts.
alt_released = !std::mem::take(&mut app.ui.alt_chord_used);
}
} else if *pressed {
other_key = true;
} else if !*pressed && !modifiers.any() {
alt_released = true;
}
} else if *pressed {
other_key = true;
if *key == egui::Key::F10 && !modifiers.any() {
f10 = true;
app.ui.alt_chord_used = true;
if *key == egui::Key::F10 && !modifiers.any() {
f10 = true;
}
}
}
egui::Event::PointerButton { pressed: true, .. } => {
other_key = true;
app.ui.alt_chord_used = true;
}
_ => {}
}
}
// Route letter/navigation keys to the keytips while they are showing.
@ -739,6 +755,40 @@ mod tests {
}
}
/// Alt+drag selects a column (#237); releasing Alt afterwards must not open keytips. A bare
/// Alt tap still does (off macOS, where Option types accents).
#[test]
fn alt_click_or_drag_does_not_arm_keytips_on_release() {
let ctx = egui::Context::default();
let mut a = app();
a.ui.backstage = false;
a.dialog = None;
let alt = egui::Modifiers { alt: true, ..Default::default() };
let key = |pressed, modifiers| egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed, repeat: false, modifiers };
let pos = egui::pos2(400.0, 300.0);
let frame = |a: &mut WordApp, events: Vec<egui::Event>| {
let input = egui::RawInput { events, ..Default::default() };
ctx.run_ui(input, |ui| logic(a, ui.ctx())).drop_without_applying_deltas();
};
// Alt down, drag with the mouse over several frames, Alt up.
frame(&mut a, vec![key(true, alt)]);
frame(&mut a, vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: alt }]);
frame(&mut a, vec![egui::Event::PointerMoved(pos + egui::vec2(60.0, 40.0))]);
frame(&mut a, vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: alt }]);
frame(&mut a, vec![key(false, egui::Modifiers::NONE)]);
assert_eq!(a.ui.keytips, Phase::Off, "Alt+drag then Alt up leaves keytips off");
// Alt held while a key is pressed in a later frame: also a chord.
frame(&mut a, vec![key(true, alt)]);
frame(&mut a, vec![egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: alt }]);
frame(&mut a, vec![key(false, egui::Modifiers::NONE)]);
assert_eq!(a.ui.keytips, Phase::Off, "Alt+key then Alt up leaves keytips off");
// A bare tap (press and release in separate frames) arms them.
frame(&mut a, vec![key(true, alt)]);
frame(&mut a, vec![key(false, egui::Modifiers::NONE)]);
let tap = if cfg!(target_os = "macos") { Phase::Off } else { Phase::Tabs };
assert_eq!(a.ui.keytips, tap);
}
#[test]
fn keytip_state_machine() {
let mut a = app();

View File

@ -29,9 +29,11 @@ pub mod keytips;
pub mod mini_toolbar;
pub mod objects;
pub mod panes;
pub mod paste_picture;
pub mod previews;
pub mod read_aloud;
pub mod ribbon;
pub mod scroll;
pub mod theme;
pub mod widgets;
pub mod window_geometry;
@ -69,6 +71,9 @@ pub struct Services {
/// Web: hand PDF bytes to the browser's print flow directly (no download, no intermediate
/// file) — opens the system print dialog on that PDF. An error means no dialog was opened.
pub print: Option<Box<dyn Fn(&[u8]) -> Result<(), String>>>,
/// Desktop: the picture on the system clipboard, if any — egui's paste only carries text
/// (#45). Read when Paste finds no text; see [`paste_picture`].
pub clipboard_picture: Option<Box<dyn Fn() -> Option<paste_picture::ClipboardPicture>>>,
}
/// Files delivered asynchronously.
@ -104,6 +109,10 @@ pub struct UiState {
/// Keytips (Alt) state; not persisted, it resets each session.
#[serde(skip)]
pub keytips: crate::keytips::Phase,
/// Since the last Alt press, another key or a mouse button was pressed: the Alt release
/// that follows ends a chord (Alt+click, Alt+drag column selection), not a keytip tap.
#[serde(skip)]
pub alt_chord_used: bool,
/// View › Switch Modes: show pages dark (white text on black), kept between runs.
pub dark_page: bool,
}
@ -127,6 +136,7 @@ impl Default for UiState {
read_aloud_rate: 1.0,
read_aloud_skip_citations: true,
keytips: crate::keytips::Phase::Off,
alt_chord_used: false,
dark_page: false,
}
}
@ -187,6 +197,10 @@ pub struct WordApp {
autosave_path: Option<std::path::PathBuf>,
/// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]).
file_dialog: Option<file_dialogs::PendingDialog>,
/// File › Info: the property field being typed in and the document revision after its last
/// keystroke, so one visit's keystrokes make a single undo step (and anything else in
/// between, such as an Undo, starts a new one).
pub(crate) info_editing: Option<(&'static str, u64)>,
}
/// The answer to "Do you want to save changes?" (`ui.saveChanges`).
@ -246,6 +260,7 @@ impl WordApp {
change_picture_target: None,
recipient_list_pending: false,
file_dialog: None,
info_editing: None,
}
}
@ -302,6 +317,14 @@ impl WordApp {
self.dialog = Some(dialogs::Dialog::SaveChanges { name, then: id.to_string(), params, document: self.session.document_id() });
return Ok(json!({"pending": "saveChanges"}));
}
// Paste without system clipboard text: a picture there is newer than our own copy (copying
// here replaces it with text), so it wins (#45).
if id == "edit.paste"
&& params.get("text").is_none()
&& let Some(r) = paste_picture::paste(self)
{
return r;
}
let r = self.execute(id, params);
// Match Fields and Check for Errors show what they found.
if let Ok(v) = &r
@ -336,6 +359,10 @@ impl WordApp {
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(|| self.default_save_name());
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
// A save is a save, download or not: advance the revision and modified stamp (#262).
if matches!(id, "file.save" | "file.saveAs") {
self.session.stamp_save();
}
let bytes = wordcraft_engine::io::save_bytes(&name, &self.session.doc)?;
if let Some(d) = &self.services.download
&& let Err(e) = d(&name, &bytes)
@ -475,6 +502,7 @@ impl WordApp {
self.ui.backstage = true;
self.ui.backstage_page = "options".into();
}
"pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)),
other => self.dialog = dialogs::Dialog::open(other, self),
}
}
@ -1076,6 +1104,91 @@ mod tests {
assert!(a.canvas.scale < zoomed, "Ctrl+wheel down zooms out");
}
/// Issue #67: View › Zoom In from a fit mode zoomed *out*: Page Width showed 163% but Zoom In
/// stepped from the stale manual 100% to 110%.
#[test]
fn view_tab_zoom_steps_from_the_shown_zoom_in_fit_modes() {
let ctx = egui::Context::default();
let mut a = app();
a.run("ui.tab", json!({"tab": "View"})).unwrap();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0));
let frame = |a: &mut WordApp| {
for _ in 0..4 {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| {
a.logic(ui.ctx());
a.ui(ui);
})
.drop_without_applying_deltas();
}
a.canvas.scale / canvas::PX_PER_PT
};
let base = frame(&mut a);
assert!((base - 1.0).abs() < 1e-3);
for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] {
a.run(fit, json!({})).unwrap();
let fitted = frame(&mut a);
assert!((fitted - base).abs() > 0.05, "{fit} changes the zoom");
assert!((a.session.view.zoom - fitted).abs() < 1e-3, "{fit}: session zoom follows the shown zoom");
a.run("view.zoomIn", json!({})).unwrap();
let zin = frame(&mut a);
assert!(zin > fitted, "{fit}: Zoom In zooms in: {fitted} -> {zin}");
a.run(fit, json!({})).unwrap();
frame(&mut a);
a.run("view.zoomOut", json!({})).unwrap();
let zout = frame(&mut a);
assert!(zout < fitted, "{fit}: Zoom Out zooms out: {fitted} -> {zout}");
// The Zoom dialog opens at the shown zoom.
a.run(fit, json!({})).unwrap();
frame(&mut a);
a.run("ui.dialog", json!({"name": "zoom"})).unwrap();
assert!(matches!(a.dialog, Some(dialogs::Dialog::Zoom { percent }) if (percent - (fitted * 100.0).round()).abs() < 1.0));
a.dialog = None;
}
a.run("view.zoom100", json!({})).unwrap();
assert!((frame(&mut a) - 1.0).abs() < 1e-3);
}
/// Issue #122: touchpad deltas scroll the page 1:1 at once; a wheel notch eases in to about
/// three lines.
#[test]
fn touchpad_scrolls_one_to_one_and_wheel_notches_ease_in() {
let ctx = egui::Context::default();
let mut a = app();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0));
let mut t = 0.0;
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
t += 1.0 / 60.0;
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| {
a.logic(ui.ctx());
a.ui(ui);
})
.drop_without_applying_deltas();
};
for _ in 0..3 {
frame(&mut a, Vec::new());
}
let over_page = a.canvas.canvas_rect.unwrap().center();
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
let wheel = |unit, y: f32, phase| egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase, modifiers: egui::Modifiers::NONE };
let start = a.canvas.scroll_offset.y;
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, -40.0, egui::TouchPhase::Start)]);
assert_eq!(a.canvas.scroll_offset.y, start + 40.0, "the first touchpad delta lands in the same frame");
// Fingers lift; the coast after it is left to the unit tests (crate::scroll).
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, 0.0, egui::TouchPhase::End)]);
a.canvas.wheel = Default::default();
let at = a.canvas.scroll_offset.y;
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Line, -1.0, egui::TouchPhase::Move)]);
let first = a.canvas.scroll_offset.y - at;
for _ in 0..30 {
frame(&mut a, Vec::new());
}
let notch = crate::scroll::notch_px(a.canvas.scale / crate::canvas::PX_PER_PT);
assert!(first > 0.0 && first < notch, "a notch eases in: {first}");
assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at);
}
/// Issue #123: zoomed out, pages sit side by side, and clicks map to the page under the pointer.
#[test]
fn zoomed_out_pages_sit_side_by_side_and_clicks_land_on_them() {

View File

@ -86,6 +86,12 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
return false;
}
let Some((object, grab)) = grab_at(app, layout, pages, scale, at) else { return false };
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if pressed && adding && grab == Grab::Move && selected(app).is_some() {
let _ = app.run("select.addObject", json!({"pos": object.pos()}));
return true;
}
if pressed {
let pos = object.pos();
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
@ -153,6 +159,12 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
frame::paint(painter, t, to, false, false);
return;
}
// The others selected along with it (Shift+click): their outlines.
for p in &app.session.also_selected {
if let Some(f) = layout.object(p, app.session.page_hint).and_then(|o| screen(pages, scale, o.page, o.rect)) {
frame::paint(painter, t, f, false, false);
}
}
if let Some((o, editing)) = active(app, layout)
&& let Some(f) = screen(pages, scale, o.page, o.rect)
{

View File

@ -1,4 +1,4 @@
//! Side panes: Navigation (headings, pages, search results), Styles, Comments.
//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, Comments.
use egui::{Stroke, Ui, vec2};
use serde_json::{Value, json};
@ -16,6 +16,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| nav(app, ui));
}
if app.session.view.clipboard_pane {
egui::Panel::left("clipboard_pane")
.default_size(250.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| clipboard(app, ui));
}
if app.session.view.styles_pane {
egui::Panel::right("styles_pane")
.default_size(250.0)
@ -23,6 +30,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| styles(app, ui));
}
if app.session.view.style_inspector {
egui::Panel::right("style_inspector")
.default_size(240.0)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| inspector(app, ui));
}
if app.session.view.comments_pane {
egui::Panel::right("comments_pane")
.default_size(290.0)
@ -138,6 +152,51 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
});
}
/// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret.
fn clipboard(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Clipboard") {
let _ = app.run("edit.clipboardPane", json!({"value": false}));
return;
}
let items: Vec<String> = app.session.clip_history.items().iter().map(|i| i.preview()).collect();
ui.horizontal(|ui| {
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() {
let _ = app.run("edit.pasteAllClipboard", json!({}));
app.canvas.want_focus = true;
}
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() {
let _ = app.run("edit.clearClipboard", json!({}));
}
});
ui.add_space(4.0);
let hint = if items.is_empty() { "Nothing collected yet. Items you copy or cut appear here." } else { "Click an item to paste it." };
ui.label(egui::RichText::new(tl!(hint)).small().weak());
ui.separator();
let t = Tokens::get(ui.ctx());
egui::ScrollArea::vertical().show(ui, |ui| {
for (i, preview) in items.iter().enumerate() {
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| {
ui.set_width(ui.available_width());
let text = if preview.is_empty() { tl!("(picture or object)").to_string() } else { preview.clone() };
let mut job =
egui::text::LayoutJob::single_section(text, egui::TextFormat { font_id: regular(12.5), color: t.text, ..Default::default() });
job.wrap.max_width = ui.available_width();
job.wrap.max_rows = 3;
if ui.add(egui::Button::new(job).frame(false)).on_hover_text(tl!("Paste")).clicked() {
let _ = app.run("edit.pasteClipboardItem", json!({"index": i}));
app.canvas.want_focus = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
if ui.small_button(tl!("Delete")).clicked() {
let _ = app.run("edit.deleteClipboardItem", json!({"index": i}));
}
});
});
ui.add_space(6.0);
}
});
}
fn styles(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Styles") {
let _ = app.run("view.stylesPane", json!({"value": false}));
@ -160,6 +219,12 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
let _ = app.run("format.clear", json!({}));
}
});
if ui.button(tl!("Manage Styles…")).clicked() {
let _ = app.run("styles.manage", json!({}));
}
if ui.selectable_label(app.session.view.style_inspector, tl!("Style Inspector")).clicked() {
let _ = app.run("styles.inspector", json!({}));
}
ui.separator();
let mut list: Vec<(String, String, bool)> = app
.session
@ -264,3 +329,222 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
});
let _ = vec2(0.0, 0.0);
}
/// Style Inspector: the paragraph and character levels at the caret, each with its style and the
/// direct formatting on top, and a button to clear each level.
fn inspector(app: &mut WordApp, ui: &mut Ui) {
if header(ui, "Style Inspector") {
let _ = app.run("styles.inspector", json!({"value": false}));
return;
}
// Read-only, so call it directly: running the command every frame would pay for the command
// machinery and consume per-command state (a pending `join_next_undo`) just to draw the pane.
let r = wordcraft_engine::cmd::inspector::inspect(&app.session);
let t = Tokens::get(ui.ctx());
let none = tl!("None").to_string();
let para = &r["paragraph"];
let chr = &r["character"];
let pstyle = para["styleName"].as_str().unwrap_or("Normal").to_string();
let cstyle = chr["styleName"].as_str().map(str::to_string);
let pdirect = direct_list(&para["direct"]);
let cdirect = direct_list(&chr["direct"]);
let mut clear: Option<&str> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
section(ui, &t, "Paragraph", |ui| {
if level_row(ui, &t, "Style", &pstyle, false, "Reset to Normal", pstyle == "Normal" && para["style"] == "Normal") {
clear = Some("paragraphStyle");
}
let (txt, empty) = if pdirect.is_empty() { (none.clone(), true) } else { (pdirect.join(", "), false) };
if level_row(ui, &t, "Direct formatting", &txt, empty, "Clear Paragraph Formatting", empty) {
clear = Some("paragraphFormatting");
}
});
ui.add_space(8.0);
section(ui, &t, "Characters", |ui| {
let (txt, empty) = match &cstyle {
Some(n) => (n.clone(), false),
None => (none.clone(), true),
};
if level_row(ui, &t, "Style", &txt, empty, "Clear Character Style", empty) {
clear = Some("characterStyle");
}
let (txt, empty) = if cdirect.is_empty() { (none.clone(), true) } else { (cdirect.join(", "), false) };
if level_row(ui, &t, "Direct formatting", &txt, empty, "Clear Character Formatting", empty) {
clear = Some("characterFormatting");
}
});
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button(tl!("Clear All")).on_hover_text(tl!("Clear All Formatting")).clicked() {
let _ = app.run("format.clear", json!({}));
app.canvas.want_focus = true;
}
if ui.button(tl!("Styles Pane")).clicked() {
let _ = app.run("view.stylesPane", json!({"value": true}));
}
});
});
if let Some(level) = clear {
let _ = app.run("styles.inspectorClear", json!({"level": level}));
app.canvas.want_focus = true;
}
}
/// A titled, bordered group in the inspector.
fn section(ui: &mut Ui, t: &Tokens, title: &str, body: impl FnOnce(&mut Ui)) {
ui.label(egui::RichText::new(tl!(title)).font(semibold(12.5)).color(t.text));
ui.add_space(2.0);
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(4).inner_margin(8).show(ui, |ui| {
ui.set_width(ui.available_width());
body(ui);
});
}
/// One level: a caption, its value, and a clear button (disabled when there's nothing to clear).
/// Returns true when the button was clicked.
fn level_row(ui: &mut Ui, t: &Tokens, caption: &str, value: &str, dim: bool, tip: &str, disabled: bool) -> bool {
ui.label(egui::RichText::new(tl!(caption)).font(regular(11.0)).color(t.text_dim));
let mut clicked = false;
ui.horizontal(|ui| {
let w = (ui.available_width() - 26.0).max(40.0);
ui.allocate_ui(vec2(w, 18.0), |ui| {
ui.set_width(w);
let text = egui::RichText::new(value).font(regular(12.5)).color(if dim { t.text_disabled } else { t.text });
ui.add(egui::Label::new(text).wrap());
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), egui::Sense::click());
let enabled = !disabled;
if enabled && resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let c = if enabled { t.icon } else { t.text_disabled };
crate::icons::paint(
ui.painter(),
egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)),
"clear",
c,
if enabled { t.accent } else { c },
);
let resp = resp.on_hover_text(tl!(tip));
clicked = enabled && resp.clicked();
});
});
ui.add_space(4.0);
clicked
}
/// Readable descriptions of the inspector's `direct` entries (`[{"prop", "value"}]`).
fn direct_list(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().take(64).map(|d| describe(d["prop"].as_str().unwrap_or(""), &d["value"])).collect()).unwrap_or_default()
}
/// "Bold", "Not Italic", "Font: Arial", "Size: 14 pt"…
fn describe(prop: &str, v: &Value) -> String {
let label = match prop {
"font" => "Font",
"size" => "Size",
"bold" => "Bold",
"italic" => "Italic",
"underline" => "Underline",
"underlineColor" => "Underline color",
"strike" => "Strikethrough",
"doubleStrike" => "Double strikethrough",
"color" => "Font color",
"highlight" => "Highlight",
"shading" => "Shading",
"vertAlign" => "Position",
"caps" => "All caps",
"smallCaps" => "Small caps",
"hidden" => "Hidden",
"spacing" => "Character spacing",
"scale" => "Scale",
"position" => "Raised/lowered",
"outline" => "Outline",
"shadow" => "Shadow",
"emboss" => "Emboss",
"engrave" => "Engrave",
"align" => "Alignment",
"indentLeft" => "Left indent",
"indentRight" => "Right indent",
"indentFirst" => "First line",
"spaceBefore" => "Space before",
"spaceAfter" => "Space after",
"lineSpacing" => "Line spacing",
"contextualSpacing" => "Don't add space between paragraphs of the same style",
"keepNext" => "Keep with next",
"keepLines" => "Keep lines together",
"pageBreakBefore" => "Page break before",
"widowControl" => "Widow/Orphan control",
"outlineLevel" => "Outline level",
"tabs" => "Tabs",
"borders" => "Borders",
"suppressHyphens" => "Don't hyphenate",
"suppressLineNumbers" => "Suppress line numbers",
"bidi" => "Right-to-left",
"dropCap" => "Drop cap",
other => other,
};
let label = tl!(label);
let pt = |x: f64| crate::i18n::fmt(tl!("{n} pt"), &[("n", &trim_num(x))]);
let hex = |c: &Value| -> Option<String> {
let a = c.as_array()?;
let ch = |i: usize| a.get(i).and_then(Value::as_u64).unwrap_or(0).min(255);
Some(format!("#{:02X}{:02X}{:02X}", ch(0), ch(1), ch(2)))
};
let value = match (prop, v) {
(_, Value::Bool(true)) => return label.to_string(),
(_, Value::Bool(false)) => return crate::i18n::fmt(tl!("Not {name}"), &[("name", label)]),
("scale", Value::Number(n)) => format!("{}%", trim_num(n.as_f64().unwrap_or(100.0))),
("outlineLevel", Value::Number(n)) => match n.as_u64().unwrap_or(9) {
l @ 0..=8 => crate::i18n::fmt(tl!("Level {n}"), &[("n", &(l + 1).to_string())]),
_ => tl!("Body Text").to_string(),
},
("dropCap", Value::Number(n)) => crate::i18n::fmt(tl!("{n} lines"), &[("n", &n.to_string())]),
(_, Value::Number(n)) => pt(n.as_f64().unwrap_or(0.0)),
("align", Value::String(s)) => tl!(match s.as_str() {
"center" => "Centered",
"right" => "Right",
"justify" => "Justified",
"distribute" => "Distributed",
_ => "Left",
})
.to_string(),
("color", Value::String(_)) => tl!("Automatic").to_string(),
("color", Value::Object(o)) => o.get("Rgb").and_then(hex).unwrap_or_default(),
(_, Value::Array(_)) if prop != "tabs" => hex(v).unwrap_or_default(),
("lineSpacing", Value::Object(o)) => {
let x = o.get("value").and_then(Value::as_f64).unwrap_or(1.0);
match o.get("rule").and_then(Value::as_str) {
Some("atLeast") => crate::i18n::fmt(tl!("At least {n}"), &[("n", &pt(x))]),
Some("exactly") => crate::i18n::fmt(tl!("Exactly {n}"), &[("n", &pt(x))]),
_ => crate::i18n::fmt(tl!("{n} lines"), &[("n", &trim_num(x))]),
}
}
(_, Value::String(s)) => tl!(&camel_words(s)).to_string(),
_ => return label.to_string(),
};
format!("{label}: {value}")
}
/// `1.50` → `1.5`, `12.0` → `12`.
fn trim_num(x: f64) -> String {
let s = format!("{:.2}", if x.is_finite() { x } else { 0.0 });
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// `dotDash` → `Dot dash` (enum values from the document model).
fn camel_words(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for (i, c) in s.chars().take(64).enumerate() {
if i == 0 {
out.extend(c.to_uppercase());
} else if c.is_uppercase() {
out.push(' ');
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}

View File

@ -0,0 +1,163 @@
//! Pasting a picture from the system clipboard (#45).
//!
//! egui's paste event only carries text, so a clipboard holding just a picture (a screenshot, a
//! browser's Copy Image) or a copied picture file used to paste nothing. The host reads the
//! clipboard through [`crate::Services::clipboard_picture`]; this module checks what it got — the
//! clipboard comes from other programs, so sizes are hostile — and inserts it with
//! `insert.picture`, one undoable step like any inserted picture.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::WordApp;
/// What the host found on the system clipboard.
pub enum ClipboardPicture {
/// Straight (not premultiplied) RGBA8 pixels, row by row.
Pixels { width: usize, height: usize, rgba: Vec<u8> },
/// Copied files (a file manager's Copy); the pictures among them are inserted.
Files(Vec<PathBuf>),
}
/// Longest side of a pasted picture, in pixels.
pub const MAX_SIDE: usize = 16_000;
/// Most pixels in a pasted picture (200 MB of RGBA).
pub const MAX_PIXELS: usize = 50_000_000;
/// Largest picture file pasted (as `insert.picture` allows).
pub const MAX_FILE_BYTES: u64 = 200 << 20;
/// Most picture files one paste inserts.
const MAX_FILES: usize = 20;
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
/// PNG bytes for clipboard pixels, or why they can't be pasted.
pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u8>, String> {
if width == 0 || height == 0 {
return Err("the clipboard picture is empty".into());
}
let pixels = width.checked_mul(height).filter(|&n| width <= MAX_SIDE && height <= MAX_SIDE && n <= MAX_PIXELS);
let Some(pixels) = pixels else {
return Err(format!("the clipboard picture is too large to paste ({width} × {height} pixels)"));
};
if Some(rgba.len()) != pixels.checked_mul(4) {
return Err("the clipboard picture is damaged (its size doesn't match its pixels)".into());
}
let (Ok(w), Ok(h)) = (u32::try_from(width), u32::try_from(height)) else {
return Err("the clipboard picture is too large to paste".into());
};
let img = image::RgbaImage::from_raw(w, h, rgba).ok_or("the clipboard picture is damaged")?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| format!("couldn't read the clipboard picture: {e}"))?;
Ok(png)
}
fn is_picture_file(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// Pasted text that is only paths or `file://` addresses of picture files — what file managers
/// put on the clipboard as text when files are copied.
pub fn names_picture_files(text: &str) -> bool {
if text.len() > 64 << 10 {
return false;
}
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).peekable();
lines.peek().is_some()
&& lines.take(MAX_FILES + 1).enumerate().all(|(i, l)| {
let path = l.strip_prefix("file://").unwrap_or(l);
i < MAX_FILES && (l.starts_with("file://") || Path::new(path).is_absolute()) && is_picture_file(Path::new(path))
})
}
/// Paste the picture on the system clipboard. `None` when there is none (or no clipboard
/// service): the caller pastes as usual.
pub(crate) fn paste(app: &mut WordApp) -> Option<Result<Value, String>> {
let picture = app.services.clipboard_picture.as_ref()?()?;
let r = match picture {
ClipboardPicture::Pixels { width, height, rgba } => match png_from_rgba(width, height, rgba) {
Ok(png) => app.execute("insert.picture", json!({"data": wordcraft_engine::cmd::insert::base64_encode(&png)})),
Err(e) => {
log::warn!("clipboard picture: {e}");
app.status(e.clone());
Err(e)
}
},
ClipboardPicture::Files(files) => {
let files: Vec<PathBuf> = files.into_iter().filter(|p| is_picture_file(p)).take(MAX_FILES).collect();
if files.is_empty() {
return None;
}
let mut r = Err(String::new());
for path in files {
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
r = if size > MAX_FILE_BYTES {
let e = format!("{} is larger than 200 MB", path.display());
app.status(e.clone());
Err(e)
} else {
app.execute("insert.picture", json!({"path": path.to_string_lossy()}))
};
if r.is_err() {
break;
}
}
r
}
};
Some(r)
}
/// Paste text naming copied picture files: insert the pictures when the clipboard holds those
/// files. False leaves the text to the usual paste.
pub(crate) fn paste_named_files(app: &mut WordApp, text: &str) -> bool {
names_picture_files(text) && paste(app).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use wordcraft_engine::Session;
fn app_with(picture: impl Fn() -> Option<ClipboardPicture> + 'static) -> WordApp {
let services = Services { clipboard_picture: Some(Box::new(picture)), ..Default::default() };
WordApp::new(Session::new(wordcraft_doc::Document::new()), services)
}
fn pictures(a: &mut WordApp) -> usize {
a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0)
}
/// Paste with only a picture on the clipboard inserts it at its natural size, one undo step.
#[test]
fn paste_inserts_clipboard_pixels() {
let mut a = app_with(|| Some(ClipboardPicture::Pixels { width: 40, height: 20, rgba: vec![200; 40 * 20 * 4] }));
let r = a.run("edit.paste", json!({})).unwrap();
// 96 ppi: 40 × 20 px is 30 × 15 pt.
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(30.0), Some(15.0)));
assert_eq!(pictures(&mut a), 1);
let media = a.session.doc.media.values().next().unwrap();
assert_eq!(wordcraft_render::image_size(media), Some((40, 20)));
a.run("edit.undo", json!({})).unwrap();
assert_eq!(pictures(&mut a), 0);
// Pasted text that names picture files only becomes pictures when files were copied.
assert!(names_picture_files("file:///home/me/shot%201.png\n/tmp/b.JPG\n"));
assert!(!names_picture_files("see /tmp/b.png") && !names_picture_files("notes.txt") && !names_picture_files(""));
}
/// Hostile clipboard sizes are refused without inserting anything (or allocating for them).
#[test]
fn hostile_clipboard_pictures_are_refused() {
assert!(png_from_rgba(100_000, 1, vec![0; 400_000]).is_err());
assert!(png_from_rgba(usize::MAX, 2, Vec::new()).is_err());
assert!(png_from_rgba(10_000, 10_000, Vec::new()).is_err());
assert!(png_from_rgba(4, 4, vec![0; 63]).is_err());
assert!(png_from_rgba(0, 4, Vec::new()).is_err());
let mut a = app_with(|| Some(ClipboardPicture::Pixels { width: 1 << 40, height: 1 << 40, rgba: Vec::new() }));
assert!(a.run("edit.paste", json!({})).is_err());
assert_eq!(pictures(&mut a), 0);
// Copied files that aren't pictures leave Paste to the usual clipboard.
let mut a = app_with(|| Some(ClipboardPicture::Files(vec![PathBuf::from("/tmp/notes.txt")])));
assert_eq!(a.run("edit.paste", json!({})).unwrap_err(), "the clipboard is empty");
}
}

View File

@ -218,6 +218,55 @@ fn snippet_blocks(
Some(egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px))
}
/// Manage Styles' preview of one style (`ty`: the `styles.manage` type), `size` points wide and
/// tall: a line of sample text, or a small table for a table style.
pub fn style_preview(app: &mut WordApp, ui: &mut Ui, id: &str, ty: &str, size: egui::Vec2) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
ui.painter().rect(r, 3.0, egui::Color32::WHITE, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let rev = app.session.rev();
let skey = app.previews.styles_key(&app.session.doc, rev);
let ppp = ui.ctx().pixels_per_point();
let (w, h) = (size.x - 12.0, size.y - 8.0);
let key = format!("manage:{id}:{ty}:{skey}:{ppp}:{w}x{h}");
let doc = &app.session.doc;
let tex = app.previews.get_or(ui.ctx(), &key, || {
let block = match ty {
"table" => {
let mut tb = Table::new(3, 4, w - 4.0);
tb.props.style = Some(id.to_string());
for (ri, row) in tb.rows.iter_mut().enumerate() {
for (ci, cell) in row.cells.iter_mut().enumerate() {
let txt = match (ri, ci) {
(0, c) => ["", "A", "B", "C"].get(c).copied().unwrap_or(""),
(_, 0) => "Row",
_ => "1",
};
cell.blocks = vec![para_block(Paragraph::with_text(txt, CharProps::default()))];
}
}
Block::Table(tb)
}
"character" => Block::Para(Paragraph::with_text("AaBbCcYyZz", CharProps { style: Some(id.to_string()), ..CharProps::default() })),
_ => {
let mut p = Paragraph::with_text("AaBbCcYyZz", CharProps::default()).styled(id);
p.props.space_before = Some(0.0);
p.props.indent_left = Some(0.0);
p.props.indent_first = Some(0.0);
p.props.numbering = None;
Block::Para(p)
}
};
snippet_blocks(doc, vec![block], w, h, ppp, 4.0, None)
});
if let Some(hd) = tex {
let sz = hd.size_vec2() / ppp;
let ir = Rect::from_min_size(pos2(r.min.x + 2.0, r.min.y + 2.0), sz.min(r.size() - vec2(4.0, 4.0)));
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0)));
ui.painter().with_clip_rect(r).image(hd.id(), ir, uv, egui::Color32::WHITE);
}
}
/// The Home tab's Styles gallery.
pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
let t = Tokens::get(ui.ctx());
@ -329,7 +378,9 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let key = format!("tstyle:{style}:{ppp}");
// Keyed by the style sheet too, so a modified table style redraws.
let skey = app.previews.styles_key(&app.session.doc, app.session.rev());
let key = format!("tstyle:{style}:{skey}:{ppp}");
let doc = &app.session.doc;
let tex = app.previews.get_or(ui.ctx(), &key, || {
let mut tb = Table::new(5, 4, 100.0);

View File

@ -250,11 +250,12 @@ fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str
fn home(app: &mut WordApp, ui: &mut Ui) {
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
group(ui, "Clipboard", None, app, |ui, app| {
group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| {
menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, |ui, app| {
mi(ui, app, "Paste", "edit.paste", json!({}));
mi(ui, app, "Keep Text Only", "edit.pasteText", json!({}));
mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({}));
mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({}));
});
stack(ui, |ui| {
small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false);
@ -466,7 +467,11 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
});
group(ui, "Editing", None, app, |ui, app| {
stack(ui, |ui| {
small(ui, app, "find", Some("Find"), "Find", "ui.dialog", json!({"name": "find"}), false).clicked();
menu_button(ui, app, "find", Some("Find"), "Find", false, |ui, app| {
mi(ui, app, "Find", "ui.dialog", json!({"name": "find"}));
mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({}));
mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"}));
});
small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false);
menu_button(ui, app, "select", Some("Select"), "Select", false, |ui, app| {
mi(ui, app, "Select All", "select.all", json!({}));
@ -795,12 +800,20 @@ fn layout(app: &mut WordApp, ui: &mut Ui) {
});
stack(ui, |ui| {
small(ui, app, "align", None, "Align", "arrange.align", json!({}), false);
small(ui, app, "group", None, "Group", "arrange.group", json!({}), false);
group_menu(ui, app, None);
small(ui, app, "rotate", None, "Rotate", "arrange.rotate", json!({}), false);
});
});
}
/// Arrange › Group: Group (Shift+click objects to select several) and Ungroup.
fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) {
menu_button(ui, app, "group", label, "Group", false, |ui, app| {
mi(ui, app, "Group", "arrange.group", json!({}));
mi(ui, app, "Ungroup", "arrange.ungroup", json!({}));
});
}
fn references(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Table of Contents", None, app, |ui, app| {
menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, |ui, app| {
@ -1053,6 +1066,11 @@ fn view(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Zoom", None, app, |ui, app| {
big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false);
big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false);
// Step the zoom up and down by 10% (issue #67), from whatever the page shows now.
stack(ui, |ui| {
small(ui, app, "zoomIn", Some("Zoom In"), "Zoom In", "view.zoomIn", json!({}), false);
small(ui, app, "zoomOut", Some("Zoom Out"), "Zoom Out", "view.zoomOut", json!({}), false);
});
stack(ui, |ui| {
small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage");
small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page);
@ -1222,6 +1240,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
stack(ui, |ui| {
small(ui, app, "rotate", Some("Rotate"), "Rotate", "arrange.rotate", json!({}), false);
small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false);
group_menu(ui, app, Some("Group"));
});
});
group(ui, "Size", None, app, |ui, app| {
@ -1319,18 +1338,20 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
});
});
group(ui, "Table Styles", None, app, |ui, app| {
let styles: Vec<(String, String)> = app
// The document's own styles first, so a new one shows without scrolling.
let mut styles: Vec<(bool, String, String)> = app
.session
.doc
.styles
.styles
.iter()
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden)
.map(|s| (s.id.clone(), s.name.clone()))
.map(|s| (s.builtin, s.id.clone(), s.name.clone()))
.collect();
styles.sort_by_key(|(builtin, _, _)| *builtin);
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
ui.horizontal(|ui| {
for (id, name) in styles {
for (_, id, name) in styles {
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
let _ = app.run("table.style", json!({"style": id}));
}
@ -1338,6 +1359,32 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
});
});
stack(ui, |ui| {
let current = look.and_then(|_| {
let (tp, _, _) = app.session.sel.focus.path.cell()?;
let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?;
app.session
.doc
.styles
.get(&id)
.filter(|st| st.kind == wordcraft_doc::StyleKind::Table)
.map(wordcraft_engine::cmd::table_style::is_builtin_table_style)
});
let can_modify = current.is_some();
// Built-in table styles can't be deleted.
let can_delete = current == Some(false);
menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| {
mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"}));
if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() {
let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"}));
ui.close();
}
if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() {
if let Err(e) = app.run("table.deleteStyle", json!({})) {
app.status(e);
}
ui.close();
}
});
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
let theme = app.session.doc.settings.theme_colors.clone();
@ -1374,8 +1421,8 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) {
mi(ui, app, "Select Row", "table.selectRow", json!({}));
mi(ui, app, "Select Table", "table.selectTable", json!({}));
});
let g = app.session.view.gridlines;
small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "view.gridlines", json!({}), g);
let g = app.session.view.table_gridlines;
small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "table.viewGridlines", json!({}), g);
small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false);
});
});

View File

@ -0,0 +1,332 @@
//! How the document canvas scrolls (issue #122).
//!
//! egui's built-in wheel handling smooths every delta it thinks is coarse and drops the deltas of
//! a touchpad's first and last events, and Wayland compositors leave kinetic scrolling to the app
//! (GTK, Qt and browsers synthesise it after the fingers lift). So the canvas scrolls itself:
//! - touchpad (pixel) deltas move the page 1:1, with no animation;
//! - after a touchpad flick the page coasts and slows down ([`Momentum`]), on platforms whose
//! system doesn't already send momentum events (not macOS, Windows or the web);
//! - a mouse-wheel notch scrolls about three text lines and eases in over ~0.1 s, like egui.
use egui::{Event, InputOptions, Modifiers, MouseWheelUnit, TouchPhase, Vec2};
/// Screen points one mouse-wheel notch scrolls at 100% zoom: about three lines of body text,
/// like other desktop apps (egui's default is 40).
pub const NOTCH_PX: f32 = 56.0;
/// Whether to synthesise kinetic scrolling. macOS and Windows send their own momentum events
/// (or report touchpads as line deltas), and browsers do their own; only Linux/BSD need it.
const SYNTH_MOMENTUM: bool = !cfg!(any(target_os = "macos", target_os = "ios", target_os = "windows", target_arch = "wasm32"));
/// Longest frame time trusted for velocity and decay (a stall shouldn't fling the page).
const MAX_DT: f32 = 0.1;
/// Shortest event spacing trusted for velocity (two events in one burst aren't infinitely fast).
const MIN_DT: f32 = 1.0 / 500.0;
/// Velocity is averaged over roughly this many seconds of input.
const VELOCITY_WINDOW: f32 = 0.04;
/// With no "fingers lifted" event, coast after this long without input.
const IDLE_RELEASE: f32 = 0.05;
/// Fingers that rested this long before lifting don't fling.
const STALE: f32 = 0.1;
/// Slower flicks than this (points/s) don't coast…
const MIN_FLING: f32 = 150.0;
/// …and coasting stops below this speed.
const STOP_SPEED: f32 = 20.0;
/// Fastest coast (points/s).
const MAX_SPEED: f32 = 8000.0;
/// Exponential decay rate of the coast (1/s): speed falls to 1/e every ~0.33 s.
const DECAY: f32 = 3.0;
/// Kinetic scrolling for touchpads: tracks the scroll velocity while the fingers move and keeps
/// scrolling with exponentially decaying speed after they lift. Time-based, so frame-rate
/// independent. Deltas are in screen points, positive = content moves down/right.
#[derive(Clone, Debug, Default)]
pub struct Momentum {
velocity: Vec2,
/// Seconds since the last fed delta.
since_input: f32,
/// Fingers are on the touchpad (deltas arrived and no lift yet).
streaming: bool,
coasting: bool,
/// The device reports when the fingers lift, so don't guess it from a pause.
reports_lift: bool,
}
impl Momentum {
/// A frame's touchpad delta, `dt` seconds after the previous frame. Non-finite input is ignored.
pub fn feed(&mut self, delta: Vec2, dt: f32) {
if !delta.is_finite() || !dt.is_finite() {
return;
}
let dt = dt.clamp(0.0, MAX_DT);
let fresh = !self.streaming || self.since_input + dt > STALE;
let span = if fresh { dt } else { self.since_input + dt }.clamp(MIN_DT, MAX_DT);
let sample = delta / span;
self.velocity = if fresh {
sample
} else {
let a = 1.0 - (-span / VELOCITY_WINDOW).exp();
self.velocity + (sample - self.velocity) * a
};
let speed = self.velocity.length();
if speed > MAX_SPEED {
self.velocity *= MAX_SPEED / speed;
}
self.since_input = 0.0;
self.streaming = true;
self.coasting = false;
}
/// The fingers lifted: coast if the flick was fast and recent enough.
pub fn release(&mut self) {
self.reports_lift = true;
self.start_coast();
}
fn start_coast(&mut self) {
let fling = self.streaming && self.since_input <= STALE && self.velocity.length() >= MIN_FLING;
self.streaming = false;
if fling {
self.coasting = true;
} else {
self.cancel();
}
}
/// Stop dead (a click, a key, a wheel notch, the end of the document).
pub fn cancel(&mut self) {
self.velocity = Vec2::ZERO;
self.coasting = false;
self.streaming = false;
}
/// Advance a frame without touchpad input; returns how far to coast this frame.
pub fn tick(&mut self, dt: f32) -> Vec2 {
if !dt.is_finite() || dt <= 0.0 {
return Vec2::ZERO;
}
let dt = dt.min(MAX_DT);
self.since_input += dt;
if self.streaming && !self.reports_lift && self.since_input >= IDLE_RELEASE {
self.start_coast();
}
if !self.coasting {
return Vec2::ZERO;
}
// Exact integral of v·e^(−kt) over the frame, so the distance doesn't depend on frame rate.
let keep = (-DECAY * dt).exp();
let step = self.velocity * ((1.0 - keep) / DECAY);
self.velocity *= keep;
if self.velocity.length() < STOP_SPEED {
self.cancel();
}
step
}
/// Still moving or waiting to decide whether to coast: keep repainting.
pub fn is_active(&self) -> bool {
self.coasting || (self.streaming && !self.reports_lift)
}
}
/// The canvas's scrolling state: momentum plus the not-yet-scrolled part of wheel notches.
#[derive(Clone, Debug, Default)]
pub struct CanvasScroll {
momentum: Momentum,
/// Wheel distance still to scroll, eased out over a few frames.
pending: Vec2,
}
impl CanvasScroll {
/// This frame's scroll delta in screen points (positive = content moves down/right) from the
/// wheel/touchpad events in `input`. `hovered`: the pointer is over the canvas (only then do
/// its wheel events scroll it). `notch`: points per wheel notch. `page`: viewport height.
pub fn frame(&mut self, input: &egui::InputState, opts: &InputOptions, hovered: bool, notch: f32, page: f32) -> Vec2 {
let notch = if notch.is_finite() { notch.clamp(1.0, 1000.0) } else { NOTCH_PX };
let page = if page.is_finite() { page.clamp(0.0, 100_000.0) } else { 0.0 };
// Browsers report lines (Firefox: three per notch) where native platforms report notches.
let line = if cfg!(target_arch = "wasm32") { notch / 3.0 } else { notch };
let dt = input.unstable_dt;
let mut now = Vec2::ZERO;
let mut touchpad = Vec2::ZERO;
let mut lifted = false;
let mut stop = false;
for event in &input.events {
match event {
Event::MouseWheel { unit, delta, phase, modifiers } => {
// Ctrl/⌘+wheel zooms (egui turns it into a zoom factor).
if !hovered || modifiers.matches_any(opts.zoom_modifier) {
continue;
}
let d = axis_lock(if delta.is_finite() { *delta } else { Vec2::ZERO }, *modifiers, opts);
match unit {
// Touchpads (and precise mice): 1:1. Browsers also report wheel notches in
// points, so big jumps there still ease in, like egui does.
MouseWheelUnit::Point if !cfg!(target_arch = "wasm32") || d.length() < 8.0 => touchpad += d,
MouseWheelUnit::Point => self.pending += d,
// Whole notches ease in; fractional ones (X11/Windows touchpads, free-spinning
// wheels) are already smooth.
MouseWheelUnit::Line if d.x.fract() == 0.0 && d.y.fract() == 0.0 => {
stop = true;
self.pending += d * line;
}
MouseWheelUnit::Line => now += d * line,
MouseWheelUnit::Page => self.pending += d * page,
}
lifted |= matches!(phase, TouchPhase::End | TouchPhase::Cancel);
}
Event::Key { pressed: true, .. } | Event::PointerButton { pressed: true, .. } | Event::Text(_) => stop = true,
_ => {}
}
}
now += touchpad;
let fed = touchpad != Vec2::ZERO;
if SYNTH_MOMENTUM {
if fed {
self.momentum.feed(touchpad, dt);
}
if lifted {
self.momentum.release();
}
}
// A click, key or wheel notch stops a coast (even one that started this frame).
if stop {
self.momentum.cancel();
}
if SYNTH_MOMENTUM && !fed {
now += self.momentum.tick(dt);
}
// Ease wheel notches in: 90% of the way in 0.1 s (egui's curve).
if self.pending != Vec2::ZERO {
let t = if dt.is_finite() { egui::emath::exponential_smooth_factor(0.9, 0.1, dt.clamp(0.0, MAX_DT)) } else { 1.0 };
let ease = |pending: f32| if pending.abs() < 1.0 { pending } else { pending * t };
let step = Vec2::new(ease(self.pending.x), ease(self.pending.y));
now += step;
self.pending -= step;
}
now
}
/// The scroll ran into the end of the document: stop coasting and drop queued notches.
pub fn hit_edge(&mut self) {
self.momentum.cancel();
self.pending = Vec2::ZERO;
}
/// Scrolling continues without new input: keep repainting.
pub fn is_animating(&self) -> bool {
self.pending != Vec2::ZERO || self.momentum.is_active()
}
}
/// Shift scrolls sideways and Alt scrolls vertically, like egui's own scroll areas.
fn axis_lock(d: Vec2, modifiers: Modifiers, opts: &InputOptions) -> Vec2 {
let horizontal = modifiers.matches_any(opts.horizontal_scroll_modifier);
let vertical = modifiers.matches_any(opts.vertical_scroll_modifier);
match (horizontal, vertical) {
(true, false) => Vec2::new(d.x + d.y, 0.0),
(false, true) => Vec2::new(0.0, d.x + d.y),
_ => d,
}
}
/// Points per wheel notch at `zoom` (1.0 = 100%): more when zoomed in, less when zoomed out, but
/// gently, so zoomed-out overviews don't crawl and close-ups don't jump.
pub fn notch_px(zoom: f32) -> f32 {
let z = if zoom.is_finite() && zoom > 0.0 { zoom.sqrt().clamp(0.75, 2.0) } else { 1.0 };
NOTCH_PX * z
}
#[cfg(test)]
mod tests {
use super::*;
const FRAME: f32 = 1.0 / 60.0;
fn flick(m: &mut Momentum) {
for _ in 0..6 {
m.feed(Vec2::new(0.0, -30.0), FRAME);
}
}
#[test]
fn flick_coasts_then_decays_to_a_stop() {
let mut m = Momentum::default();
flick(&mut m);
m.release();
let first = m.tick(FRAME);
assert!(first.y < -10.0, "keeps going the same way after the fingers lift: {first:?}");
let mut total = first.y;
let mut last = first.y.abs();
let mut frames = 0;
while m.is_active() {
let d = m.tick(FRAME).y;
assert!(d.abs() <= last + 1e-3, "slows down");
last = d.abs();
total += d;
frames += 1;
assert!(frames < 600, "stops within ten seconds");
}
// 1800 pt/s with a 1/3 s time constant: a light coast of about 600 pt.
assert!((-700.0..-400.0).contains(&total), "coasted {total}");
assert_eq!(m.tick(FRAME), Vec2::ZERO);
// Frame-rate independent: 144 Hz coasts about as far.
let mut fast = Momentum::default();
flick(&mut fast);
fast.release();
let mut total144 = 0.0;
while fast.is_active() {
total144 += fast.tick(1.0 / 144.0).y;
}
assert!((total144 - total).abs() < 30.0, "{total144} vs {total}");
}
#[test]
fn new_input_cancels_and_slow_or_stale_lifts_dont_fling() {
let mut m = Momentum::default();
flick(&mut m);
m.release();
assert_ne!(m.tick(FRAME), Vec2::ZERO);
m.cancel();
assert!(!m.is_active());
assert_eq!(m.tick(FRAME), Vec2::ZERO);
// Fingers touching again mid-coast take over: no coasting until they lift again.
flick(&mut m);
m.release();
m.feed(Vec2::new(0.0, -1.0), FRAME);
assert_eq!(m.tick(FRAME), Vec2::ZERO, "the coast stopped");
m.release();
assert!(!m.is_active(), "a slow drag doesn't fling");
// Resting the fingers before lifting doesn't fling either.
flick(&mut m);
for _ in 0..10 {
m.tick(FRAME);
}
m.release();
assert!(!m.is_active());
}
#[test]
fn hostile_numbers_are_ignored() {
let mut m = Momentum::default();
m.feed(Vec2::new(f32::NAN, f32::INFINITY), FRAME);
m.feed(Vec2::new(0.0, -30.0), f32::NAN);
assert!(!m.is_active());
flick(&mut m);
m.feed(Vec2::new(0.0, f32::NEG_INFINITY), FRAME);
m.release();
assert_eq!(m.tick(f32::NAN), Vec2::ZERO);
assert_eq!(m.tick(f32::INFINITY), Vec2::ZERO);
let d = m.tick(FRAME);
assert!(d.is_finite() && d.y < 0.0);
// A huge delta in a tiny frame is capped.
let mut m = Momentum::default();
m.feed(Vec2::new(0.0, 1e30), 1e-9);
m.release();
assert!(m.tick(FRAME).length() <= MAX_SPEED * FRAME);
assert!(notch_px(f32::NAN).is_finite() && notch_px(-1.0) == NOTCH_PX);
}
}

View File

@ -99,6 +99,8 @@ pub struct Tokens {
pub ruler_tick: Color32,
pub status_bar: Color32,
pub selection: Color32,
/// Find › Reading Highlight.
pub find_highlight: Color32,
pub caret: Color32,
pub page_shadow: Color32,
/// Selection-frame handles (pictures, shapes, text boxes) and their shadow.
@ -140,6 +142,7 @@ impl Tokens {
ruler_tick: Color32::from_rgb(0x70, 0x70, 0x70),
status_bar: Color32::from_rgb(0xF0, 0xF0, 0xF0),
selection: Color32::from_rgba_unmultiplied(0x3B, 0x5B, 0xDB, 0x48),
find_highlight: Color32::from_rgba_unmultiplied(0xFF, 0xE0, 0x3D, 0x90),
caret: Color32::BLACK,
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22),
handle: Color32::WHITE,
@ -179,6 +182,7 @@ impl Tokens {
ruler_tick: Color32::from_rgb(0xA0, 0xA0, 0xA0),
status_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F),
selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55),
find_highlight: Color32::from_rgba_unmultiplied(0xC8, 0xA8, 0x00, 0x70),
caret: Color32::BLACK,
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60),
handle: Color32::from_rgb(0xF2, 0xF2, 0xF2),

View File

@ -2,42 +2,36 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**379 of 428 catalog features have commands (88.6%).**
**390 of 431 catalog features have commands (90.5%).**
| Tab | Live | Total |
|---|---|---|
| File | 20 | 20 |
| Quick Access | 4 | 4 |
| Home | 92 | 101 |
| Home | 98 | 101 |
| Insert | 42 | 53 |
| Draw | 0 | 11 |
| Design | 10 | 10 |
| Layout | 18 | 19 |
| Layout | 19 | 19 |
| References | 24 | 24 |
| Mailings | 17 | 17 |
| Review | 26 | 29 |
| View | 25 | 29 |
| Table Design | 5 | 5 |
| Table Design | 8 | 8 |
| Table Layout | 28 | 30 |
| Equation | 21 | 22 |
| Picture Format | 13 | 13 |
| Shape Format | 3 | 6 |
| Header & Footer | 5 | 5 |
| Editing | 23 | 24 |
| Editing | 24 | 24 |
| Tools | 3 | 6 |
## Missing
### Home
- Clipboard › Paste Special (`edit.pasteSpecial`)
- Clipboard › Clipboard Pane (`edit.clipboardPane`)
- Font › Phonetic Guide (`format.phonetic`)
- Font › Enclose Characters (`format.enclose`)
- Paragraph › Asian Typography (`para.asianTypography`)
- Styles › Style Inspector (`styles.inspector`)
- Styles › Manage Styles (`styles.manage`)
- Editing › Advanced Find (`edit.advancedFind`)
- Voice › Dictate (`tools.dictate`)
### Insert
@ -68,10 +62,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
- Insert › Drawing Canvas (`insert.canvas`)
- Replay › Ink Replay (`draw.replay`)
### Layout
- Arrange › Group (`arrange.group`)
### Review
- Language › Translate (`review.translate`)
@ -100,10 +90,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
- Text › Align Text (`shape.alignText`)
- Text › Create Link (`shape.link`)
### Editing
- Selection › Column Selection (`select.column`)
### Tools
- Customize › Customize Ribbon (`tools.customizeRibbon`)