diff --git a/Cargo.lock b/Cargo.lock index fff9355..d7ceb01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4865,6 +4865,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" name = "wordcraft" version = "0.4.0" dependencies = [ + "arboard", "eframe", "egui", "fmv-macos-events", diff --git a/ROADMAP.md b/ROADMAP.md index 2dd3181..93a6c20 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml index 9495b21..befd8b7 100644 --- a/apps/wordcraft-web/Cargo.toml +++ b/apps/wordcraft-web/Cargo.toml @@ -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"] } diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index fab12d9..c032150 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -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::::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>) -> Services { let open_inbox = inbox.clone(); diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index f006c2b..f63e0b2 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -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" diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 2a0991f..2cadb6b 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -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 { + 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. diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index fc46e24..99488c1 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -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| 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, depth: usize) -> usize { + let fix = |s: &mut Option| { + 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 + '_ { - 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) -> usize { + let stories: Vec = + 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 { @@ -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 { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2d8d1cc..893170b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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 { 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, follow: &dyn Fn(&InlineObject) -> Option<(u32, &'static [PartKind])>) -> Vec { + fn reachable(&self, roots: Vec, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec { let mut out = roots; let mut seen: std::collections::BTreeSet = 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 { diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 02ceca6..87e7786 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -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, + }, /// 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 { + 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 { + 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> { + 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, } } diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index d759c11..aea43f8 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -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, + /// 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, + /// `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, + /// `w:overflowPunct`: punctuation may hang past the line end. Unset = on. + #[serde(skip_serializing_if = "Option::is_none")] + pub overflow_punct: Option, + /// `w:topLinePunct`: compress punctuation at the start of a line. Unset = off. + #[serde(skip_serializing_if = "Option::is_none")] + pub top_line_punct: Option, + /// `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, + /// `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, } 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); diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index d559d3a..c12ad65 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -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), } } } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 7a0e201..806b9eb 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -52,27 +52,52 @@ pub struct TableStyleParts { pub cell_margins: Option<[f32; 4]>, pub header_fill: Option, 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, pub band_fill: Option, + /// 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, + /// Rows per band (`w:tblStyleRowBandSize`); one when unset. + pub band_size: Option, pub first_col_chr: CharProps, pub total_chr: CharProps, pub total_border_top: Option, + /// Shading and cell borders of the first column (`firstCol` region). + pub first_col_fill: Option, + pub first_col_borders: Option, + /// The last column (`lastCol` region). + pub last_col_fill: Option, + pub last_col_chr: CharProps, + pub last_col_borders: Option, + /// 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, + pub total_borders: Option, + /// The odd column bands (`band1Vert` region). + pub col_band_fill: Option, + pub col_band_chr: CharProps, + pub col_band_borders: Option, } 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, p: Option| 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