mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
Re-land #264, #263, #270, #271, #267, #280: clipboard pane, table styles, Manage Styles, Asian Typography, Group, Shape Effects (#323)
The original squash merges of these six PRs were empty. Integrated and tested together on current main. Co-authored-by: Valentin Klein <vangel1976@googlemail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
49 files changed
+4047
-404
No files matched your search
+2
-1
@@ -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 |
|
||||
|
||||
+78
-6
@@ -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 {
|
||||
@@ -501,6 +572,7 @@ mod tests {
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story,
|
||||
effects: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Shadow>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glow: Option<Glow>,
|
||||
/// 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<f32>,
|
||||
}
|
||||
|
||||
/// 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<Shadow> {
|
||||
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::<f32>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -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<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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
@@ -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 {
|
||||
|
||||
+115
-1
@@ -223,6 +223,23 @@ pub enum InlineObject {
|
||||
/// Text box content: `Document::parts` id.
|
||||
#[serde(default)]
|
||||
story: Option<u32>,
|
||||
/// 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<GroupChild>,
|
||||
},
|
||||
/// 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<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!(
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,21 @@ pub struct TableStyleParts {
|
||||
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 {
|
||||
@@ -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<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();
|
||||
|
||||
@@ -88,6 +88,7 @@ fn text_box_anchor_finds_the_owning_shape() {
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story,
|
||||
effects: Default::default(),
|
||||
};
|
||||
// A plain shape before it doesn't count.
|
||||
d.insert_object(&Pos::body(0, 0), shape(None), &CharProps::default()).unwrap();
|
||||
@@ -108,6 +109,7 @@ fn word_count_including_notes_counts_used_boxes_and_notes_only() {
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
|
||||
let boxed = d.add_part(PartKind::TextBox, para_of("three four"));
|
||||
@@ -146,6 +148,7 @@ fn counted_stories_survive_self_nested_boxes() {
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape.clone(), &CharProps::default()).unwrap();
|
||||
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, shape, &CharProps::default()).unwrap();
|
||||
@@ -163,6 +166,7 @@ fn prune_text_boxes_drops_only_unshown_ones() {
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
|
||||
let mut d = Document::from_text("body");
|
||||
@@ -195,6 +199,7 @@ fn notes_in_text_box_follow_nested_boxes_once() {
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let note = |id| InlineObject::NoteRef { kind: para::NoteKind::Footnote, id, custom: String::new() };
|
||||
let mut d = Document::new();
|
||||
|
||||
@@ -505,10 +505,28 @@ impl Reader<'_> {
|
||||
t.band_chr = chr;
|
||||
t.band_borders = cell_borders;
|
||||
}
|
||||
Some("firstCol") => t.first_col_chr = chr,
|
||||
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("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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+181
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind};
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{CharProps, Rgb};
|
||||
@@ -125,7 +126,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"))
|
||||
}
|
||||
@@ -663,19 +664,11 @@ impl Reader<'_> {
|
||||
let graphic = self.graphic(kind, gd, rels, w, h);
|
||||
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
|
||||
}
|
||||
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));
|
||||
@@ -715,6 +708,99 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
@@ -741,11 +827,12 @@ impl Reader<'_> {
|
||||
let ln = sppr.and_then(|s| s.child("a:ln"));
|
||||
let stroke = solid(ln);
|
||||
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0);
|
||||
let effects = sppr.and_then(|s| s.child("a:effectLst")).map(effect_list).unwrap_or_default();
|
||||
let story = match txbx {
|
||||
Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)),
|
||||
_ => None,
|
||||
};
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story }
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, effects }
|
||||
}
|
||||
|
||||
fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
|
||||
@@ -775,7 +862,17 @@ impl Reader<'_> {
|
||||
let story = Some(self.read_textbox(sc, t, rels));
|
||||
let fill = shape.attr("fillcolor").and_then(Rgb::parse);
|
||||
let stroke = shape.attr("strokecolor").and_then(Rgb::parse);
|
||||
return Some(InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill, stroke, stroke_width: 0.75, float, story });
|
||||
return Some(InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: 0.75,
|
||||
float,
|
||||
story,
|
||||
effects: Default::default(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -965,3 +1062,71 @@ fn vml_float(shape: &El, style: &str) -> Float {
|
||||
fn graphic_work(items: &[GraphicItem]) -> usize {
|
||||
items.iter().map(|it| if let GraphicItem::Path { segs, .. } = it { segs.len() + 1 } else { 1 }).sum()
|
||||
}
|
||||
|
||||
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26): its outer shadow, glow and soft edges.
|
||||
/// Other effects are dropped.
|
||||
fn effect_list(l: &El) -> ShapeEffects {
|
||||
let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0);
|
||||
let shadow = l.child("a:outerShdw").map(|e| {
|
||||
let (color, transparency) = effect_color(e);
|
||||
// `dir`: 60000ths of a degree, clockwise.
|
||||
let angle = e.attr("dir").and_then(int).map(|v| (v.rem_euclid(21_600_000) as f32) / 60_000.0).unwrap_or(0.0);
|
||||
Shadow { color: color.unwrap_or(Rgb::BLACK), transparency, blur: pt(e, "blurRad"), distance: pt(e, "dist"), angle }
|
||||
});
|
||||
let glow = l.child("a:glow").map(|e| {
|
||||
let (color, transparency) = effect_color(e);
|
||||
Glow { color: color.unwrap_or(Glow::default().color), size: pt(e, "rad"), transparency }
|
||||
});
|
||||
let soft_edge = l.child("a:softEdge").map(|e| pt(e, "rad"));
|
||||
ShapeEffects { shadow, glow, soft_edge }.sanitized()
|
||||
}
|
||||
|
||||
/// The colour inside an effect (`a:srgbClr`, `a:prstClr`, `a:sysClr`; theme colours aren't
|
||||
/// resolved here) and its transparency, percent (from `a:alpha`, opaque when absent).
|
||||
fn effect_color(e: &El) -> (Option<Rgb>, f32) {
|
||||
let Some(c) = e.els().find(|c| matches!(c.name.as_str(), "a:srgbClr" | "a:prstClr" | "a:sysClr" | "a:schemeClr" | "a:scrgbClr" | "a:hslClr"))
|
||||
else {
|
||||
return (None, 0.0);
|
||||
};
|
||||
let rgb = match c.name.as_str() {
|
||||
"a:srgbClr" => c.attr("val").and_then(Rgb::parse),
|
||||
"a:sysClr" => c.attr("lastClr").and_then(Rgb::parse),
|
||||
"a:prstClr" => match c.attr("val").unwrap_or("") {
|
||||
"black" => Some(Rgb::BLACK),
|
||||
"white" => Some(Rgb::WHITE),
|
||||
"gray" | "grey" => Some(Rgb(0x80, 0x80, 0x80)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
// `a:alpha`: opacity in 1000ths of a percent.
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@@ -665,9 +665,18 @@ fn style_xml(w: &mut W, st: &Style) {
|
||||
};
|
||||
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
|
||||
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
|
||||
let total_top = t.total_border_top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() });
|
||||
cond(w, "lastRow", &t.total_chr, None, total_top);
|
||||
cond(w, "firstCol", &t.first_col_chr, None, None);
|
||||
// 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");
|
||||
|
||||
@@ -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)));
|
||||
|
||||
+204
-89
@@ -1,7 +1,8 @@
|
||||
//! Blocks, paragraphs, runs, objects and tables → WordprocessingML.
|
||||
|
||||
use wordcraft_doc::effects::ShapeEffects;
|
||||
use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::props::{CharProps, Rgb};
|
||||
use wordcraft_doc::section::{LineNumberRestart, SectionProps, SectionStart};
|
||||
use wordcraft_doc::table::Table;
|
||||
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind};
|
||||
@@ -371,10 +372,8 @@ impl Writer<'_> {
|
||||
}
|
||||
// Charts and diagrams aren't written back yet.
|
||||
InlineObject::Graphic { .. } => {}
|
||||
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);
|
||||
@@ -385,30 +384,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" });
|
||||
@@ -416,77 +392,70 @@ impl Writer<'_> {
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, float, story } => {
|
||||
InlineObject::Shape { w: sw, h: sh, float, effects, .. } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("Shape {docpr}");
|
||||
// The effect extent leaves room for the shadow and glow.
|
||||
let mut float = *float;
|
||||
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
|
||||
*e = wordcraft_geom::finite(*e).max(fx);
|
||||
}
|
||||
let float = &float;
|
||||
self.drawing_open(w, float, *sw, *sh, &docpr, &name, "");
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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" });
|
||||
@@ -576,6 +545,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)));
|
||||
@@ -776,9 +847,53 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn xfrm(w: &mut W, cw: f32, ch: f32) {
|
||||
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order.
|
||||
fn effect_list(w: &mut W, effects: &ShapeEffects) {
|
||||
let fx = effects.sanitized();
|
||||
if fx.is_empty() {
|
||||
return;
|
||||
}
|
||||
let color = |w: &mut W, c: Rgb, transparency: f32| {
|
||||
let alpha = n(((100.0 - transparency.clamp(0.0, 100.0)) * 1000.0).round() as i64);
|
||||
w.open("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.empty("a:alpha", &[("val", &alpha)]);
|
||||
w.close("a:srgbClr");
|
||||
};
|
||||
w.open("a:effectLst", &[]);
|
||||
if let Some(g) = fx.glow {
|
||||
w.open("a:glow", &[("rad", &emu(g.size))]);
|
||||
color(w, g.color, g.transparency);
|
||||
w.close("a:glow");
|
||||
}
|
||||
if let Some(s) = fx.shadow {
|
||||
let dir = n((s.angle * 60_000.0).round() as i64);
|
||||
// Anchor scaling at the corner the shadow falls away from (no scaling is written, so
|
||||
// this only matters to editors that resize it).
|
||||
let (dx, dy) = s.offset();
|
||||
let algn = match (dy > 0.01, dy < -0.01, dx > 0.01, dx < -0.01) {
|
||||
(true, _, true, _) => "tl",
|
||||
(true, _, _, true) => "tr",
|
||||
(true, _, _, _) => "t",
|
||||
(_, true, true, _) => "bl",
|
||||
(_, true, _, true) => "br",
|
||||
(_, true, _, _) => "b",
|
||||
(_, _, true, _) => "l",
|
||||
(_, _, _, true) => "r",
|
||||
_ => "ctr",
|
||||
};
|
||||
w.open("a:outerShdw", &[("blurRad", &emu(s.blur)), ("dist", &emu(s.distance)), ("dir", &dir), ("algn", algn), ("rotWithShape", "0")]);
|
||||
color(w, s.color, s.transparency);
|
||||
w.close("a:outerShdw");
|
||||
}
|
||||
if let Some(r) = fx.soft_edge {
|
||||
w.empty("a:softEdge", &[("rad", &emu(r))]);
|
||||
}
|
||||
w.close("a:effectLst");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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>"#;
|
||||
|
||||
@@ -699,3 +699,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)]);
|
||||
}
|
||||
@@ -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())];
|
||||
@@ -678,6 +685,7 @@ fn toc_field_skips_nested_stories() {
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
}
|
||||
};
|
||||
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
|
||||
@@ -799,6 +807,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
stroke_width: 1.0,
|
||||
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let star = InlineObject::Shape {
|
||||
kind: ShapeKind::Star,
|
||||
@@ -809,6 +818,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() };
|
||||
let mut p = Paragraph::with_text("shapes ", CharProps::default());
|
||||
@@ -824,7 +834,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
let got = paras(&r);
|
||||
assert_eq!(got[0].objects.len(), 3);
|
||||
match &got[0].objects[0] {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story } => {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } => {
|
||||
assert_eq!(
|
||||
(*kind, *w, *h, *fill, *stroke, *stroke_width),
|
||||
(ShapeKind::TextBox, 144.0, 72.0, Some(Rgb(255, 255, 200)), Some(Rgb(0, 0, 0)), 1.0)
|
||||
@@ -848,6 +858,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
|
||||
@@ -1027,6 +1113,7 @@ fn self_showing_text_box_saves_bounded() {
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
for _ in 0..30 {
|
||||
d.insert_object(
|
||||
@@ -1107,3 +1194,48 @@ fn list_level_overrides_round_trip() {
|
||||
assert_eq!(c.next_label(&back.numbering, restart, 0).unwrap().0, "1.");
|
||||
assert_eq!(c.next_label(&back.numbering, restart, 1).unwrap().0, "1.01");
|
||||
}
|
||||
|
||||
/// Shape effects (#275): `a:effectLst` with an outer shadow, a glow and soft edges round-trips,
|
||||
/// and the effect extent leaves room for them.
|
||||
#[test]
|
||||
fn shape_effects_round_trip() {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
let effects = ShapeEffects {
|
||||
shadow: Some(Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0 }),
|
||||
glow: Some(Glow { color: Rgb(0xC0, 0x50, 0x10), size: 8.0, transparency: 40.0 }),
|
||||
soft_edge: Some(2.5),
|
||||
};
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::RoundedRectangle,
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
fill: Some(Rgb(0x15, 0x60, 0x82)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
effects,
|
||||
};
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.insert_object(1, shape, &CharProps::default()).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(
|
||||
xml.contains(
|
||||
r#"<a:outerShdw blurRad="50800" dist="38100" dir="8100000" algn="tr" rotWithShape="0"><a:srgbClr val="203040"><a:alpha val="40000"/>"#
|
||||
),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(xml.contains(r#"<a:glow rad="101600"><a:srgbClr val="C05010"><a:alpha val="60000"/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:softEdge rad="31750"/>"#), "{xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
match paras(&r)[0].objects.first() {
|
||||
Some(InlineObject::Shape { effects: got, float, .. }) => {
|
||||
assert_eq!(*got, effects);
|
||||
assert_eq!(float.effect_extent(), effects.extent());
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
@@ -329,6 +329,7 @@ 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
|
||||
@@ -502,6 +503,6 @@ mod tests {
|
||||
let p = parity(®);
|
||||
let pct = p["percent"].as_f64().unwrap();
|
||||
// The floor only ever rises.
|
||||
assert!(pct >= 89.0, "parity {pct}%");
|
||||
assert!(pct >= 90.4, "parity {pct}%");
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,31 @@ 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}"#)
|
||||
@@ -66,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}))
|
||||
}
|
||||
@@ -88,6 +114,140 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let text = match p::str(v, "text") {
|
||||
Some(t) => t.to_string(),
|
||||
|
||||
@@ -252,8 +252,17 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let obj =
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None };
|
||||
let obj = InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: 1.0,
|
||||
float: Float { wrap: Wrap::Inline, ..Default::default() },
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection { anchor: at, focus: end };
|
||||
sel_result(s)
|
||||
@@ -275,6 +284,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
s.doc.insert_object(&at, obj, &props)?;
|
||||
// Like Word, type straight into the new box.
|
||||
|
||||
@@ -23,7 +23,7 @@ fn has_object(s: &Session) -> Option<&'static str> {
|
||||
fn has_floating(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,15 @@ fn has_movable(s: &Session) -> Option<&'static str> {
|
||||
_ => has_object(s),
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -271,6 +280,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);
|
||||
@@ -305,6 +321,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null, "width"?: pt}"#)
|
||||
.when(has_shape),
|
||||
CommandSpec::new("shape.effects", "Shape Effects", "Shape Format › Shape Styles", shape_effects)
|
||||
.params(
|
||||
r#"{"shadow"?: preset|{"preset"?, "color"?: "RRGGBB", "transparency"?: %, "blur"?: pt, "distance"?: pt, "angle"?: deg}|null, "glow"?: pt|{"color"?: "RRGGBB", "size"?: pt, "transparency"?: %}|null, "softEdge"?: pt|null} (shadow presets: offsetBottomRight, offsetBottom, offsetBottomLeft, offsetRight, offsetCenter, offsetLeft, offsetTopRight, offsetTop, offsetTopLeft; an omitted key is left as it is; applies to every shape in the selection)"#,
|
||||
)
|
||||
.when(has_shape),
|
||||
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
|
||||
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
with_obj(s, |o| {
|
||||
@@ -326,12 +347,20 @@ 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::Graphic { .. } | 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.
|
||||
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
selected_all(s, 1).into_iter().next()
|
||||
}
|
||||
|
||||
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
|
||||
/// caret.
|
||||
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
|
||||
let mut out = Vec::new();
|
||||
let (a, b) = s.sel.ordered();
|
||||
let max = if a == b { 1 } else { max };
|
||||
let story = a.story;
|
||||
let paths = if a == b { vec![a.path.clone()] } else { s.doc.paths_between(&a, &b) };
|
||||
for path in paths {
|
||||
@@ -345,12 +374,104 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
if !inside {
|
||||
continue;
|
||||
}
|
||||
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
|
||||
return Some((Pos { story, path: path.clone(), off }, o.clone()));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
out
|
||||
}
|
||||
|
||||
/// Most shapes one `shape.effects` changes.
|
||||
const MAX_EFFECT_SHAPES: usize = 10_000;
|
||||
|
||||
/// `shape.effects`: set or clear the shadow, glow and soft edges of the selected shapes.
|
||||
fn shape_effects(s: &mut Session, v: &Value) -> CmdResult {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
let color = |o: &Value| p::str(o, "color").and_then(Rgb::parse);
|
||||
// Each change is `None` (leave as it is) or `Some(new value)`.
|
||||
let shadow: Option<Option<Shadow>> = match v.get("shadow") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Bool(b)) => Some(b.then(Shadow::default)),
|
||||
Some(Value::String(id)) if id == "none" => Some(None),
|
||||
Some(Value::String(id)) => Some(Some(Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?)),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let mut sh = match p::str(o, "preset") {
|
||||
Some(id) => Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?,
|
||||
None => Shadow::default(),
|
||||
};
|
||||
sh.color = color(o).unwrap_or(sh.color);
|
||||
sh.transparency = p::f32(o, "transparency").unwrap_or(sh.transparency);
|
||||
sh.blur = p::f32(o, "blur").unwrap_or(sh.blur);
|
||||
sh.distance = p::f32(o, "distance").unwrap_or(sh.distance);
|
||||
sh.angle = p::f32(o, "angle").unwrap_or(sh.angle);
|
||||
Some(Some(sh.sanitized()))
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`shadow`: a preset name, an object or null".into())),
|
||||
};
|
||||
let glow: Option<Option<Glow>> = match v.get("glow") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(id)) if id == "none" => Some(None),
|
||||
Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..Glow::default() }.sanitized())),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let d = Glow::default();
|
||||
Some(Some(
|
||||
Glow {
|
||||
color: color(o).unwrap_or(d.color),
|
||||
size: p::f32(o, "size").unwrap_or(d.size),
|
||||
transparency: p::f32(o, "transparency").unwrap_or(d.transparency),
|
||||
}
|
||||
.sanitized(),
|
||||
))
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`glow`: a size in points, an object or null".into())),
|
||||
};
|
||||
let soft: Option<Option<f32>> = match v.get("softEdge") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
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())),
|
||||
};
|
||||
// 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()));
|
||||
}
|
||||
let mut last = ShapeEffects::default();
|
||||
for pos in &targets {
|
||||
let o = edit_obj(s, pos, |o| {
|
||||
if let InlineObject::Shape { effects, float, .. } = o {
|
||||
let mut e = *effects;
|
||||
if let Some(x) = shadow {
|
||||
e.shadow = x;
|
||||
}
|
||||
if let Some(x) = glow {
|
||||
e.glow = x;
|
||||
}
|
||||
if let Some(x) = soft {
|
||||
e.soft_edge = x;
|
||||
}
|
||||
*effects = e.sanitized();
|
||||
// Room around the shape for its shadow and glow, as Word's effect extent.
|
||||
float.effect = effects.extent();
|
||||
}
|
||||
})?;
|
||||
if let InlineObject::Shape { effects, .. } = o {
|
||||
last = effects;
|
||||
}
|
||||
}
|
||||
Ok(json!({"shapes": targets.len(), "effects": serde_json::to_value(last).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// Smallest width/height an object can be resized to, points (a text box keeps room for a line).
|
||||
@@ -374,14 +495,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::Graphic { 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))?,
|
||||
@@ -461,7 +575,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::Graphic { 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(());
|
||||
}
|
||||
@@ -510,10 +624,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::Graphic { 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 {
|
||||
@@ -534,15 +645,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::Graphic { 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::Graphic { 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.
|
||||
@@ -550,7 +661,6 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
|
||||
float.dist = 9.0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -750,12 +860,191 @@ 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 matches!(obj, InlineObject::Graphic { .. }) {
|
||||
return Err(CmdError::Disabled(FROZEN.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::Graphic { .. } | 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()));
|
||||
}
|
||||
}
|
||||
@@ -773,6 +1062,7 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
|
||||
InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }),
|
||||
InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)),
|
||||
InlineObject::Graphic { graphic, .. } => ("graphic", format!("{:?} {}", graphic.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)})
|
||||
@@ -876,6 +1166,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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
//! Table Design › Table Styles: create and modify table styles (whole table, header row and
|
||||
//! banded rows), stored as table styles in the document's style sheet (ECMA-376 §17.7.6).
|
||||
//! 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};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Table};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`,
|
||||
/// `bandedRows`) takes the same shortcuts.
|
||||
/// 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, "bandedRows"?: Region, "bandSize"?: n, "para"?: ParaProps, "apply"?: bool}"#,
|
||||
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}"#
|
||||
)
|
||||
};
|
||||
@@ -25,6 +33,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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)}"#),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +53,8 @@ fn current_table_style(s: &Session) -> Option<String> {
|
||||
s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()
|
||||
}
|
||||
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// 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()));
|
||||
@@ -56,8 +67,14 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
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: id.clone(), name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
|
||||
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) {
|
||||
@@ -66,11 +83,28 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
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 id = 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()))?,
|
||||
};
|
||||
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)
|
||||
@@ -96,12 +130,119 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
st.based_on = Some(b);
|
||||
}
|
||||
edit(&mut st, v)?;
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(false) {
|
||||
apply(s, &id)?;
|
||||
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;
|
||||
}
|
||||
Ok(json!({"id": id}))
|
||||
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.
|
||||
@@ -123,6 +264,18 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ pub struct ViewState {
|
||||
#[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,
|
||||
@@ -95,6 +98,7 @@ impl Default for ViewState {
|
||||
styles_pane: false,
|
||||
style_inspector: false,
|
||||
comments_pane: false,
|
||||
clipboard_pane: false,
|
||||
multi_page: false,
|
||||
fit: String::new(),
|
||||
web_width: 800.0,
|
||||
@@ -183,6 +187,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>,
|
||||
@@ -241,6 +247,9 @@ 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.
|
||||
@@ -285,6 +294,7 @@ impl Session {
|
||||
dirty: false,
|
||||
clipboard: None,
|
||||
clipboard_text: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find: FindState::default(),
|
||||
goal_x: None,
|
||||
page_hint: 0,
|
||||
@@ -316,6 +326,7 @@ impl Session {
|
||||
math: None,
|
||||
math_latex: false,
|
||||
math_normal_text: false,
|
||||
also_selected: Vec::new(),
|
||||
prefs: Prefs::default(),
|
||||
read_aloud: Default::default(),
|
||||
column: None,
|
||||
@@ -482,6 +493,7 @@ 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();
|
||||
@@ -516,6 +528,7 @@ impl Session {
|
||||
dirty,
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
page_hint: _,
|
||||
@@ -550,6 +563,7 @@ 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: _,
|
||||
@@ -581,6 +595,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;
|
||||
@@ -679,6 +694,7 @@ impl Session {
|
||||
} else if spec.id.starts_with("caret.") {
|
||||
self.typing_open = false;
|
||||
}
|
||||
let sel_before = self.sel.clone();
|
||||
let run = spec.run;
|
||||
let mutates = spec.mutates;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
@@ -697,6 +713,10 @@ 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, c)) = before_doc {
|
||||
|
||||
@@ -601,6 +601,73 @@ fn paste_special_respects_track_changes_and_brings_lists() {
|
||||
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();
|
||||
@@ -2285,3 +2352,240 @@ fn accessibility_reports_a_chart_without_alt_text() {
|
||||
let issues = v["issues"].as_array().expect("issues");
|
||||
assert!(issues.iter().any(|i| i["issue"] == "Chart or diagram has no alternative text"), "{issues:?}");
|
||||
}
|
||||
|
||||
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
|
||||
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
|
||||
#[test]
|
||||
fn shape_effects_apply_and_undo() {
|
||||
use wordcraft_doc::effects::{MAX_BLUR, MAX_DISTANCE, Shadow};
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "ellipse"}));
|
||||
let effects = |s: &Session| match cmd::objects::selected(s) {
|
||||
Some((_, wordcraft_doc::InlineObject::Shape { effects, float, .. })) => (effects, float.effect),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
assert!(effects(&s).0.is_empty());
|
||||
run(&mut s, "shape.effects", json!({"shadow": "offsetBottomRight", "glow": {"color": "FF0000", "size": 8}, "softEdge": 5}));
|
||||
let (e, extent) = effects(&s);
|
||||
assert_eq!(e.shadow, Shadow::preset("offsetBottomRight"));
|
||||
assert_eq!(e.glow.map(|g| (g.color, g.size)), Some((wordcraft_doc::props::Rgb(255, 0, 0), 8.0)));
|
||||
assert_eq!(e.soft_edge, Some(5.0));
|
||||
assert!(extent.iter().all(|v| *v >= 8.0), "room for the glow: {extent:?}");
|
||||
// Clearing the glow leaves the shadow and soft edges.
|
||||
run(&mut s, "shape.effects", json!({"glow": null}));
|
||||
let (e, _) = effects(&s);
|
||||
assert!(e.glow.is_none() && e.shadow.is_some() && e.soft_edge == Some(5.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(effects(&s).0.glow.map(|g| g.size), Some(8.0));
|
||||
// Hostile numbers.
|
||||
run(&mut s, "shape.effects", json!({"shadow": {"blur": 1e30, "distance": -5, "angle": 1e20, "transparency": 400}, "softEdge": -3}));
|
||||
let (e, _) = effects(&s);
|
||||
let sh = e.shadow.unwrap();
|
||||
assert!(sh.blur == MAX_BLUR && sh.distance == 0.0 && (0.0..360.0).contains(&sh.angle) && sh.transparency == 100.0, "{sh:?}");
|
||||
assert!(e.soft_edge.is_none());
|
||||
run(&mut s, "shape.effects", json!({"shadow": {"distance": 1e9}}));
|
||||
assert_eq!(effects(&s).0.shadow.map(|s| s.distance), Some(MAX_DISTANCE));
|
||||
assert!(s.run("shape.effects", &json!({"shadow": "perspective"})).is_err());
|
||||
// Off a shape it's disabled.
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
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");
|
||||
}
|
||||
@@ -66,6 +66,8 @@ pub enum Draw {
|
||||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
stroke_width: f32,
|
||||
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
|
||||
effects: wordcraft_doc::effects::ShapeEffects,
|
||||
},
|
||||
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
|
||||
Path {
|
||||
@@ -211,8 +213,8 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
|
||||
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }),
|
||||
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
|
||||
Placed::Image { rect, media, crop, .. } => out.push(Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }),
|
||||
Placed::Shape { rect, kind, fill, stroke, stroke_width } => {
|
||||
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
|
||||
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
|
||||
}
|
||||
Placed::Graphic { rect, graphic, .. } => out.extend(graphic_draws(doc, graphic, *rect, alpha)),
|
||||
Placed::Cell { .. } | Placed::Object { .. } => {}
|
||||
@@ -546,8 +548,25 @@ fn lines(
|
||||
let rect = inline_rect(obj, cx, base, c.adv, c.obj_h);
|
||||
match obj {
|
||||
Some(InlineObject::Image { media, crop, .. }) => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
|
||||
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, .. }) => {
|
||||
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
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::Graphic { graphic, alt, .. }) => {
|
||||
out.push(Draw::Figure { alt: alt.clone(), kind: graphic.kind, draws: graphic_draws(doc, graphic, rect, alpha) })
|
||||
@@ -722,9 +741,14 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec<Dra
|
||||
let mut out = Vec::with_capacity(g.items.len());
|
||||
for it in &g.items {
|
||||
match it {
|
||||
GraphicItem::Shape { rect: r, kind, fill, stroke, stroke_width } => {
|
||||
out.push(Draw::Shape { rect: inside(r), kind: *kind, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k })
|
||||
}
|
||||
GraphicItem::Shape { rect: r, kind, fill, stroke, stroke_width } => out.push(Draw::Shape {
|
||||
rect: inside(r),
|
||||
kind: *kind,
|
||||
fill: *fill,
|
||||
stroke: *stroke,
|
||||
stroke_width: stroke_width * k,
|
||||
effects: Default::default(),
|
||||
}),
|
||||
GraphicItem::Path { segs, fill, stroke, stroke_width } => {
|
||||
let segs = page_segs(segs, |x, y| (rect.x + x * sx, rect.y + y * sy));
|
||||
out.push(Draw::Path { segs, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k })
|
||||
@@ -867,9 +891,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::Graphic { 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))
|
||||
}
|
||||
@@ -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", "é"]);
|
||||
}
|
||||
}
|
||||
+51
-41
@@ -11,6 +11,7 @@
|
||||
pub mod display;
|
||||
pub mod fields;
|
||||
pub mod hit;
|
||||
pub mod kinsoku;
|
||||
pub mod math;
|
||||
pub mod para;
|
||||
mod table;
|
||||
@@ -96,6 +97,7 @@ pub enum Placed {
|
||||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
stroke_width: f32,
|
||||
effects: wordcraft_doc::effects::ShapeEffects,
|
||||
},
|
||||
/// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at
|
||||
/// byte `off` of paragraph `path` (its alt text).
|
||||
@@ -1143,14 +1145,7 @@ struct PageFrame<'a> {
|
||||
|
||||
/// A floating (not inline) picture, chart or shape: its size and placement.
|
||||
fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> {
|
||||
match o {
|
||||
InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { 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.
|
||||
@@ -1213,20 +1208,26 @@ 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, .. } => {
|
||||
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
|
||||
vec![Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects }]
|
||||
}
|
||||
InlineObject::Graphic { graphic, .. } => Some(Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off }),
|
||||
_ => None,
|
||||
InlineObject::Graphic { graphic, .. } => vec![Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off }],
|
||||
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`.
|
||||
@@ -1304,23 +1305,19 @@ fn place_objects(
|
||||
if left_out(oi) {
|
||||
continue;
|
||||
}
|
||||
let Some(
|
||||
obj @ (InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { 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) => {
|
||||
@@ -1337,16 +1334,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).items;
|
||||
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).items;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1357,12 +1368,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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1803,10 +1816,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::Graphic { 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).
|
||||
|
||||
+10
-10
@@ -732,10 +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::Graphic { 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 };
|
||||
@@ -824,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);
|
||||
}
|
||||
|
||||
+97
-38
@@ -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::{Block, Blocks, Paragraph, 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, row band) and cell index in the row.
|
||||
region: (bool, 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: Region,
|
||||
ci: usize,
|
||||
/// Top margin plus top border band.
|
||||
top: f32,
|
||||
@@ -143,27 +144,20 @@ fn measure_para(p: &wordcraft_doc::Paragraph, env: &crate::para::ParaEnv) -> (f3
|
||||
pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f32, f32)> {
|
||||
use wordcraft_doc::Block;
|
||||
let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id));
|
||||
let parts = style.as_ref().map(|s| &s.parts);
|
||||
let margins_def = default_margins(t, style.as_ref());
|
||||
let ncols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS);
|
||||
let fields = crate::FieldCtx::default();
|
||||
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;
|
||||
let nrows = t.rows.len();
|
||||
let mut cols = vec![(0.0f32, 0.0f32); ncols];
|
||||
// Cells spanning several columns: (first column, span, min, max), applied after the others.
|
||||
let mut spanned: Vec<(usize, usize, f32, f32)> = Vec::new();
|
||||
let mut budget = MEASURE_BUDGET;
|
||||
'rows: for (ri, row) in t.rows.iter().enumerate() {
|
||||
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.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2);
|
||||
let mut g = 0usize;
|
||||
for cell in &row.cells {
|
||||
let span = cell.span();
|
||||
let margins = cell.props.margins.unwrap_or(margins_def);
|
||||
let side = margins[1].max(0.0) + margins[3].max(0.0);
|
||||
let text = style.as_ref().map(|st| region_text(st, (is_header, is_total, t.props.look.first_column && g == 0, band)));
|
||||
let text = style.as_ref().map(|st| region_text(st, region_of(t, style.as_ref(), ri, g, span, ncols)));
|
||||
let env = crate::para::ParaEnv {
|
||||
doc,
|
||||
width: MEASURE_WIDE,
|
||||
@@ -261,6 +255,48 @@ 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);
|
||||
@@ -362,15 +398,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let is_header = header_rows && ri == 0;
|
||||
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let band = banded(t, style.as_ref(), ri);
|
||||
// The header row's or the odd band's own cell borders (conditional formatting).
|
||||
let region_borders = if is_header {
|
||||
parts.and_then(|p| p.header_borders)
|
||||
} else if band {
|
||||
parts.and_then(|p| p.band_borders)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.unwrap_or_default();
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
let span = cell.span();
|
||||
let x0 = colx.get(g).copied().unwrap_or(acc);
|
||||
@@ -378,15 +405,31 @@ 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 = region_of(t, style.as_ref(), ri, g);
|
||||
let region = region_of(t, style.as_ref(), ri, g, span, ncols);
|
||||
let (_, _, first_col, last_col, _, col_band) = region;
|
||||
// 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)
|
||||
@@ -402,8 +445,10 @@ 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 rb = region_borders;
|
||||
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.or(rb.top), ri == 0, tb.top, tb.between),
|
||||
bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between),
|
||||
@@ -412,7 +457,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
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
|
||||
@@ -763,12 +811,16 @@ fn split_rect(r: Rect, cut: f32, shift: f32) -> (Option<Rect>, Option<Rect>) {
|
||||
}
|
||||
|
||||
/// A cell's region for table-style conditional formatting: (header row, total row, first column,
|
||||
/// banded row).
|
||||
type Region = (bool, bool, bool, bool);
|
||||
/// last column, banded row, banded column).
|
||||
type Region = (bool, bool, bool, bool, bool, bool);
|
||||
|
||||
fn region_of(t: &Table, style: Option<&TableStyleProps>, ri: usize, g: usize) -> Region {
|
||||
/// The region of the cell at grid column `g` spanning `span` of `ncols` columns in row `ri`.
|
||||
fn region_of(t: &Table, style: Option<&TableStyleProps>, ri: usize, g: usize, span: usize, ncols: usize) -> Region {
|
||||
let (look, nrows) = (&t.props.look, t.rows.len());
|
||||
(look.header_row && ri == 0, look.total_row && ri + 1 == nrows && nrows > 1, look.first_column && g == 0, banded(t, style, ri))
|
||||
let first_col = look.first_column && g == 0;
|
||||
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
|
||||
let col_band = look.banded_columns && !first_col && g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
(look.header_row && ri == 0, look.total_row && ri + 1 == nrows && nrows > 1, first_col, last_col, banded(t, style, ri), col_band)
|
||||
}
|
||||
|
||||
/// Whether row `ri` is in an odd band of the table style's banded rows (bands of the style's
|
||||
@@ -779,16 +831,23 @@ fn banded(t: &Table, style: Option<&TableStyleProps>, ri: usize) -> bool {
|
||||
look.banded_rows && !(look.header_row && ri == 0) && (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2)
|
||||
}
|
||||
|
||||
/// The table style's text formatting in `region`: the whole table's, then the band's, the
|
||||
/// column's and the row's (later regions win, ECMA-376 §17.7.6).
|
||||
fn region_text(st: &TableStyleProps, (header, total, first_col, band): Region) -> CellText {
|
||||
/// The table style's text formatting in `region`: the whole table's, then the column band's, the
|
||||
/// row band's, the first/last column's and the header/total row's (later regions win,
|
||||
/// ECMA-376 §17.7.6).
|
||||
fn region_text(st: &TableStyleProps, (header, total, first_col, last_col, band, col_band): Region) -> CellText {
|
||||
let mut chr = st.chr.clone();
|
||||
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 header {
|
||||
chr.overlay(&st.parts.header_chr);
|
||||
} else if total {
|
||||
@@ -810,7 +869,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
for cell in &row.cells {
|
||||
let span = cell.span();
|
||||
if cell.props.vmerge != VMerge::Continue {
|
||||
let text = style.map(|st| region_text(st, region_of(t, style, ri, g)));
|
||||
let text = style.map(|st| region_text(st, region_of(t, style, ri, g, cell.span(), cols.len())));
|
||||
let (lo, hi) = blocks_content(ctx, &cell.blocks, text.as_ref(), depth);
|
||||
any |= hi > 0.0;
|
||||
let m = cell.props.margins.unwrap_or(margins_def);
|
||||
|
||||
@@ -231,6 +231,7 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() {
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let obj = wordcraft_doc::para::OBJ.to_string();
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default());
|
||||
@@ -1046,6 +1047,7 @@ fn text_wraps_around_square_float() {
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
@@ -1083,6 +1085,7 @@ fn deleted_float_leaves_no_wrap_area_without_markup() {
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let obj = wordcraft_doc::para::OBJ.len_utf8();
|
||||
@@ -1134,6 +1137,7 @@ fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() {
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let obj = wordcraft_doc::para::OBJ.len_utf8();
|
||||
@@ -1170,6 +1174,7 @@ fn line_numbers_borders_text_boxes() {
|
||||
stroke_width: 1.0,
|
||||
float: Default::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(2, 5), tb, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
@@ -1463,6 +1468,7 @@ fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: w
|
||||
stroke_width: 0.75,
|
||||
float,
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(pos, shape, &Default::default()).unwrap();
|
||||
id
|
||||
@@ -1533,6 +1539,7 @@ fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() {
|
||||
stroke_width: 1.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
@@ -1752,6 +1759,7 @@ fn box_fan_out(levels: usize, fan: usize) -> Document {
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
let next = *ids.get(k + 1).unwrap_or(id);
|
||||
@@ -1815,7 +1823,17 @@ fn a_text_box_inside_a_text_box_still_shows_its_text() {
|
||||
}
|
||||
|
||||
fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
|
||||
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
|
||||
InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w,
|
||||
h,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1953,7 +1971,17 @@ fn lines_with_tabs_never_shrink() {
|
||||
}
|
||||
|
||||
fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
|
||||
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
|
||||
InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w,
|
||||
h,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn shapes(items: &[Placed]) -> Vec<Rect> {
|
||||
@@ -2121,6 +2149,7 @@ fn floating_header_text_box_leaves_the_body_at_the_top_margin() {
|
||||
stroke_width: 0.75,
|
||||
float,
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
anchor.insert_object(0, tb, &Default::default()).unwrap();
|
||||
}
|
||||
@@ -2680,3 +2709,25 @@ fn a_split_row_moves_shading_below_the_cut_with_it() {
|
||||
let nested = moved.get(1).copied().unwrap_or_default();
|
||||
assert!(nested.0 > 0.0 && (nested.1 - 50.0).abs() < 0.01, "the nested shading keeps its place and size: {moved:?}");
|
||||
}
|
||||
|
||||
#[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:?}");
|
||||
}
|
||||
+58
-1
@@ -321,6 +321,46 @@ fn rect_path(r: &Rect) -> Option<Path> {
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// `r` grown by `g` points on every side and moved by (`dx`, `dy`).
|
||||
fn grown(r: &Rect, g: f32, dx: f32, dy: f32) -> Rect {
|
||||
Rect { x: r.x - g + dx, y: r.y - g + dy, w: r.w + 2.0 * g, h: r.h + 2.0 * g }
|
||||
}
|
||||
|
||||
/// A shape's shadow and glow, behind it: bands of its silhouette (the same approximation of a
|
||||
/// blur as the raster renderer, see [`wordcraft_doc::effects::bands`]).
|
||||
fn shape_effects(s: &mut Surface, kind: ShapeKind, rect: &Rect, fx: &wordcraft_doc::effects::ShapeEffects, filled: bool, stroked: bool, sw: f32) {
|
||||
let sw = if sw.is_finite() { sw.clamp(0.25, 200.0) } else { 0.75 };
|
||||
let silhouette = |s: &mut Surface, c: Rgb, a: f32, g: f32, dx: f32, dy: f32| {
|
||||
if filled {
|
||||
let grow = g + if stroked { sw / 2.0 } else { 0.0 };
|
||||
if let Some(p) = shape_path(kind, &grown(rect, grow, dx, dy)) {
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(fill(c, a)));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
} else if sw + 2.0 * g > 0.05
|
||||
&& let Some(p) = shape_path(kind, &grown(rect, 0.0, dx, dy))
|
||||
{
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke { paint: rgb::Color::new(c.0, c.1, c.2).into(), width: sw + 2.0 * g, opacity: norm(a), ..Default::default() }));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
};
|
||||
if let Some(sh) = fx.shadow {
|
||||
let (dx, dy) = sh.offset();
|
||||
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
|
||||
silhouette(s, sh.color, a, g, dx, dy);
|
||||
}
|
||||
}
|
||||
if let Some(gl) = fx.glow {
|
||||
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
|
||||
silhouette(s, gl.color, a, g, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
|
||||
/// Outline of a basic shape in a rectangle (same geometry as the raster renderer).
|
||||
fn shape_path(kind: ShapeKind, r: &Rect) -> Option<Path> {
|
||||
if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || r.w <= 0.0 || r.h <= 0.0 {
|
||||
@@ -704,9 +744,26 @@ impl Exporter<'_> {
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width } => {
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects } => {
|
||||
let Some(p) = shape_path(*kind, rect) else { return };
|
||||
let can_fill = *kind != ShapeKind::Line;
|
||||
let fx = effects.sanitized();
|
||||
if !fx.is_empty() && (f.is_some() && can_fill || stroke.is_some()) {
|
||||
shape_effects(s, *kind, rect, &fx, f.is_some() && can_fill, stroke.is_some(), *stroke_width);
|
||||
}
|
||||
// Soft edges: the fill fades out toward the outline (which fades with it).
|
||||
if let (Some(rad), Some(c), true) = (fx.soft_edge, f, can_fill) {
|
||||
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
s.set_stroke(None);
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0)) {
|
||||
s.set_fill(Some(fill(*c, a)));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
}
|
||||
s.set_fill(None);
|
||||
return;
|
||||
}
|
||||
s.set_fill(f.filter(|_| can_fill).map(|c| fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke {
|
||||
paint: rgb::Color::new(c.0, c.1, c.2).into(),
|
||||
|
||||
+100
-1
@@ -639,10 +639,54 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width } => {
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
let path = shape_path(*kind, r);
|
||||
ctx.set_transform(view);
|
||||
let fx = effects.sanitized();
|
||||
let can_fill = *kind != ShapeKind::Line && fill.is_some();
|
||||
if !fx.is_empty() && (can_fill || stroke.is_some()) {
|
||||
let sw = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) as f64 } else { 0.75 };
|
||||
// The shape's silhouette grown by `g` points and moved by `d`, in the current paint.
|
||||
let silhouette = |ctx: &mut RenderContext, g: f32, d: kurbo::Vec2| {
|
||||
let g = g as f64;
|
||||
if can_fill {
|
||||
let grow = g + if stroke.is_some() { sw / 2.0 } else { 0.0 };
|
||||
let rr = r.inflate(grow, grow) + d;
|
||||
if rr.width() > 0.0 && rr.height() > 0.0 {
|
||||
ctx.fill_path(&shape_path(*kind, rr));
|
||||
}
|
||||
} else if sw + 2.0 * g > 0.05 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(sw + 2.0 * g));
|
||||
ctx.stroke_path(&shape_path(*kind, r + d));
|
||||
}
|
||||
};
|
||||
if let Some(sh) = fx.shadow {
|
||||
let (dx, dy) = sh.offset();
|
||||
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
|
||||
ctx.set_paint(color(opts.ink(sh.color), a));
|
||||
silhouette(ctx, g, kurbo::Vec2::new(dx as f64, dy as f64));
|
||||
}
|
||||
}
|
||||
if let Some(gl) = fx.glow {
|
||||
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
|
||||
ctx.set_paint(color(opts.ink(gl.color), a));
|
||||
silhouette(ctx, g, kurbo::Vec2::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Soft edges: the fill fades out toward the outline (which fades with it).
|
||||
if let (Some(rad), Some(f), true) = (fx.soft_edge, fill, can_fill) {
|
||||
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
let rr = r.inflate(g as f64, g as f64);
|
||||
if rr.width() > 0.0 && rr.height() > 0.0 {
|
||||
ctx.set_paint(color(opts.ink(*f), a));
|
||||
ctx.fill_path(&shape_path(*kind, rr));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
ctx.set_paint(color(opts.ink(*f), 1.0));
|
||||
ctx.fill_path(&path);
|
||||
@@ -774,6 +818,61 @@ mod tests {
|
||||
assert!(ink(TextDirection::Up) > 30, "up");
|
||||
}
|
||||
|
||||
/// Shape Effects (#275): an outer shadow paints dark pixels beside the shape on the side it is
|
||||
/// cast to, and none on the other; a glow surrounds it.
|
||||
#[test]
|
||||
fn shape_shadow_is_cast_offset_from_the_shape() {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
let draw = |effects: ShapeEffects| {
|
||||
let mut d = Document::from_text("");
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 100.0,
|
||||
h: 60.0,
|
||||
fill: Some(Rgb(0, 0, 255)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
effects,
|
||||
};
|
||||
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 };
|
||||
d.insert_object(&at, shape, &Default::default()).unwrap();
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
render_page(&d, &l.pages[0], 1.0, &RenderOptions::default())
|
||||
};
|
||||
let plain = draw(ShapeEffects::default());
|
||||
let blue = |img: &Rendered| {
|
||||
let px: Vec<(u32, u32)> = (0..612)
|
||||
.flat_map(|x| (0..400).map(move |y| (x, y)))
|
||||
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, _, b, _] if r < 40 && b > 200))
|
||||
.collect();
|
||||
let (x0, x1) = (px.iter().map(|p| p.0).min().unwrap(), px.iter().map(|p| p.0).max().unwrap());
|
||||
let (y0, y1) = (px.iter().map(|p| p.1).min().unwrap(), px.iter().map(|p| p.1).max().unwrap());
|
||||
(x0, y0, x1, y1)
|
||||
};
|
||||
let (x0, y0, x1, y1) = blue(&plain);
|
||||
assert!(x1 - x0 > 90 && y1 - y0 > 50, "{:?}", (x0, y0, x1, y1));
|
||||
// Grey (not white, not blue) pixels in a strip just outside an edge.
|
||||
let grey = |img: &Rendered, xs: std::ops::Range<u32>, ys: std::ops::Range<u32>| {
|
||||
xs.flat_map(|x| ys.clone().map(move |y| (x, y)))
|
||||
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, g, b, _] if r < 200 && r == g && g == b))
|
||||
.count()
|
||||
};
|
||||
let right = |img: &Rendered| grey(img, x1 + 2..x1 + 6, y0 + 10..y1);
|
||||
let left = |img: &Rendered| grey(img, x0.saturating_sub(6)..x0.saturating_sub(2), y0 + 10..y1);
|
||||
assert_eq!((right(&plain), left(&plain)), (0, 0));
|
||||
let shadow = Shadow { color: Rgb::BLACK, transparency: 30.0, blur: 2.0, distance: 8.0, angle: 0.0 };
|
||||
let img = draw(ShapeEffects { shadow: Some(shadow), ..Default::default() });
|
||||
assert_eq!(blue(&img), (x0, y0, x1, y1), "the shape itself is unchanged");
|
||||
assert!(right(&img) > 100, "shadow to the right: {}", right(&img));
|
||||
assert_eq!(left(&img), 0, "nothing on the left");
|
||||
let img = draw(ShapeEffects { glow: Some(Glow { color: Rgb(255, 0, 0), size: 6.0, transparency: 0.0 }), ..Default::default() });
|
||||
let reddish = |x: u32, y: u32| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 200);
|
||||
assert!(reddish(x1 + 2, (y0 + y1) / 2) && reddish(x0 - 2, (y0 + y1) / 2), "glow on both sides");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stem_darkening_follows_the_macos_curve() {
|
||||
assert_eq!(stem_darkening(0.0), 0.0);
|
||||
|
||||
+457
-76
@@ -1,8 +1,9 @@
|
||||
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Table Properties,
|
||||
//! 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.
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Table
|
||||
//! Properties, 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 +46,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 +99,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,6 +116,12 @@ 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.
|
||||
@@ -108,13 +129,13 @@ pub enum Dialog {
|
||||
name: String,
|
||||
/// Style id.
|
||||
based_on: String,
|
||||
/// Index into `regions`: whole table, header row, banded rows.
|
||||
/// Index into `regions` (see [`REGION_LABELS`]).
|
||||
region: usize,
|
||||
regions: Box<[TableRegion; 3]>,
|
||||
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; 3]>,
|
||||
basis: Box<[TableRegion; 7]>,
|
||||
},
|
||||
Commands {
|
||||
query: String,
|
||||
@@ -352,8 +373,11 @@ pub struct TableRegion {
|
||||
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; 3]> {
|
||||
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();
|
||||
@@ -362,17 +386,136 @@ fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
|
||||
_ => String::new(),
|
||||
};
|
||||
let lines = |b: Option<wordcraft_doc::props::Borders>| b.is_some_and(|b| b.any_visible());
|
||||
let mut header_chr = st.chr.clone();
|
||||
header_chr.overlay(&p.header_chr);
|
||||
let mut band_chr = st.chr.clone();
|
||||
band_chr.overlay(&p.band_chr);
|
||||
// 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([
|
||||
TableRegion { borders: lines(p.borders), fill: hex(p.fill), bold: st.chr.bold.unwrap_or(false), color: text(&st.chr) },
|
||||
TableRegion { borders: lines(p.header_borders), fill: hex(p.header_fill), bold: header_chr.bold.unwrap_or(false), color: text(&header_chr) },
|
||||
TableRegion { borders: lines(p.band_borders), fill: hex(p.band_fill), bold: band_chr.bold.unwrap_or(false), color: text(&band_chr) },
|
||||
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;
|
||||
@@ -446,6 +589,7 @@ 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",
|
||||
@@ -499,7 +643,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 {
|
||||
@@ -520,6 +664,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 {
|
||||
@@ -548,7 +695,8 @@ 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;
|
||||
@@ -630,6 +778,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,
|
||||
@@ -702,6 +851,7 @@ 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",
|
||||
@@ -835,48 +985,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
|
||||
}
|
||||
@@ -1113,7 +1309,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);
|
||||
@@ -1124,12 +1320,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);
|
||||
@@ -1163,8 +1364,12 @@ 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
|
||||
@@ -1192,12 +1397,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Apply formatting to:"));
|
||||
let names = ["Whole Table", "Header Row", "Banded Rows"];
|
||||
egui::ComboBox::from_id_salt("tstyle_region")
|
||||
.width(200.0)
|
||||
.selected_text(tl!(names.get(*region).copied().unwrap_or("Whole Table")))
|
||||
.selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table")))
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
for (i, n) in REGION_LABELS.iter().enumerate() {
|
||||
ui.selectable_value(region, i, tl!(n));
|
||||
}
|
||||
});
|
||||
@@ -1224,24 +1428,32 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
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 mut v = json!({"name": name.trim(), "basedOn": based_on});
|
||||
for (i, key) in ["wholeTable", "headerRow", "bandedRows"].into_iter().enumerate() {
|
||||
if let (Some(r), Some(b)) = (regions.get(i), basis.get(i)) {
|
||||
let ch = region_changes(r, b);
|
||||
if ch.as_object().is_some_and(|o| !o.is_empty()) {
|
||||
v[key] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
let cmd = match id {
|
||||
Some(sid) => {
|
||||
v["style"] = json!(sid);
|
||||
"table.modifyStyle"
|
||||
}
|
||||
None => "table.newStyle",
|
||||
};
|
||||
let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" };
|
||||
if let Err(e) = app.run(cmd, v) {
|
||||
app.status(e);
|
||||
return false;
|
||||
@@ -1677,6 +1889,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::*;
|
||||
@@ -1695,7 +2076,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")
|
||||
|
||||
@@ -23,7 +23,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
|
||||
@@ -46,13 +49,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
|
||||
@@ -134,6 +141,14 @@
|
||||
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
|
||||
@@ -152,6 +167,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
|
||||
@@ -329,6 +345,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
|
||||
@@ -385,6 +402,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
|
||||
@@ -765,6 +783,7 @@
|
||||
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
|
||||
@@ -1045,3 +1064,42 @@
|
||||
Leave the equation (Esc) Salir de la ecuación (Esc)
|
||||
Preview Vista previa
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects Efectos de forma
|
||||
Glow Resplandor
|
||||
Soft Edges Bordes suaves
|
||||
No Shadow Sin sombra
|
||||
No Glow Sin resplandor
|
||||
No Soft Edges Sin bordes suaves
|
||||
Glow Colors Colores de resplandor
|
||||
Weight Grosor
|
||||
Shadow, glow and soft edges Sombra, resplandor y bordes suaves
|
||||
Offset: Bottom Right Desplazamiento: abajo a la derecha
|
||||
Offset: Bottom Desplazamiento: abajo
|
||||
Offset: Bottom Left Desplazamiento: abajo a la izquierda
|
||||
Offset: Right Desplazamiento: derecha
|
||||
Offset: Center Desplazamiento: centro
|
||||
Offset: Left Desplazamiento: izquierda
|
||||
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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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 自動調整
|
||||
@@ -53,9 +60,12 @@
|
||||
AutoSave documents you have saved in WordCraft WordCraft で保存した文書を自動保存する
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動保存できませんでした: {error}。文書を保存し直すと、自動保存が再開します。
|
||||
Bottom Margin 下余白
|
||||
Compress punctuation at the start of a line 行頭の約物を詰める
|
||||
Dark page (white text on black) ダーク ページ (黒地に白い文字)
|
||||
Edit Comment コメントを編集
|
||||
Left Margin 左余白
|
||||
Indents and Spacing インデントと間隔
|
||||
Line breaking 行の分割
|
||||
More templates → その他のテンプレート →
|
||||
No templates match your search. 検索に一致するテンプレートはありません。
|
||||
Reopen 再度開く
|
||||
@@ -126,6 +136,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 選択範囲を縮小
|
||||
@@ -745,6 +764,7 @@
|
||||
Underline Words Only 単語のみ下線
|
||||
Undo 元に戻す
|
||||
Undo {action} 元に戻す: {action}
|
||||
Ungroup グループ解除
|
||||
Up 上
|
||||
Update Field フィールドの更新
|
||||
Update Index 索引の更新
|
||||
@@ -898,7 +918,6 @@
|
||||
Clear Paragraph Formatting 段落の書式をクリア
|
||||
Clear Character Style 文字スタイルをクリア
|
||||
Clear Character Formatting 文字の書式をクリア
|
||||
Clear All すべてクリア
|
||||
Paste Special 形式を選択して貼り付け
|
||||
Paste Special… 形式を選択して貼り付け…
|
||||
Paste as: 貼り付ける形式:
|
||||
@@ -1029,3 +1048,42 @@
|
||||
Leave the equation (Esc) 数式を終了 (Esc)
|
||||
Preview プレビュー
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects 図形の効果
|
||||
Glow 光彩
|
||||
Soft Edges 輪郭のぼかし
|
||||
No Shadow 影なし
|
||||
No Glow 光彩なし
|
||||
No Soft Edges ぼかしなし
|
||||
Glow Colors 光彩の色
|
||||
Weight 太さ
|
||||
Shadow, glow and soft edges 影、光彩、輪郭のぼかし
|
||||
Offset: Bottom Right オフセット: 右下
|
||||
Offset: Bottom オフセット: 下
|
||||
Offset: Bottom Left オフセット: 左下
|
||||
Offset: Right オフセット: 右
|
||||
Offset: Center オフセット: 中央
|
||||
Offset: Left オフセット: 左
|
||||
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 表スタイルの削除
|
||||
|
Can't render this file because it contains an unexpected character in line 439 and column 13.
|
@@ -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
|
||||
@@ -124,6 +131,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
|
||||
@@ -142,6 +158,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
|
||||
@@ -186,6 +203,8 @@
|
||||
Design Design
|
||||
Edit Comment Editar comentário
|
||||
Left Margin Margem esquerda
|
||||
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.
|
||||
Reopen Reabrir
|
||||
@@ -745,6 +764,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
|
||||
@@ -910,7 +930,6 @@
|
||||
Clear Paragraph Formatting Limpar formatação do parágrafo
|
||||
Clear Character Style Limpar estilo de caractere
|
||||
Clear Character Formatting Limpar formatação de caractere
|
||||
Clear All Limpar tudo
|
||||
Paste Special Colar especial
|
||||
Paste Special… Colar especial…
|
||||
Paste as: Colar como:
|
||||
@@ -1041,3 +1060,42 @@
|
||||
Leave the equation (Esc) Sair da equação (Esc)
|
||||
Preview Visualizar
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects Efeitos de forma
|
||||
Glow Brilho
|
||||
Soft Edges Bordas suaves
|
||||
No Shadow Sem sombra
|
||||
No Glow Sem brilho
|
||||
No Soft Edges Sem bordas suaves
|
||||
Glow Colors Cores do brilho
|
||||
Weight Espessura
|
||||
Shadow, glow and soft edges Sombra, brilho e bordas suaves
|
||||
Offset: Bottom Right Deslocamento: inferior direito
|
||||
Offset: Bottom Deslocamento: inferior
|
||||
Offset: Bottom Left Deslocamento: inferior esquerdo
|
||||
Offset: Right Deslocamento: direita
|
||||
Offset: Center Deslocamento: centro
|
||||
Offset: Left Deslocamento: esquerda
|
||||
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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -12,6 +12,7 @@
|
||||
# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters.
|
||||
|
||||
(no suggestions) (nema predloga)
|
||||
(picture or object) (slika ili objekat)
|
||||
1.5 Line Spacing Proređenost 1,5
|
||||
A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Besplatan, otvoreni obrađivač teksta napisan od nule u Rust-u.\nDeo Crafting Apps paketa tima ArtCraft.
|
||||
About O programu
|
||||
@@ -23,7 +24,10 @@
|
||||
Add Dodaj
|
||||
Add a comment Dodaj komentar
|
||||
Add a Table Dodaj tabelu
|
||||
Add Object to Selection Dodaj objekat u izbor
|
||||
Add Space Before Paragraph Dodaj razmak pre pasusa
|
||||
Add space between Asian and Latin text Dodaj razmak između azijskog i latiničnog teksta
|
||||
Add space between Asian text and numbers Dodaj razmak između azijskog teksta i brojeva
|
||||
Add Text Dodaj tekst
|
||||
Add to Dictionary Dodaj u rečnik
|
||||
Add to Style Gallery Dodaj u galeriju stilova
|
||||
@@ -42,7 +46,11 @@
|
||||
All Caps Sva velika slova
|
||||
All caps Sva velika slova
|
||||
All Markup Sve izmene
|
||||
Allow hanging punctuation Dozvoli viseću interpunkciju
|
||||
Allow Latin text to wrap in the middle of a word Dozvoli prelom latiničnog teksta usred reči
|
||||
Alphabetical Abecedno
|
||||
Alt Text Alternativni tekst
|
||||
Apply Asian line-breaking rules (kinsoku) Primeni azijska pravila preloma redova (kinsoku)
|
||||
Apply Character Style Primeni stil znakova
|
||||
Apply Style Primeni stil
|
||||
Apply Styles Izaberi i primeni stilove
|
||||
@@ -50,6 +58,8 @@
|
||||
Arrange\nAll Rasporedi\nsve
|
||||
Arrow Strelica
|
||||
Artistic Effects Umetnički efekti
|
||||
As Recommended Kao što je preporučeno
|
||||
Asian Typography Azijska tipografija
|
||||
Author Autor
|
||||
AutoCorrect Options Opcije automatske ispravke
|
||||
AutoFit Automatsko uklapanje
|
||||
@@ -64,11 +74,50 @@
|
||||
AutoSave documents you have saved in WordCraft Automatski čuvaj dokumente koje ste već sačuvali u WordCraft-u
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. Automatsko čuvanje neuspelo: {error}. Sačuvajte dokument da biste ga ponovo uključili.
|
||||
Bottom Margin Donja margina
|
||||
Based on: {style} Zasnovan na: {style}
|
||||
Built-in style Ugrađeni stil
|
||||
Built-in styles can't be deleted. Ugrađeni stilovi ne mogu da se obrišu.
|
||||
Character style Stil znakova
|
||||
Click an item to paste it. Kliknite na stavku da biste je nalepili.
|
||||
Clipboard Items Stavke ostave
|
||||
Clipboard Pane Okno ostave
|
||||
Color: {color} Boja: {color}
|
||||
Compress punctuation at the start of a line Sažmi interpunkciju na početku reda
|
||||
Dark page (white text on black) Tamna stranica (beo tekst na crnom)
|
||||
Edit Comment Uredi komentar
|
||||
Left Margin Leva margina
|
||||
Reopen Ponovo otvori
|
||||
Right Margin Desna margina
|
||||
Delete Clipboard Item Obriši stavku iz ostave
|
||||
Delete Table Style Obriši stil tabele
|
||||
Font: {font} Font: {font}
|
||||
Glow Sjaj
|
||||
Glow Colors Boje sjaja
|
||||
Hide from style lists Sakrij sa lista stilova
|
||||
Indents and Spacing Uvlačenja i razmaci
|
||||
Line breaking Prelom redova
|
||||
Linked style (paragraph and character) Povezani stil (pasus i znakovi)
|
||||
Manage Styles Upravljanje stilovima
|
||||
Manage Styles… Upravljanje stilovima…
|
||||
No Glow Bez sjaja
|
||||
No Shadow Bez senke
|
||||
No Soft Edges Bez mekih ivica
|
||||
Nothing collected yet. Items you copy or cut appear here. Još ništa nije prikupljeno. Ovde se pojavljuje ono što kopirate ili isečete.
|
||||
Offset: Bottom Pomak: dole
|
||||
Offset: Bottom Left Pomak: dole levo
|
||||
Offset: Bottom Right Pomak: dole desno
|
||||
Offset: Center Pomak: centar
|
||||
Offset: Left Pomak: levo
|
||||
Offset: Right Pomak: desno
|
||||
Offset: Top Pomak: gore
|
||||
Offset: Top Left Pomak: gore levo
|
||||
Offset: Top Right Pomak: gore desno
|
||||
Paragraph style Stil pasusa
|
||||
Paste All Nalepi sve
|
||||
Paste Clipboard Item Nalepi stavku iz ostave
|
||||
Preview Pregled
|
||||
Shadow, glow and soft edges Senka, sjaj i meke ivice
|
||||
Shape Effects Efekti oblika
|
||||
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Prikaži dokumente sa invertovanim bojama, kao kod Prikaz › Promeni režim. Čuvanje, štampanje i PDF nisu pogođeni.
|
||||
AutoSave saves every change to the file (needs a saved document) Automatsko čuvanje upisuje svaku izmenu u fajl (dokument mora biti već sačuvan)
|
||||
AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Automatsko čuvanje nije dostupno u pregledaču: ne može da upisuje u vaše fajlove. Čuvanje preuzima kopiju.
|
||||
@@ -646,8 +695,10 @@
|
||||
Show Prikaži
|
||||
Show all formatting marks Prikaži sve znake formatiranja
|
||||
Show Comments Prikaži komentare
|
||||
Show in the Styles gallery Prikaži u galeriji stilova
|
||||
Show Markup Prikaži izmene
|
||||
Show Notes Prikaži napomene
|
||||
Show or Hide Style Prikaži ili sakrij stil
|
||||
Show rulers Prikaži lenjire
|
||||
Show the community button in the title bar Prikaži dugme zajednice u traci naslova
|
||||
Show/Hide ¶ Prikaži/sakrij ¶
|
||||
@@ -663,9 +714,13 @@
|
||||
Small Caps Kapitelke
|
||||
Small caps kapitelke
|
||||
SmartArt SmartArt
|
||||
Soft Edges Meke ivice
|
||||
Sort Sortiraj
|
||||
Sort order: Redosled sortiranja:
|
||||
Source code: github.com/storytold/wordcraft Izvorni kod: github.com/storytold/wordcraft
|
||||
Space after: {pt} pt Razmak posle: {pt} pt
|
||||
Space before/after: Razmak pre/posle:
|
||||
Space before: {pt} pt Razmak pre: {pt} pt
|
||||
Spacing Razmak
|
||||
Spacing (pt): Razmak (pt):
|
||||
Speech Govor
|
||||
@@ -708,6 +763,7 @@
|
||||
Table of\nContents Sadržaj
|
||||
Table Properties Svojstva tabele
|
||||
Table Row Height Visina reda tabele
|
||||
Table style Stil tabele
|
||||
Table Style Options Opcije stila tabele
|
||||
Table Styles Stilovi tabele
|
||||
Tables Tabele
|
||||
@@ -753,6 +809,7 @@
|
||||
Underline Words Only Podvuci samo reči
|
||||
Undo Opozovi
|
||||
Undo {action} Opozovi {action}
|
||||
Ungroup Razgrupisanje
|
||||
Up Gore
|
||||
Update Field Ažuriraj polje
|
||||
Update Index Ažuriraj indeks
|
||||
@@ -778,6 +835,7 @@
|
||||
Web Layout Veb raspored
|
||||
Web page (*.html) Veb strana (*.html)
|
||||
Web\nLayout Veb\nraspored
|
||||
Weight Debljina
|
||||
Whole page Cela strana
|
||||
Whole words Samo cele reči
|
||||
Wide Široko
|
||||
@@ -802,6 +860,7 @@
|
||||
Zoom Out Umanji
|
||||
{group} settings Podešavanja za {group}
|
||||
{key}+Click to follow link {key}+klik za otvaranje veze
|
||||
{size} pt {size} pt
|
||||
Browse… Potraži…
|
||||
{words} words {words} reči
|
||||
{selected} of {words} words {selected} od {words} reči
|
||||
@@ -1038,5 +1097,4 @@
|
||||
Reset Crop Poništi isecanje
|
||||
Insert a new equation (Alt+=) Umetni novu jednačinu (Alt+=)
|
||||
Leave the equation (Esc) Napusti jednačinu (Esc)
|
||||
Preview Pregled
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -16,6 +16,7 @@
|
||||
# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters.
|
||||
|
||||
(no suggestions) (нема предлога)
|
||||
(picture or object) (слика или објекат)
|
||||
1.5 Line Spacing Проређеност 1,5
|
||||
A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Бесплатан, отворени обрађивач текста написан од нуле у Rust-у.\nДео Crafting Apps пакета тима ArtCraft.
|
||||
About О програму
|
||||
@@ -27,7 +28,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 Додај у галерију стилова
|
||||
@@ -46,7 +50,11 @@
|
||||
All Caps Сва велика слова
|
||||
All caps Сва велика слова
|
||||
All Markup Све измене
|
||||
Allow hanging punctuation Дозволи висећу интерпункцију
|
||||
Allow Latin text to wrap in the middle of a word Дозволи прелом латиничног текста усред речи
|
||||
Alphabetical Азбучно
|
||||
Alt Text Алтернативни текст
|
||||
Apply Asian line-breaking rules (kinsoku) Примени азијска правила прелома редова (кинсоку)
|
||||
Apply Character Style Примени стил знакова
|
||||
Apply Style Примени стил
|
||||
Apply Styles Изабери и примени стилове
|
||||
@@ -54,6 +62,8 @@
|
||||
Arrange\nAll Распореди\nсве
|
||||
Arrow Стрелица
|
||||
Artistic Effects Уметнички ефекти
|
||||
As Recommended Као што је препоручено
|
||||
Asian Typography Азијска типографија
|
||||
Author Аутор
|
||||
AutoCorrect Options Опције аутоматске исправке
|
||||
AutoFit Аутоматско уклапање
|
||||
@@ -68,11 +78,50 @@
|
||||
AutoSave documents you have saved in WordCraft Аутоматски чувај документе које сте већ сачували у WordCraft-у
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. Аутоматско чување неуспело: {error}. Сачувајте документ да бисте га поново укључили.
|
||||
Bottom Margin Доња маргина
|
||||
Based on: {style} Заснован на: {style}
|
||||
Built-in style Уграђени стил
|
||||
Built-in styles can't be deleted. Уграђени стилови не могу да се обришу.
|
||||
Character style Стил знакова
|
||||
Click an item to paste it. Кликните на ставку да бисте је налепили.
|
||||
Clipboard Items Ставке оставе
|
||||
Clipboard Pane Окно оставе
|
||||
Color: {color} Боја: {color}
|
||||
Compress punctuation at the start of a line Сажми интерпункцију на почетку реда
|
||||
Dark page (white text on black) Тамна страница (бео текст на црном)
|
||||
Edit Comment Уреди коментар
|
||||
Left Margin Лева маргина
|
||||
Reopen Поново отвори
|
||||
Right Margin Десна маргина
|
||||
Delete Clipboard Item Обриши ставку из оставе
|
||||
Delete Table Style Обриши стил табеле
|
||||
Font: {font} Фонт: {font}
|
||||
Glow Сјај
|
||||
Glow Colors Боје сјаја
|
||||
Hide from style lists Сакриј са листа стилова
|
||||
Indents and Spacing Увлачења и размаци
|
||||
Line breaking Прелом редова
|
||||
Linked style (paragraph and character) Повезани стил (пасус и знакови)
|
||||
Manage Styles Управљање стиловима
|
||||
Manage Styles… Управљање стиловима…
|
||||
No Glow Без сјаја
|
||||
No Shadow Без сенке
|
||||
No Soft Edges Без меких ивица
|
||||
Nothing collected yet. Items you copy or cut appear here. Још ништа није прикупљено. Овде се појављује оно што копирате или исечете.
|
||||
Offset: Bottom Помак: доле
|
||||
Offset: Bottom Left Помак: доле лево
|
||||
Offset: Bottom Right Помак: доле десно
|
||||
Offset: Center Помак: центар
|
||||
Offset: Left Помак: лево
|
||||
Offset: Right Помак: десно
|
||||
Offset: Top Помак: горе
|
||||
Offset: Top Left Помак: горе лево
|
||||
Offset: Top Right Помак: горе десно
|
||||
Paragraph style Стил пасуса
|
||||
Paste All Налепи све
|
||||
Paste Clipboard Item Налепи ставку из оставе
|
||||
Preview Преглед
|
||||
Shadow, glow and soft edges Сенка, сјај и меке ивице
|
||||
Shape Effects Ефекти облика
|
||||
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Прикажи документе са инвертованим бојама, као код Приказ › Промени режим. Чување, штампање и PDF нису погођени.
|
||||
AutoSave saves every change to the file (needs a saved document) Аутоматско чување уписује сваку измену у фајл (документ мора бити већ сачуван)
|
||||
AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Аутоматско чување није доступно у прегледачу: не може да уписује у ваше фајлове. Чување преузима копију.
|
||||
@@ -650,8 +699,10 @@
|
||||
Show Прикажи
|
||||
Show all formatting marks Прикажи све знаке форматирања
|
||||
Show Comments Прикажи коментаре
|
||||
Show in the Styles gallery Прикажи у галерији стилова
|
||||
Show Markup Прикажи измене
|
||||
Show Notes Прикажи напомене
|
||||
Show or Hide Style Прикажи или сакриј стил
|
||||
Show rulers Прикажи лењире
|
||||
Show the community button in the title bar Прикажи дугме заједнице у траци наслова
|
||||
Show/Hide ¶ Прикажи/сакриј ¶
|
||||
@@ -667,9 +718,13 @@
|
||||
Small Caps Капителке
|
||||
Small caps капителке
|
||||
SmartArt SmartArt
|
||||
Soft Edges Меке ивице
|
||||
Sort Сортирај
|
||||
Sort order: Редослед сортирања:
|
||||
Source code: github.com/storytold/wordcraft Изворни код: github.com/storytold/wordcraft
|
||||
Space after: {pt} pt Размак после: {pt} пт
|
||||
Space before/after: Размак пре/после:
|
||||
Space before: {pt} pt Размак пре: {pt} пт
|
||||
Spacing Размак
|
||||
Spacing (pt): Размак (пт):
|
||||
Speech Говор
|
||||
@@ -712,6 +767,7 @@
|
||||
Table of\nContents Садржај
|
||||
Table Properties Својства табеле
|
||||
Table Row Height Висина реда табеле
|
||||
Table style Стил табеле
|
||||
Table Style Options Опције стила табеле
|
||||
Table Styles Стилови табеле
|
||||
Tables Табеле
|
||||
@@ -757,6 +813,7 @@
|
||||
Underline Words Only Подвуци само речи
|
||||
Undo Опозови
|
||||
Undo {action} Опозови {action}
|
||||
Ungroup Разгруписање
|
||||
Up Горе
|
||||
Update Field Ажурирај поље
|
||||
Update Index Ажурирај индекс
|
||||
@@ -782,6 +839,7 @@
|
||||
Web Layout Веб распоред
|
||||
Web page (*.html) Веб страна (*.html)
|
||||
Web\nLayout Веб\nраспоред
|
||||
Weight Дебљина
|
||||
Whole page Цела страна
|
||||
Whole words Само целе речи
|
||||
Wide Широко
|
||||
@@ -806,6 +864,7 @@
|
||||
Zoom Out Умањи
|
||||
{group} settings Подешавања за {group}
|
||||
{key}+Click to follow link {key}+клик за отварање везе
|
||||
{size} pt {size} пт
|
||||
Browse… Потражи…
|
||||
{words} words {words} речи
|
||||
{selected} of {words} words {selected} од {words} речи
|
||||
@@ -1042,5 +1101,4 @@
|
||||
Reset Crop Поништи исецање
|
||||
Insert a new equation (Alt+=) Уметни нову једначину (Alt+=)
|
||||
Leave the equation (Esc) Напусти једначину (Esc)
|
||||
Preview Преглед
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -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 Додати до колекції стилів
|
||||
@@ -44,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 Добір розміру
|
||||
@@ -132,6 +139,14 @@
|
||||
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 Зняти виділення
|
||||
@@ -150,6 +165,7 @@
|
||||
Compact Компактний
|
||||
Compare Порівняти
|
||||
Compress Pictures Стиснути зображення
|
||||
Compress punctuation at the start of a line Стискати розділові знаки на початку рядка
|
||||
Continuous Безперервний
|
||||
Convert Перетворити
|
||||
Convert Text to Table Перетворити текст на таблицю
|
||||
@@ -194,6 +210,8 @@
|
||||
Design Дизайн
|
||||
Edit Comment Редагувати коментар
|
||||
Left Margin Ліве поле
|
||||
Indents and Spacing Відступи й інтервали
|
||||
Line breaking Розрив рядків
|
||||
More templates → Більше шаблонів →
|
||||
No templates match your search. Немає шаблонів, що відповідають пошуку.
|
||||
Reopen Відкрити знову
|
||||
@@ -771,6 +789,7 @@
|
||||
Underline Words Only Підкреслити лише слова
|
||||
Undo Скасувати
|
||||
Undo {action} Скасувати {action}
|
||||
Ungroup Розгрупувати
|
||||
Unformatted Text Неформатований текст
|
||||
Up Угору
|
||||
Update Field Оновити поле
|
||||
@@ -1041,3 +1060,42 @@
|
||||
Leave the equation (Esc) Вийти з рівняння (Esc)
|
||||
Preview Попередній перегляд
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects Ефекти фігури
|
||||
Glow Світіння
|
||||
Soft Edges М'які краї
|
||||
No Shadow Без тіні
|
||||
No Glow Без світіння
|
||||
No Soft Edges Без м'яких країв
|
||||
Glow Colors Кольори світіння
|
||||
Weight Товщина
|
||||
Shadow, glow and soft edges Тінь, світіння та м'які краї
|
||||
Offset: Bottom Right Зміщення: вниз праворуч
|
||||
Offset: Bottom Зміщення: вниз
|
||||
Offset: Bottom Left Зміщення: вниз ліворуч
|
||||
Offset: Right Зміщення: праворуч
|
||||
Offset: Center Зміщення: по центру
|
||||
Offset: Left Зміщення: ліворуч
|
||||
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 Видалити стиль таблиці
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -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 自动调整
|
||||
@@ -64,9 +71,12 @@
|
||||
AutoSave documents you have saved in WordCraft 自动保存在 WordCraft 中保存过的文档
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自动保存失败:{error}。重新保存文档后将恢复自动保存。
|
||||
Bottom Margin 下边距
|
||||
Compress punctuation at the start of a line 压缩行首标点
|
||||
Dark page (white text on black) 深色页面(黑底白字)
|
||||
Edit Comment 编辑批注
|
||||
Left Margin 左边距
|
||||
Indents and Spacing 缩进和间距
|
||||
Line breaking 断行
|
||||
More templates → 更多模板 →
|
||||
No templates match your search. 没有与搜索匹配的模板。
|
||||
Reopen 重新打开
|
||||
@@ -137,6 +147,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 折叠选定内容
|
||||
@@ -756,6 +775,7 @@
|
||||
Underline Words Only 仅给字词加下划线
|
||||
Undo 撤消
|
||||
Undo {action} 撤消{action}
|
||||
Ungroup 取消组合
|
||||
Up 上
|
||||
Update Field 更新域
|
||||
Update Index 更新索引
|
||||
@@ -909,7 +929,6 @@
|
||||
Clear Paragraph Formatting 清除段落格式
|
||||
Clear Character Style 清除字符样式
|
||||
Clear Character Formatting 清除字符格式
|
||||
Clear All 全部清除
|
||||
Paste Special 选择性粘贴
|
||||
Paste Special… 选择性粘贴…
|
||||
Paste as: 粘贴为:
|
||||
@@ -1040,3 +1059,42 @@
|
||||
Leave the equation (Esc) 退出公式 (Esc)
|
||||
Preview 预览
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects 形状效果
|
||||
Glow 发光
|
||||
Soft Edges 柔化边缘
|
||||
No Shadow 无阴影
|
||||
No Glow 无发光
|
||||
No Soft Edges 无柔化边缘
|
||||
Glow Colors 发光颜色
|
||||
Weight 粗细
|
||||
Shadow, glow and soft edges 阴影、发光和柔化边缘
|
||||
Offset: Bottom Right 偏移:右下
|
||||
Offset: Bottom 偏移:下
|
||||
Offset: Bottom Left 偏移:左下
|
||||
Offset: Right 偏移:右
|
||||
Offset: Center 偏移:居中
|
||||
Offset: Left 偏移:左
|
||||
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 删除表格样式
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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 自動調整
|
||||
@@ -54,9 +61,12 @@
|
||||
AutoSave documents you have saved in WordCraft 自動儲存在 WordCraft 中儲存過的文件
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動儲存失敗:{error}。重新儲存文件後將恢復自動儲存。
|
||||
Bottom Margin 下邊界
|
||||
Compress punctuation at the start of a line 壓縮行首標點
|
||||
Dark page (white text on black) 深色頁面(黑底白字)
|
||||
Edit Comment 編輯註解
|
||||
Left Margin 左邊界
|
||||
Indents and Spacing 縮排和間距
|
||||
Line breaking 斷行
|
||||
More templates → 更多範本 →
|
||||
No templates match your search. 沒有符合搜尋的範本。
|
||||
Reopen 重新開啟
|
||||
@@ -127,6 +137,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 更新索引
|
||||
@@ -899,7 +919,6 @@
|
||||
Clear Paragraph Formatting 清除段落格式設定
|
||||
Clear Character Style 清除字元樣式
|
||||
Clear Character Formatting 清除字元格式設定
|
||||
Clear All 全部清除
|
||||
Paste Special 選擇性貼上
|
||||
Paste Special… 選擇性貼上…
|
||||
Paste as: 貼上為:
|
||||
@@ -1030,3 +1049,42 @@
|
||||
Leave the equation (Esc) 離開方程式 (Esc)
|
||||
Preview 預覽
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects 圖案效果
|
||||
Glow 光暈
|
||||
Soft Edges 柔化邊緣
|
||||
No Shadow 無陰影
|
||||
No Glow 無光暈
|
||||
No Soft Edges 無柔化邊緣
|
||||
Glow Colors 光暈色彩
|
||||
Weight 粗細
|
||||
Shadow, glow and soft edges 陰影、光暈和柔化邊緣
|
||||
Offset: Bottom Right 位移:右下
|
||||
Offset: Bottom 位移:下
|
||||
Offset: Bottom Left 位移:左下
|
||||
Offset: Right 位移:右
|
||||
Offset: Center 位移:置中
|
||||
Offset: Left 位移:左
|
||||
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 刪除表格樣式
|
||||
|
Can't render this file because it contains an unexpected character in line 440 and column 13.
|
@@ -312,6 +312,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
||||
pen.rect(3.0, 3.0, 11.0, 11.0, c);
|
||||
pen.fcircle(13.0, 13.0, 4.5, a);
|
||||
}
|
||||
// Shape Effects: a square casting a soft shadow down and to the right.
|
||||
"shapeEffects" => {
|
||||
for (k, alpha) in [(2.0, 30), (1.0, 50), (0.0, 80)] {
|
||||
pen.frect(7.0 - k, 7.0 - k, 18.0 + k, 18.0 + k, Color32::from_rgba_unmultiplied(0, 0, 0, alpha));
|
||||
}
|
||||
pen.frect(3.0, 3.0, 14.0, 14.0, a);
|
||||
pen.rect(3.0, 3.0, 14.0, 14.0, c);
|
||||
}
|
||||
"icons" => {
|
||||
pen.circle(10.0, 10.0, 7.5, c);
|
||||
pen.fcircle(7.5, 8.0, 1.0, c);
|
||||
|
||||
@@ -94,6 +94,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() };
|
||||
@@ -161,6 +167,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)
|
||||
{
|
||||
@@ -238,6 +250,7 @@ mod tests {
|
||||
stroke_width: 1.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let (app, hit) = app_with(shape);
|
||||
assert!(!fixed(&app, &hit));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Side panes: Navigation (headings, pages, search results), Styles, Style Inspector, 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)
|
||||
@@ -146,6 +153,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}));
|
||||
@@ -168,6 +220,9 @@ 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!({}));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
+133
-15
@@ -14,6 +14,11 @@ pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool {
|
||||
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. })))
|
||||
}
|
||||
|
||||
/// Whether the selection is a shape or text box (so Shape Format is shown).
|
||||
pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool {
|
||||
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Shape { .. })))
|
||||
}
|
||||
|
||||
/// Contextual tabs for the current selection (pure, tested).
|
||||
pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
|
||||
let mut tabs = Vec::new();
|
||||
@@ -24,6 +29,9 @@ pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
|
||||
if has_picture_selected(s) {
|
||||
tabs.push("Picture Format");
|
||||
}
|
||||
if has_shape_selected(s) {
|
||||
tabs.push("Shape Format");
|
||||
}
|
||||
// Editing an equation.
|
||||
if s.math.is_some() {
|
||||
tabs.push("Equation");
|
||||
@@ -77,7 +85,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
for tab in tabs {
|
||||
let contextual = tab.starts_with("Table ") || tab == "Picture Format" || tab == "Equation";
|
||||
let contextual = tab.starts_with("Table ") || tab.ends_with(" Format") || tab == "Equation";
|
||||
let shown = tl!(tab);
|
||||
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
@@ -171,6 +179,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
"Table Layout" => table_layout(app, ui),
|
||||
"Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui),
|
||||
"Picture Format" => picture_format(app, ui),
|
||||
"Shape Format" => shape_format(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
});
|
||||
@@ -244,7 +253,7 @@ 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!({}));
|
||||
@@ -803,12 +812,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| {
|
||||
@@ -1103,6 +1120,83 @@ fn help(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Shape Format: fill, outline and effects of the selected shape, and its arrangement.
|
||||
fn shape_format(app: &mut WordApp, ui: &mut Ui) {
|
||||
let (stroke, glow_size) = match wordcraft_engine::cmd::objects::selected(&app.session) {
|
||||
Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, .. })) => (stroke, effects.glow.map(|g| g.size)),
|
||||
_ => (None, None),
|
||||
};
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
group(ui, "Shape Styles", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, |ui, app| {
|
||||
mi(ui, app, "No Color", "shape.fill", json!({"color": null}));
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("shape.fill", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, |ui, app| {
|
||||
mi(ui, app, "No Color", "shape.outline", json!({"color": null}));
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("shape.outline", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Weight"), |ui| {
|
||||
let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex();
|
||||
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
|
||||
mi(ui, app, &format!("{w} pt"), "shape.outline", json!({"color": color, "width": w}));
|
||||
}
|
||||
});
|
||||
});
|
||||
menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, |ui, app| {
|
||||
shape_effects_menu(ui, app, &theme, glow_size)
|
||||
});
|
||||
});
|
||||
});
|
||||
group(ui, "Arrange", None, app, |ui, app| {
|
||||
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
|
||||
big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry.
|
||||
fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option<f32>) {
|
||||
ui.menu_button(tl!("Shadow"), |ui| {
|
||||
mi(ui, app, "No Shadow", "shape.effects", json!({"shadow": null}));
|
||||
ui.separator();
|
||||
for (id, label) in wordcraft_doc::effects::SHADOW_PRESETS {
|
||||
mi(ui, app, label, "shape.effects", json!({"shadow": id}));
|
||||
}
|
||||
});
|
||||
ui.menu_button(tl!("Glow"), |ui| {
|
||||
mi(ui, app, "No Glow", "shape.effects", json!({"glow": null}));
|
||||
ui.separator();
|
||||
for size in [5.0, 8.0, 11.0, 18.0] {
|
||||
mi(ui, app, &format!("{size} pt"), "shape.effects", json!({"glow": size}));
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Glow Colors"), |ui| {
|
||||
if let Some(hex) = color_grid(ui, theme) {
|
||||
let _ = app.run("shape.effects", json!({"glow": {"color": hex, "size": glow_size.unwrap_or(8.0)}}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.menu_button(tl!("Soft Edges"), |ui| {
|
||||
mi(ui, app, "No Soft Edges", "shape.effects", json!({"softEdge": null}));
|
||||
ui.separator();
|
||||
for r in [1.0, 2.5, 5.0, 10.0, 25.0, 50.0] {
|
||||
mi(ui, app, &format!("{r} pt"), "shape.effects", json!({"softEdge": r}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn picture_format(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Adjust", None, app, |ui, app| {
|
||||
menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, |ui, app| {
|
||||
@@ -1158,6 +1252,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| {
|
||||
@@ -1255,18 +1350,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}));
|
||||
}
|
||||
@@ -1274,21 +1371,31 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
let can_modify = look.is_some()
|
||||
&& app
|
||||
.session
|
||||
.sel
|
||||
.focus
|
||||
.path
|
||||
.cell()
|
||||
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone())
|
||||
.is_some();
|
||||
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}));
|
||||
@@ -1464,6 +1571,17 @@ mod tests {
|
||||
assert!(!has_picture_selected(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_format_tab_appears_when_shape_selected() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
assert!(contextual_tabs(&s).contains(&"Shape Format"));
|
||||
assert!(!contextual_tabs(&s).contains(&"Picture Format"));
|
||||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert!(!contextual_tabs(&s).contains(&"Shape Format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_tab_falls_back_when_contextual_tab_expires() {
|
||||
let all = ["Home", "Table Design", "Picture Format"];
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
|
||||
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.
|
||||
|
||||
**384 of 430 catalog features have commands (89.3%).**
|
||||
**390 of 431 catalog features have commands (90.5%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
| File | 20 | 20 |
|
||||
| Quick Access | 4 | 4 |
|
||||
| Home | 95 | 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 | 7 | 7 |
|
||||
| Table Design | 8 | 8 |
|
||||
| Table Layout | 28 | 30 |
|
||||
| Equation | 21 | 22 |
|
||||
| Picture Format | 13 | 13 |
|
||||
| Shape Format | 2 | 6 |
|
||||
| Shape Format | 3 | 6 |
|
||||
| Header & Footer | 5 | 5 |
|
||||
| Editing | 24 | 24 |
|
||||
| Tools | 3 | 6 |
|
||||
@@ -30,11 +30,8 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
|
||||
### Home
|
||||
|
||||
- Clipboard › Clipboard Pane (`edit.clipboardPane`)
|
||||
- Font › Phonetic Guide (`format.phonetic`)
|
||||
- Font › Enclose Characters (`format.enclose`)
|
||||
- Paragraph › Asian Typography (`para.asianTypography`)
|
||||
- Styles › Manage Styles (`styles.manage`)
|
||||
- Voice › Dictate (`tools.dictate`)
|
||||
|
||||
### Insert
|
||||
@@ -65,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`)
|
||||
@@ -93,7 +86,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
|
||||
### Shape Format
|
||||
|
||||
- Shape Styles › Shape Effects (`shape.effects`)
|
||||
- Text › Text Direction (`shape.textDirection`)
|
||||
- Text › Align Text (`shape.alignText`)
|
||||
- Text › Create Link (`shape.link`)
|
||||
|
||||
@@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **384 / 430 (89.3%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
|
||||
| Ribbon/menu catalog coverage | **390 / 431 (90.5%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
|
||||
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
|
||||
| Feature depth (weighted by use, table below) | **~68%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |
|
||||
|
||||
Reference in new issue
Block a user