876 lines
41 KiB
Rust
876 lines
41 KiB
Rust
//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles).
|
||
|
||
use serde_json::{Value, json};
|
||
use wordcraft_doc::numbering::ListKind;
|
||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
|
||
use wordcraft_doc::styles::{Style, StyleKind};
|
||
use wordcraft_doc::{Block, Pos};
|
||
|
||
use super::sel_result;
|
||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
CommandSpec::new("para.alignLeft", "Align Left", "Home › Paragraph", |s, _| align(s, Align::Left)).key("Mod+L"),
|
||
CommandSpec::new("para.alignCenter", "Center", "Home › Paragraph", |s, _| align(s, Align::Center)).key("Mod+E"),
|
||
CommandSpec::new("para.alignRight", "Align Right", "Home › Paragraph", |s, _| align(s, Align::Right)).key("Mod+R"),
|
||
CommandSpec::new("para.justify", "Justify", "Home › Paragraph", |s, _| align(s, Align::Justify)).key("Mod+J"),
|
||
CommandSpec::new("para.distribute", "Distributed", "Home › Paragraph", |s, _| align(s, Align::Distribute)).key("Mod+Shift+J"),
|
||
CommandSpec::new("para.align", "Alignment", "Home › Paragraph", |s, v| {
|
||
let a = match p::req_str(v, "value")? {
|
||
"left" => Align::Left,
|
||
"center" => Align::Center,
|
||
"right" => Align::Right,
|
||
"justify" | "both" => Align::Justify,
|
||
"distribute" => Align::Distribute,
|
||
// Logical edges, whatever the paragraph's direction.
|
||
"start" => return fmt(s, &|p| p.align = Some(Align::Left)),
|
||
"end" => return fmt(s, &|p| p.align = Some(Align::Right)),
|
||
x => return Err(CmdError::Params(format!("unknown alignment `{x}`"))),
|
||
};
|
||
// An explicit value sets it (no toggling back to the start edge like the buttons).
|
||
set_align(s, a)
|
||
})
|
||
.params(r#"{"value": "left|center|right|justify|distribute (as seen on the page) | start|end (reading direction)"}"#),
|
||
CommandSpec::new("para.rtl", "Right-to-Left Text Direction", "Home › Paragraph", |s, v| direction(s, v, true))
|
||
.params(r#"{"value"?: bool (false = left to right)}"#),
|
||
CommandSpec::new("para.ltr", "Left-to-Right Text Direction", "Home › Paragraph", |s, v| direction(s, v, false))
|
||
.params(r#"{"value"?: bool (false = right to left)}"#),
|
||
CommandSpec::new("para.indent", "Increase Indent", "Home › Paragraph", indent).key("Mod+M"),
|
||
CommandSpec::new("para.outdent", "Decrease Indent", "Home › Paragraph", outdent).key("Mod+Shift+M"),
|
||
CommandSpec::new("para.hangingIndent", "Hanging Indent", "Home › Paragraph › Paragraph", |s, _| {
|
||
fmt(s, &|p| {
|
||
let l = p.indent_left.unwrap_or(0.0) + 36.0;
|
||
p.indent_left = Some(l);
|
||
p.indent_first = Some(-36.0);
|
||
})
|
||
})
|
||
.key("Mod+T"),
|
||
CommandSpec::new("para.removeHanging", "Reduce Hanging Indent", "Home › Paragraph › Paragraph", |s, _| {
|
||
fmt(s, &|p| {
|
||
p.indent_left = Some((p.indent_left.unwrap_or(0.0) - 36.0).max(0.0));
|
||
p.indent_first = Some(0.0);
|
||
})
|
||
})
|
||
.key("Mod+Shift+T"),
|
||
CommandSpec::new("para.indents", "Indents", "Layout › Paragraph", |s, v| {
|
||
let (l, r, f) = (p::f32(v, "left"), p::f32(v, "right"), p::f32(v, "firstLine"));
|
||
fmt(s, &|p| {
|
||
if let Some(x) = l {
|
||
p.indent_left = Some(x.clamp(-1584.0, 1584.0));
|
||
}
|
||
if let Some(x) = r {
|
||
p.indent_right = Some(x.clamp(-1584.0, 1584.0));
|
||
}
|
||
if let Some(x) = f {
|
||
p.indent_first = Some(x.clamp(-1584.0, 1584.0));
|
||
}
|
||
})
|
||
})
|
||
.params(r#"{"left"?: pt, "right"?: pt, "firstLine"?: pt (negative = hanging)}"#),
|
||
CommandSpec::new("para.lineSpacing", "Line and Paragraph Spacing", "Home › Paragraph", line_spacing)
|
||
.params(r#"{"value": number (multiple) } | {"atLeast": pt} | {"exactly": pt}"#),
|
||
CommandSpec::new("para.single", "Single Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(1.0))).key("Mod+1"),
|
||
CommandSpec::new("para.double", "Double Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(2.0))).key("Mod+2"),
|
||
CommandSpec::new("para.oneAndHalf", "1.5 Line Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(1.5))).key("Mod+5"),
|
||
CommandSpec::new("para.spacing", "Paragraph Spacing", "Layout › Paragraph", |s, v| {
|
||
let (b, a) = (p::f32(v, "before"), p::f32(v, "after"));
|
||
fmt(s, &|p| {
|
||
if let Some(x) = b {
|
||
p.space_before = Some(x.clamp(0.0, 1584.0));
|
||
}
|
||
if let Some(x) = a {
|
||
p.space_after = Some(x.clamp(0.0, 1584.0));
|
||
}
|
||
})
|
||
})
|
||
.params(r#"{"before"?: pt, "after"?: pt}"#),
|
||
CommandSpec::new("para.addSpaceBefore", "Add Space Before Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
|
||
let has = cur(s).space_before > 0.0;
|
||
fmt(s, &|p| p.space_before = Some(if has { 0.0 } else { 12.0 }))
|
||
})
|
||
.key("Mod+0"),
|
||
CommandSpec::new("para.removeSpaceAfter", "Remove Space After Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
|
||
let has = cur(s).space_after > 0.0;
|
||
fmt(s, &|p| p.space_after = Some(if has { 0.0 } else { 8.0 }))
|
||
}),
|
||
CommandSpec::new("para.style", "Apply Style", "Home › Styles", apply_style).params(r#"{"style": string (name or id)}"#),
|
||
CommandSpec::new("para.normal", "Normal Style", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Normal"}))).key("Mod+Shift+N"),
|
||
CommandSpec::new("para.heading1", "Heading 1", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Heading1"}))).key("Mod+Alt+1"),
|
||
CommandSpec::new("para.heading2", "Heading 2", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Heading2"}))).key("Mod+Alt+2"),
|
||
CommandSpec::new("para.heading3", "Heading 3", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Heading3"}))).key("Mod+Alt+3"),
|
||
CommandSpec::new("para.bullets", "Bullets", "Home › Paragraph", |s, v| list(s, v, ListKind::Bullet)).params(r#"{"kind"?: "bullet" | single char, "off"?: bool}"#).key("Mod+Shift+L"),
|
||
CommandSpec::new("para.numbering", "Numbering", "Home › Paragraph", |s, v| list(s, v, ListKind::Numbered))
|
||
.params(r#"{"kind"?: "numbered|numberedParen|upperLetter|lowerLetter|lowerRoman|outline", "off"?: bool}"#),
|
||
CommandSpec::new("para.multilevel", "Multilevel List", "Home › Paragraph", |s, v| list(s, v, ListKind::Legal)).params(r#"{"kind"?: "legal|outline"}"#),
|
||
CommandSpec::new("para.listLevel", "Change List Level", "Home › Paragraph › Bullets", |s, v| {
|
||
let lvl = p::u64(v, "level").unwrap_or(0).min(8) as u8;
|
||
fmt(s, &|p| {
|
||
if let Some(n) = p.numbering.as_mut() {
|
||
n.level = lvl;
|
||
}
|
||
})
|
||
})
|
||
.params(r#"{"level": 0-8}"#),
|
||
CommandSpec::new("para.restartNumbering", "Restart at 1", "Home › Paragraph › Numbering", restart_numbering),
|
||
CommandSpec::new("para.shading", "Shading", "Home › Paragraph", |s, v| {
|
||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||
fmt(s, &|p| p.shading = c)
|
||
})
|
||
.params(r#"{"color": "RRGGBB" | null}"#),
|
||
CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders).params(
|
||
r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick|triple|dotDash|wave", "sides"?: Sides} where Sides = {"top"|"left"|"bottom"|"right"|"between": {"style"?, "width"?: pt, "color"?: "RRGGBB"} | null}: exactly these sides (others off), instead of `kind`"#,
|
||
),
|
||
CommandSpec::new("para.sort", "Sort", "Home › Paragraph", sort).params(r#"{"descending"?: bool}"#),
|
||
CommandSpec::new("para.keepNext", "Keep with Next", "Home › Paragraph › Line and Page Breaks", |s, v| tog(s, v, |p| p.keep_next, |p, b| p.keep_next = Some(b))),
|
||
CommandSpec::new("para.keepLines", "Keep Lines Together", "Home › Paragraph › Line and Page Breaks", |s, v| tog(s, v, |p| p.keep_lines, |p, b| p.keep_lines = Some(b))),
|
||
CommandSpec::new("para.pageBreakBefore", "Page Break Before", "Home › Paragraph › Line and Page Breaks", |s, v| {
|
||
tog(s, v, |p| p.page_break_before, |p, b| p.page_break_before = Some(b))
|
||
}),
|
||
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", tabs).params(
|
||
r#"{"tabs"?: [TabStop] (replace the direct stops), "set"?: [TabStop], "clear"?: [pt], "clearAll"?: bool, "default"?: pt} where TabStop = {"pos": pt, "align"?: "left|center|right|decimal|bar", "leader"?: "none|dot|hyphen|underscore|middleDot"}"#,
|
||
),
|
||
CommandSpec::new("para.outlineLevel", "Outline Level", "Home › Paragraph › Paragraph", |s, v| {
|
||
let l = p::u64(v, "level").map(|x| x.min(9) as u8);
|
||
fmt(s, &|p| p.outline_level = l)
|
||
}),
|
||
CommandSpec::new("para.set", "Set Paragraph Formatting", "Home › Paragraph › Paragraph", |s, v| {
|
||
let props: ParaProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
|
||
fmt(s, &|p| p.overlay(&props))
|
||
})
|
||
.params(r#"{"props": ParaProps}"#),
|
||
CommandSpec::new("para.dialog", "Paragraph Settings", "Home › Paragraph", |s, _| {
|
||
s.ui_requests.push(json!({"open": "paragraph"}));
|
||
sel_result(s)
|
||
})
|
||
.pure(),
|
||
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", 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)")));
|
||
}
|
||
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(),
|
||
))
|
||
})
|
||
.pure(),
|
||
CommandSpec::new("styles.pane", "Styles Pane", "Home › Styles", |s, _| {
|
||
s.view.styles_pane = !s.view.styles_pane;
|
||
sel_result(s)
|
||
})
|
||
.key("Mod+Alt+Shift+S")
|
||
.pure(),
|
||
CommandSpec::new("styles.apply", "Apply Styles", "Home › Styles", apply_styles)
|
||
.params(r#"{"style"?: string (name or id)}"#)
|
||
.key("Mod+Shift+S")
|
||
.pure(),
|
||
CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| {
|
||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||
let on = p::bool(v, "value").unwrap_or(true);
|
||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||
st.quick = on;
|
||
}
|
||
sel_result(s)
|
||
}),
|
||
]
|
||
}
|
||
|
||
/// Resolved paragraph props at the caret.
|
||
fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
||
let props = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
|
||
s.doc.styles.resolve_para(&props)
|
||
}
|
||
|
||
pub fn fmt(s: &mut Session, f: &dyn Fn(&mut ParaProps)) -> CmdResult {
|
||
let (a, b) = s.sel.ordered();
|
||
s.doc.format_paragraphs(&a, &b, f)?;
|
||
sel_result(s)
|
||
}
|
||
|
||
/// `a` is the alignment as seen on the page (the buttons): in a right-to-left paragraph
|
||
/// Align Left is its end edge.
|
||
fn align(s: &mut Session, a: Align) -> CmdResult {
|
||
let r = cur(s);
|
||
// Clicking the active alignment again returns to the start edge (Word).
|
||
let logical = a.visual(r.bidi);
|
||
let target = if r.align == logical && logical != Align::Left { None } else { Some(a) };
|
||
let focus_rtl = r.bidi;
|
||
fmt(s, &|p| {
|
||
let rtl = p.bidi.unwrap_or(focus_rtl);
|
||
p.align = Some(target.map_or(Align::Left, |a| a.visual(rtl)));
|
||
})
|
||
}
|
||
|
||
/// Set the alignment as seen on the page in every selected paragraph, in its own direction.
|
||
fn set_align(s: &mut Session, a: Align) -> CmdResult {
|
||
let focus_rtl = cur(s).bidi;
|
||
fmt(s, &|p| p.align = Some(a.visual(p.bidi.unwrap_or(focus_rtl))))
|
||
}
|
||
|
||
/// Paragraph reading order (Word's Right-to-Left / Left-to-Right Text Direction buttons). Only
|
||
/// the direction changes: alignment and indents are logical, so a start-aligned paragraph moves
|
||
/// to the other side, as in Word. `rtl` is what the command turns on; `value: false` turns it off.
|
||
fn direction(s: &mut Session, v: &Value, rtl: bool) -> CmdResult {
|
||
let on = p::bool(v, "value").unwrap_or(true);
|
||
let want = if on { rtl } else { !rtl };
|
||
fmt(s, &|p| p.bidi = Some(want))
|
||
}
|
||
|
||
fn tog(s: &mut Session, v: &Value, get: fn(&ParaProps) -> Option<bool>, set: fn(&mut ParaProps, bool)) -> CmdResult {
|
||
let on = p::bool(v, "value").unwrap_or_else(|| {
|
||
let r = cur(s);
|
||
let props = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
|
||
let _ = r;
|
||
!get(&props).unwrap_or(false)
|
||
});
|
||
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);
|
||
let tab = s.doc.settings.default_tab.max(1.0);
|
||
for path in paths {
|
||
let (num, left) = {
|
||
let Some(p) = s.doc.para(a.story, &path) else { continue };
|
||
(p.props.numbering.filter(|n| n.num != 0), s.doc.styles.resolve_para(&p.props).indent_left)
|
||
};
|
||
let para = s.doc.para_mut(a.story, &path)?;
|
||
match num {
|
||
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }),
|
||
None => para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab),
|
||
}
|
||
para.touch();
|
||
}
|
||
sel_result(s)
|
||
}
|
||
|
||
pub fn outdent(s: &mut Session, _: &Value) -> CmdResult {
|
||
let (a, b) = s.sel.ordered();
|
||
let paths = s.doc.paths_between(&a, &b);
|
||
let tab = s.doc.settings.default_tab.max(1.0);
|
||
for path in paths {
|
||
let (num, left) = {
|
||
let Some(p) = s.doc.para(a.story, &path) else { continue };
|
||
(p.props.numbering.filter(|n| n.num != 0), s.doc.styles.resolve_para(&p.props).indent_left)
|
||
};
|
||
let para = s.doc.para_mut(a.story, &path)?;
|
||
match num {
|
||
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_sub(1) }),
|
||
None => para.props.indent_left = Some((((left / tab).ceil() - 1.0) * tab).max(0.0)),
|
||
}
|
||
para.touch();
|
||
}
|
||
sel_result(s)
|
||
}
|
||
|
||
fn ls(s: &mut Session, l: LineSpacing) -> CmdResult {
|
||
fmt(s, &|p| p.line_spacing = Some(l))
|
||
}
|
||
|
||
fn line_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||
let l = if let Some(x) = p::f32(v, "atLeast") {
|
||
LineSpacing::AtLeast(x.clamp(0.0, 1584.0))
|
||
} else if let Some(x) = p::f32(v, "exactly") {
|
||
LineSpacing::Exactly(x.clamp(0.7, 1584.0))
|
||
} else {
|
||
LineSpacing::Multiple(p::req_f32(v, "value")?.clamp(0.06, 132.0))
|
||
};
|
||
ls(s, l)
|
||
}
|
||
|
||
fn style_id(s: &Session, name: &str) -> Result<String, CmdError> {
|
||
s.doc.styles.find(name).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{name}`")))
|
||
}
|
||
|
||
/// Apply Styles. Pure itself: with a `style` it runs `para.style` as its own command (so undo,
|
||
/// protection, macro recording and Repeat see a real edit); without one it only shows the
|
||
/// styles pane, which is view state and never an edit.
|
||
fn apply_styles(s: &mut Session, v: &Value) -> CmdResult {
|
||
match v.get("style") {
|
||
None => {
|
||
s.view.styles_pane = true;
|
||
Ok(json!({"pane": true}))
|
||
}
|
||
Some(Value::String(name)) if !name.trim().is_empty() => s.run("para.style", &json!({"style": name})),
|
||
Some(Value::String(_)) => Err(CmdError::Params("`style` must not be empty".into())),
|
||
Some(_) => Err(CmdError::Params("`style` must be a string (name or id)".into())),
|
||
}
|
||
}
|
||
|
||
fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
|
||
let name = p::req_str(v, "style")?;
|
||
let id = style_id(s, name)?;
|
||
let kind = s.doc.styles.get(&id).map(|x| x.kind).unwrap_or_default();
|
||
if kind == StyleKind::Character {
|
||
return super::format::apply(s, &|c| c.style = Some(id.clone()));
|
||
}
|
||
let (a, b) = s.sel.ordered();
|
||
s.doc.format_paragraphs(&a, &b, &|p| {
|
||
p.style = Some(id.clone());
|
||
// Applying a heading or Normal removes direct list numbering (except list styles).
|
||
if (id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0) {
|
||
p.numbering = None;
|
||
}
|
||
})?;
|
||
// A paragraph style resets direct character formatting that matches nothing (Word keeps
|
||
// direct formatting unless it covers most of the paragraph; we clear font/size/colour).
|
||
if a.path == b.path || a == b {
|
||
let paths = s.doc.paths_between(&a, &b);
|
||
for path in paths {
|
||
let para = s.doc.para_mut(a.story, &path)?;
|
||
let len = para.len();
|
||
para.format(0, len, &|c| {
|
||
c.font = None;
|
||
c.font_cs = None;
|
||
c.size = None;
|
||
c.size_cs = None;
|
||
c.color = None;
|
||
})?;
|
||
para.mark.font = None;
|
||
para.mark.font_cs = None;
|
||
para.mark.size = None;
|
||
para.mark.size_cs = None;
|
||
para.mark.color = None;
|
||
}
|
||
}
|
||
s.pending = None;
|
||
sel_result(s)
|
||
}
|
||
|
||
fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||
let kind = p::str(v, "kind").and_then(ListKind::parse).unwrap_or(default);
|
||
let off = p::bool(v, "off").unwrap_or(false);
|
||
let (a, b) = s.sel.ordered();
|
||
// Toggle: if every selected paragraph already has this kind of list, remove it.
|
||
let current: Vec<Option<NumRef>> =
|
||
s.doc.paths_between(&a, &b).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
|
||
let same_kind = |n: &NumRef| {
|
||
s.doc.numbering.level(n.num, 0).is_some_and(|l| {
|
||
let want = wordcraft_doc::numbering::levels_for(kind);
|
||
want.first().is_some_and(|w| {
|
||
(w.format == wordcraft_doc::section::NumFormat::Bullet) == (l.format == wordcraft_doc::section::NumFormat::Bullet)
|
||
&& (p::str(v, "kind").is_none() || w.text == l.text)
|
||
})
|
||
})
|
||
};
|
||
let remove = off || (!current.is_empty() && current.iter().all(|n| n.as_ref().is_some_and(same_kind)));
|
||
if remove {
|
||
return fmt(s, &|p| {
|
||
p.numbering = Some(NumRef { num: 0, level: 0 });
|
||
p.indent_left = None;
|
||
p.indent_first = None;
|
||
if p.style.as_deref() == Some("ListParagraph") {
|
||
p.style = None;
|
||
}
|
||
});
|
||
}
|
||
// Continue the list of the previous paragraph if it has the same kind; else a new list.
|
||
let prev_num = s
|
||
.doc
|
||
.prev_para(a.story, &a.path)
|
||
.and_then(|q| s.doc.para(a.story, &q).and_then(|x| x.props.numbering))
|
||
.filter(|n| n.num != 0 && same_kind(n));
|
||
let num = match prev_num {
|
||
Some(n) => n.num,
|
||
None => match kind {
|
||
ListKind::Bullet | ListKind::BulletChar(_) => s.doc.numbering.find_kind(kind).unwrap_or_else(|| s.doc.numbering.add_list(kind)),
|
||
_ => s.doc.numbering.add_list(kind),
|
||
},
|
||
};
|
||
fmt(s, &|p| {
|
||
let level = p.numbering.filter(|n| n.num != 0).map(|n| n.level).unwrap_or(0);
|
||
p.numbering = Some(NumRef { num, level });
|
||
p.indent_left = None;
|
||
p.indent_first = None;
|
||
if p.style.is_none() || p.style.as_deref() == Some("Normal") {
|
||
p.style = Some("ListParagraph".into());
|
||
}
|
||
})
|
||
}
|
||
|
||
fn restart_numbering(s: &mut Session, _: &Value) -> CmdResult {
|
||
let f = s.sel.focus.clone();
|
||
let Some(n) = s.doc.para_at(&f).and_then(|p| p.props.numbering).filter(|n| n.num != 0) else {
|
||
return Err(CmdError::Failed("not in a numbered list".into()));
|
||
};
|
||
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||
// This paragraph and the following ones of the same list use the new instance.
|
||
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|p| *p >= f.path).collect();
|
||
for path in paths {
|
||
let para = s.doc.para_mut(f.story, &path)?;
|
||
match para.props.numbering {
|
||
Some(x) if x.num == n.num => {
|
||
para.props.numbering = Some(NumRef { num: new, level: x.level });
|
||
para.touch();
|
||
}
|
||
_ => {
|
||
if para.props.numbering.is_none() && !para.is_empty() {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
sel_result(s)
|
||
}
|
||
|
||
/// Most tab stops a paragraph keeps.
|
||
const MAX_TABS: usize = 64;
|
||
|
||
/// Tab stops: replace the direct list, set and clear stops, and change the default interval, in
|
||
/// one step. Clearing a stop the paragraph style defines records a clear stop over it.
|
||
fn tabs(s: &mut Session, v: &Value) -> CmdResult {
|
||
let stops = |k: &str| -> Result<Option<Vec<TabStop>>, CmdError> {
|
||
let Some(x) = v.get(k).filter(|x| !x.is_null()) else { return Ok(None) };
|
||
let list: Vec<TabStop> = serde_json::from_value(x.clone()).map_err(|e| CmdError::Params(format!("`{k}`: {e}")))?;
|
||
if list.len() > MAX_TABS {
|
||
return Err(CmdError::Params(format!("at most {MAX_TABS} tab stops")));
|
||
}
|
||
if list.iter().any(|t| !t.pos.is_finite() || t.pos.abs() > 1584.0) {
|
||
return Err(CmdError::Params("tab positions are points within ±1584".into()));
|
||
}
|
||
Ok(Some(list))
|
||
};
|
||
let replace = stops("tabs")?;
|
||
let set = stops("set")?.unwrap_or_default();
|
||
let clear: Vec<f32> = match v.get("clear").filter(|x| !x.is_null()) {
|
||
None => Vec::new(),
|
||
Some(x) => {
|
||
let list: Vec<f32> = serde_json::from_value(x.clone()).map_err(|e| CmdError::Params(format!("`clear`: {e}")))?;
|
||
if list.len() > MAX_TABS || list.iter().any(|p| !p.is_finite()) {
|
||
return Err(CmdError::Params(format!("`clear` is at most {MAX_TABS} positions in points")));
|
||
}
|
||
list
|
||
}
|
||
};
|
||
let clear_all = p::bool(v, "clearAll").unwrap_or(false);
|
||
if let Some(d) = p::f32(v, "default") {
|
||
s.doc.settings.default_tab = d.clamp(1.0, 1584.0);
|
||
}
|
||
if replace.is_none() && set.is_empty() && clear.is_empty() && !clear_all {
|
||
return sel_result(s);
|
||
}
|
||
let styles = s.doc.styles.clone();
|
||
let near = |a: f32, b: f32| (a - b).abs() < 0.5;
|
||
fmt(s, &|p| {
|
||
// The stops the paragraph inherits from its style.
|
||
let inherited = styles.resolve_para(&ParaProps { tabs: None, ..p.clone() }).tabs;
|
||
let mut list = replace.clone().unwrap_or_else(|| p.tabs.clone().unwrap_or_default());
|
||
let clear_at = |list: &mut Vec<TabStop>, pos: f32| {
|
||
list.retain(|t| !near(t.pos, pos));
|
||
if inherited.iter().any(|t| near(t.pos, pos)) {
|
||
list.push(TabStop { pos, align: TabAlign::Clear, leader: TabLeader::None });
|
||
}
|
||
};
|
||
if clear_all {
|
||
list.clear();
|
||
for t in &inherited {
|
||
clear_at(&mut list, t.pos);
|
||
}
|
||
}
|
||
for pos in &clear {
|
||
clear_at(&mut list, *pos);
|
||
}
|
||
for t in &set {
|
||
list.retain(|x| !near(x.pos, t.pos));
|
||
list.push(*t);
|
||
}
|
||
list.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||
list.truncate(MAX_TABS);
|
||
p.tabs = Some(list);
|
||
})
|
||
}
|
||
|
||
/// A `sides` object: exactly the sides it names, each `{"style", "width", "color"}`.
|
||
pub fn border_sides(v: &Value, space_tb: f32, space_lr: f32) -> Result<Borders, CmdError> {
|
||
let obj = v.as_object().ok_or_else(|| CmdError::Params("`sides` is an object".into()))?;
|
||
let side = |k: &str, space: f32| -> Option<Border> {
|
||
let x = obj.get(k).filter(|x| x.is_object())?;
|
||
let style = p::str(x, "style").map(BorderStyle::from_ooxml).unwrap_or(BorderStyle::Single);
|
||
let width = p::f32(x, "width").unwrap_or(0.5).clamp(0.25, 6.0);
|
||
Some(Border { style, width, color: p::str(x, "color").and_then(Rgb::parse), space })
|
||
};
|
||
Ok(Borders {
|
||
top: side("top", space_tb),
|
||
left: side("left", space_lr),
|
||
bottom: side("bottom", space_tb),
|
||
right: side("right", space_lr),
|
||
between: side("between", space_tb),
|
||
inside_v: None,
|
||
})
|
||
}
|
||
|
||
fn borders(s: &mut Session, v: &Value) -> CmdResult {
|
||
if let Some(sides) = v.get("sides") {
|
||
let b = border_sides(sides, 1.0, 4.0)?;
|
||
// An explicit empty set keeps a style's borders off.
|
||
return fmt(s, &|p| p.borders = Some(b));
|
||
}
|
||
let kind = p::str(v, "kind").unwrap_or("bottom");
|
||
let width = p::f32(v, "width").unwrap_or(0.5).clamp(0.25, 6.0);
|
||
let color = p::str(v, "color").and_then(Rgb::parse);
|
||
let style = p::str(v, "style").map(BorderStyle::from_ooxml).unwrap_or(BorderStyle::Single);
|
||
let b = Border { style, width, color, space: if kind == "left" || kind == "right" { 4.0 } else { 1.0 } };
|
||
if kind == "horizontalLine" {
|
||
// A paragraph with a bottom border, like Word's Horizontal Line.
|
||
return fmt(s, &|p| p.borders = Some(Borders { bottom: Some(Border { width: 1.5, ..b }), ..Default::default() }));
|
||
}
|
||
fmt(s, &|p| {
|
||
let mut cur = p.borders.unwrap_or_default();
|
||
match kind {
|
||
"none" => cur = Borders::default(),
|
||
"all" => cur = Borders::all(b),
|
||
"outside" => {
|
||
cur.top = Some(b);
|
||
cur.bottom = Some(b);
|
||
cur.left = Some(b);
|
||
cur.right = Some(b);
|
||
}
|
||
"inside" => cur.between = Some(b),
|
||
"top" => cur.top = if cur.top.is_some_and(|x| x.is_visible()) { None } else { Some(b) },
|
||
"bottom" => cur.bottom = if cur.bottom.is_some_and(|x| x.is_visible()) { None } else { Some(b) },
|
||
"left" => cur.left = if cur.left.is_some_and(|x| x.is_visible()) { None } else { Some(b) },
|
||
"right" => cur.right = if cur.right.is_some_and(|x| x.is_visible()) { None } else { Some(b) },
|
||
_ => {}
|
||
}
|
||
p.borders = if cur.any_visible() { Some(cur) } else { Some(Borders::default()) };
|
||
})
|
||
}
|
||
|
||
/// Sort the selected paragraphs (same container) alphabetically.
|
||
fn sort(s: &mut Session, v: &Value) -> CmdResult {
|
||
let desc = p::bool(v, "descending").unwrap_or(false);
|
||
let (a, b) = s.sel.ordered();
|
||
if a.path.parent() != b.path.parent() {
|
||
return Err(CmdError::Failed("select paragraphs in one place to sort".into()));
|
||
}
|
||
let (i0, i1) = (a.path.last(), b.path.last());
|
||
let bl = s.doc.container_mut(a.story, &a.path)?;
|
||
let Some(slice) = bl.get_mut(i0..=i1) else { return Err(CmdError::Failed("bad range".into())) };
|
||
if slice.iter().any(|b| !matches!(**b, Block::Para(_))) {
|
||
return Err(CmdError::Failed("sorting tables isn't supported here; use Table › Sort".into()));
|
||
}
|
||
let key = |b: &std::sync::Arc<Block>| b.as_para().map(|p| p.plain_text().to_lowercase()).unwrap_or_default();
|
||
slice.sort_by(|x, y| {
|
||
let o = natural_cmp(&key(x), &key(y));
|
||
if desc { o.reverse() } else { o }
|
||
});
|
||
// Keep a section break on the last paragraph of the range where it was.
|
||
s.sel = crate::Selection { anchor: Pos { off: 0, ..a }, focus: b };
|
||
sel_result(s)
|
||
}
|
||
|
||
/// Compare strings with embedded numbers numerically ("item 2" < "item 10").
|
||
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
|
||
let num = |s: &str| s.trim().split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse::<f64>().ok());
|
||
match (num(a), num(b)) {
|
||
(Some(x), Some(y)) if x != y => x.total_cmp(&y),
|
||
_ => a.cmp(b),
|
||
}
|
||
}
|
||
|
||
fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
||
let name = p::req_str(v, "name")?.trim().to_string();
|
||
if name.is_empty() {
|
||
return Err(CmdError::Params("name is empty".into()));
|
||
}
|
||
if s.doc.styles.find(&name).is_some() {
|
||
return Err(CmdError::Failed(format!("a style named `{name}` already exists")));
|
||
}
|
||
let id = s.doc.styles.new_id(&name);
|
||
let based = p::str(v, "basedOn").and_then(|b| s.doc.styles.find(b).map(|x| x.id.clone())).or(Some("Normal".into()));
|
||
let mut st = Style {
|
||
id: id.clone(),
|
||
name,
|
||
kind: StyleKind::Paragraph,
|
||
based_on: based,
|
||
next: Some(id.clone()),
|
||
quick: true,
|
||
priority: Some(50),
|
||
..Default::default()
|
||
};
|
||
if p::bool(v, "fromSelection").unwrap_or(true)
|
||
&& let Some(para) = s.doc.para_at(&s.sel.focus)
|
||
{
|
||
st.para = para.props.clone();
|
||
st.para.style = None;
|
||
st.para.numbering = None;
|
||
st.chr = para.props_at(s.sel.focus.off).clone();
|
||
st.chr.style = None;
|
||
st.chr.link = None;
|
||
st.chr.ins = None;
|
||
st.chr.del = None;
|
||
st.chr.fmt_change = None;
|
||
st.para = st.para.formatting();
|
||
}
|
||
s.doc.styles.upsert(st);
|
||
apply_style(s, &json!({"style": id}))?;
|
||
Ok(json!({"id": id}))
|
||
}
|
||
|
||
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||
let chr: Option<wordcraft_doc::CharProps> =
|
||
v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||
let para: Option<ParaProps> =
|
||
v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||
let next = p::str(v, "next").map(str::to_string);
|
||
let name = p::str(v, "name").map(str::to_string);
|
||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||
if let Some(c) = chr {
|
||
st.chr.overlay(&c);
|
||
}
|
||
if let Some(pp) = para {
|
||
st.para.overlay(&pp);
|
||
}
|
||
if let Some(n) = next {
|
||
st.next = Some(n);
|
||
}
|
||
if let Some(n) = name.filter(|n| !n.trim().is_empty()) {
|
||
st.name = n;
|
||
}
|
||
// Every paragraph's layout depends on styles: bump all revisions.
|
||
touch_all(s);
|
||
sel_result(s)
|
||
}
|
||
|
||
fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
|
||
let f = s.sel.focus.clone();
|
||
let Some(para) = s.doc.para_at(&f).cloned() else { return sel_result(s) };
|
||
let id = match p::str(v, "style") {
|
||
Some(n) => style_id(s, n)?,
|
||
None => para.props.style.clone().unwrap_or_else(|| "Normal".into()),
|
||
};
|
||
let mut chr = para.props_at(f.off).clone();
|
||
chr.style = None;
|
||
chr.link = None;
|
||
chr.ins = None;
|
||
chr.del = None;
|
||
chr.fmt_change = None;
|
||
let mut pp = para.props.formatting();
|
||
pp.style = None;
|
||
pp.numbering = None;
|
||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||
st.chr.overlay(&chr);
|
||
st.para.overlay(&pp);
|
||
}
|
||
// Remove the now-redundant direct formatting from the paragraph.
|
||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||
let len = p.len();
|
||
p.format(0, len, &|c| *c = c.cleared())?;
|
||
let style = p.props.style.clone();
|
||
p.props = ParaProps { style, numbering: p.props.numbering, ..Default::default() };
|
||
touch_all(s);
|
||
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) {
|
||
s.dirty = true;
|
||
}
|