diff --git a/ROADMAP.md b/ROADMAP.md index 6881540..31d5af6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **384 / 430 (89.3%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **390 / 431 (90.5%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | @@ -141,6 +141,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) | | 2026-10-10 | minor | Merged main: catalog 384/430 (89.3%), 431 commands; Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info, Serbian landed; mainstream 54→55% | | 2026-10-10 | minor | Readiness table with hours per audience; full number restored to the additive weighted sum (~47% → ~60%): method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h | | 2026-10-10 | minor | Mainstream practitioner (~54%) and essentials user (~63%) numbers added; full number recomputed 60% → ~47% with the standard's discounts; beta distance now ~28 points and ~450–700 h; stage stays alpha | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 006b9b6..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 { @@ -501,6 +572,7 @@ mod tests { stroke_width: 0.75, float: Default::default(), story, + effects: Default::default(), } } diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs new file mode 100644 index 0000000..d5040e1 --- /dev/null +++ b/crates/doc/src/effects.rs @@ -0,0 +1,224 @@ +//! Shape effects (DrawingML `a:effectLst`, ECMA-376 Part 1 §20.1.8): an outer shadow, a glow and +//! soft edges. Lengths are points, angles degrees, transparencies percent (0 = opaque). +//! +//! Renderers approximate the blurs with [`bands`]: a few copies of the shape's silhouette, grown +//! or shrunk by steps and overlaid with partial opacity, so the screen and PDF output (which has +//! no blur filter) match. + +use serde::{Deserialize, Serialize}; + +use crate::props::Rgb; + +/// Largest blur radius, points. +pub const MAX_BLUR: f32 = 100.0; +/// Largest shadow distance, points. +pub const MAX_DISTANCE: f32 = 200.0; +/// Largest glow size, points. +pub const MAX_GLOW: f32 = 150.0; +/// Largest soft-edge radius, points. +pub const MAX_SOFT_EDGE: f32 = 100.0; + +/// The effects on a shape. Empty (the default) draws the shape plainly. +#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShapeEffects { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shadow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glow: Option, + /// Soft-edge radius, points: the shape fades out over this distance inside its outline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub soft_edge: Option, +} + +/// An outer shadow: a blurred copy of the shape's silhouette cast `distance` points away in +/// direction `angle` (degrees clockwise from the positive x axis, y pointing down). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Shadow { + pub color: Rgb, + pub transparency: f32, + pub blur: f32, + pub distance: f32, + pub angle: f32, +} + +impl Default for Shadow { + /// "Offset: Bottom Right". + fn default() -> Self { + Shadow { color: Rgb::BLACK, transparency: 60.0, blur: 4.0, distance: 3.0, angle: 45.0 } + } +} + +/// A glow: a soft band of colour `size` points wide around the shape's outline. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Glow { + pub color: Rgb, + pub size: f32, + pub transparency: f32, +} + +impl Default for Glow { + fn default() -> Self { + Glow { color: Rgb(0x15, 0x60, 0x82), size: 5.0, transparency: 60.0 } + } +} + +fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 { + if v.is_finite() { v.clamp(lo, hi) } else { dflt } +} + +/// The outer-shadow presets (no perspective shadows): id, label. +pub const SHADOW_PRESETS: [(&str, &str); 9] = [ + ("offsetBottomRight", "Offset: Bottom Right"), + ("offsetBottom", "Offset: Bottom"), + ("offsetBottomLeft", "Offset: Bottom Left"), + ("offsetRight", "Offset: Right"), + ("offsetCenter", "Offset: Center"), + ("offsetLeft", "Offset: Left"), + ("offsetTopRight", "Offset: Top Right"), + ("offsetTop", "Offset: Top"), + ("offsetTopLeft", "Offset: Top Left"), +]; + +impl Shadow { + /// A preset by id (see [`SHADOW_PRESETS`]). + pub fn preset(id: &str) -> Option { + let d = Shadow::default(); + let angle = match id { + "offsetBottomRight" => 45.0, + "offsetBottom" => 90.0, + "offsetBottomLeft" => 135.0, + "offsetLeft" => 180.0, + "offsetTopLeft" => 225.0, + "offsetTop" => 270.0, + "offsetTopRight" => 315.0, + "offsetRight" => 0.0, + "offsetCenter" => return Some(Shadow { distance: 0.0, blur: 5.0, ..d }), + _ => return None, + }; + Some(Shadow { angle, ..d }) + } + /// Finite, in range. + pub fn sanitized(self) -> Shadow { + let angle = clamp(self.angle, -1e6, 1e6, 45.0).rem_euclid(360.0); + Shadow { + color: self.color, + transparency: clamp(self.transparency, 0.0, 100.0, 60.0), + blur: clamp(self.blur, 0.0, MAX_BLUR, 0.0), + distance: clamp(self.distance, 0.0, MAX_DISTANCE, 0.0), + angle: if angle.is_finite() && angle < 360.0 { angle } else { 0.0 }, + } + } + /// The shadow's offset from the shape, points (x right, y down). + pub fn offset(&self) -> (f32, f32) { + let s = self.sanitized(); + let a = s.angle.to_radians(); + (s.distance * a.cos(), s.distance * a.sin()) + } + /// Opacity 0..1. + pub fn opacity(&self) -> f32 { + 1.0 - self.sanitized().transparency / 100.0 + } +} + +impl Glow { + /// Finite, in range. + pub fn sanitized(self) -> Glow { + Glow { color: self.color, size: clamp(self.size, 0.0, MAX_GLOW, 0.0), transparency: clamp(self.transparency, 0.0, 100.0, 60.0) } + } + /// Opacity 0..1. + pub fn opacity(&self) -> f32 { + 1.0 - self.sanitized().transparency / 100.0 + } +} + +impl ShapeEffects { + pub fn is_empty(&self) -> bool { + self.shadow.is_none() && self.glow.is_none() && self.soft_edge.is_none() + } + /// Finite, in range; zero-size glows and soft edges dropped. + pub fn sanitized(self) -> ShapeEffects { + ShapeEffects { + shadow: self.shadow.map(Shadow::sanitized), + glow: self.glow.map(Glow::sanitized).filter(|g| g.size > 0.0), + soft_edge: self.soft_edge.map(|r| clamp(r, 0.0, MAX_SOFT_EDGE, 0.0)).filter(|r| *r > 0.0), + } + } + /// How far the effects reach beyond the shape's frame on each side (left, top, right, + /// bottom), points — what `wp:effectExtent` records. + pub fn extent(&self) -> [f32; 4] { + let e = self.sanitized(); + let mut out = [0.0f32; 4]; + if let Some(g) = e.glow { + out = [g.size; 4]; + } + if let Some(s) = e.shadow { + let (dx, dy) = s.offset(); + let r = s.blur / 2.0; + let reach = [r - dx, r - dy, r + dx, r + dy]; + for (o, v) in out.iter_mut().zip(reach) { + *o = o.max(v.max(0.0)); + } + } + out.map(|v| (v * 100.0).round() / 100.0) + } +} + +/// Bands approximating a blurred edge: `(grow, alpha)` pairs. Each is a copy of the silhouette +/// grown outward by `grow` points (negative shrinks it), filled with opacity `alpha`; overlaid in +/// any order, in one colour, they ramp linearly from `opacity` at `from` to nothing at `to`. +pub fn bands(from: f32, to: f32, opacity: f32) -> Vec<(f32, f32)> { + let (from, to) = (clamp(from, -1000.0, 1000.0, 0.0), clamp(to, -1000.0, 1000.0, 0.0)); + let opacity = clamp(opacity, 0.0, 1.0, 0.0); + if opacity <= 0.0 { + return Vec::new(); + } + let span = (to - from).max(0.0); + if span < 0.25 { + return vec![((from + to) / 2.0, opacity)]; + } + let n = ((span / 1.5).ceil() as usize).clamp(3, 12); + let nf = n as f32; + (1..=n) + .map(|m| { + // The m-th band from the outside: after it, coverage is opacity·m/n. + let grow = to - span * (m as f32 - 0.5) / nf; + let before = 1.0 - opacity * (m as f32 - 1.0) / nf; + let after = 1.0 - opacity * m as f32 / nf; + let alpha = if before > 0.0 { 1.0 - after / before } else { 1.0 }; + (grow, alpha.clamp(0.0, 1.0)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bands_ramp_to_the_requested_opacity() { + for (from, to, o) in [(-2.0, 2.0, 0.4), (-5.0, 0.0, 1.0), (0.0, 8.0, 0.6)] { + let b = bands(from, to, o); + assert!(b.iter().all(|(g, a)| *g > from && *g < to && (0.0..=1.0).contains(a))); + let covered = 1.0 - b.iter().map(|(_, a)| 1.0 - a).product::(); + assert!((covered - o).abs() < 1e-4, "{covered} vs {o}"); + } + assert_eq!(bands(0.0, 0.0, 0.5), vec![(0.0, 0.5)]); + assert!(bands(f32::NAN, f32::INFINITY, f32::NAN).is_empty()); + } + + #[test] + fn hostile_numbers_are_clamped() { + let s = Shadow { color: Rgb::BLACK, transparency: f32::NAN, blur: 1e9, distance: -4.0, angle: -90.0 }.sanitized(); + assert_eq!((s.transparency, s.blur, s.distance, s.angle), (60.0, MAX_BLUR, 0.0, 270.0)); + let e = ShapeEffects { shadow: None, glow: Some(Glow { size: 1e9, ..Glow::default() }), soft_edge: Some(-1.0) }.sanitized(); + assert_eq!(e.glow.map(|g| g.size), Some(MAX_GLOW)); + assert_eq!(e.soft_edge, None); + let e = ShapeEffects { glow: Some(Glow { size: f32::INFINITY, ..Glow::default() }), ..Default::default() }.sanitized(); + assert!(e.is_empty(), "a non-finite size is no glow"); + let (dx, dy) = Shadow::preset("offsetBottomRight").unwrap().offset(); + assert!(dx > 2.0 && dy > 2.0); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 409475e..8c89f2d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod bidi; pub mod edit; +pub mod effects; pub mod encoding; pub mod fields; pub mod graphic; @@ -596,7 +597,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() }); } } @@ -700,9 +701,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(), }) } @@ -716,10 +716,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() @@ -765,13 +762,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(); + } } } }); @@ -780,7 +780,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; @@ -789,7 +789,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 04bd5a8..bbf18c1 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -223,6 +223,23 @@ pub enum InlineObject { /// Text box content: `Document::parts` id. #[serde(default)] story: Option, + /// Shadow, glow and soft edges. + #[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. @@ -283,7 +300,101 @@ 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, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped. + pub fn is_drawing(&self) -> bool { + matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | 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::Graphic { 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::Graphic { 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::Graphic { 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!( @@ -298,7 +409,10 @@ impl InlineObject { } pub fn is_floating(&self) -> bool { match self { - InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline, + InlineObject::Image { float, .. } + | InlineObject::Graphic { 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 2c8d913..2dea2d8 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 0844c93..806b9eb 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -65,6 +65,21 @@ pub struct TableStyleParts { 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 { @@ -91,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); } } @@ -437,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