Files
deckcraft/crates/engine/src/cmd/format.rs
T
NetterSilverandClaude Haiku 5.5 e5a3c9ca65 Change case across formatting run boundaries
Found while testing crates/engine/src/cmd/format.rs:693-715 (also the whole-shape path at :734-741)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:13:47 +02:00

1595 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Home ▸ Font and Paragraph, Format menu: character, paragraph and text box formatting, and
//! the Format Painter.
use deckcraft_model::edit as ed;
use deckcraft_model::resolve;
use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir};
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape};
use serde_json::{Value, json};
use super::text::{format_body, format_para, format_run};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "font.add", "Add Font", [], None, "{data: base64 TTF/OTF/TTC} → {faces} added for this session", always, font_add),
cmd!(noundo "font.rescan", "Rescan Installed Fonts", ["Home", "Font"], None, "{} → {faces, families} (fonts installed since start-up)", always, font_rescan),
cmd!("format.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{on?: bool}", has_text_or_shapes, bold),
cmd!("format.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{on?: bool}", has_text_or_shapes, italic),
cmd!(
"format.underline",
"Underline",
["Home", "Font"],
Some("Cmd+U"),
"{on?: bool, style?: sng|dbl|heavy|dotted|dash|wavy}",
has_text_or_shapes,
underline
),
cmd!(
"format.strikethrough",
"Strikethrough",
["Home", "Font"],
Some("Cmd+Shift+X"),
"{on?: bool, double?: bool}",
has_text_or_shapes,
strike
),
cmd!("format.superscript", "Superscript", ["Home", "Font"], Some("Cmd+Shift+="), "{on?: bool}", has_text_or_shapes, superscript),
cmd!("format.subscript", "Subscript", ["Home", "Font"], Some("Cmd+="), "{on?: bool}", has_text_or_shapes, subscript),
cmd!("format.font", "Font", ["Home", "Font"], None, "{family}", has_text_or_shapes, font),
cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size),
cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow),
cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink),
cmd!("format.color", "Font Color", ["Home", "Font"], None, "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}}", has_text_or_shapes, color),
cmd!(
"format.textTransparency",
"Text Transparency",
["Shape Format", "WordArt Styles"],
None,
"{value: 0..1} — 0 opaque, 1 invisible; keeps the text's colour (inherited colours included)",
has_text_or_shapes,
text_transparency
),
cmd!(
"format.language",
"Language",
["Review", "Language"],
None,
"{lang?, noProof?: bool} (proofing language of the selected text, and Do Not Check Spelling)",
has_text_or_shapes,
language
),
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
cmd!(
"format.case",
"Change Case",
["Home", "Font"],
Some("Shift+F3"),
"{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word",
has_text_or_shapes,
case
),
cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps),
cmd!(
"format.spacing",
"Character Spacing",
["Home", "Font"],
None,
"{pt} (veryTight -3, tight -1.5, normal 0, loose 3, veryLoose 6)",
has_text_or_shapes,
spacing
),
cmd!("format.clear", "Clear All Formatting", ["Home", "Font"], Some("Cmd+Space"), "{}", has_text_or_shapes, clear),
cmd!(
"format.fontDialog",
"Font…",
["Home", "Font"],
Some("Cmd+T"),
"{family?, size?: pt, bold?: bool, italic?: bool, underline?: none|sng|dbl|heavy|dotted|dash|wavy, color?, strike?: none|sng|dbl, baseline?: none|super|sub|<fraction>, caps?: none|small|all, spacing?: pt} — with none of these, opens the Font dialog",
has_text_or_shapes,
font_dialog
),
cmd!("format.textShadow", "Text Shadow", ["Home", "Font"], None, "{on?: bool}", has_text_or_shapes, text_shadow),
cmd!(
"format.textOutline",
"Text Outline",
["Shape Format", "WordArt Styles"],
None,
"{color?, width?: pt} (no color = none)",
has_text_or_shapes,
text_outline
),
cmd!(
"format.textWarp",
"Text Transform",
["Shape Format", "WordArt Styles"],
None,
"{preset: a prstTxWarp name or none, adj?: [finite raw guide values, at most 8]} — none clears the transform; omitted adj uses the preset's defaults",
has_text_or_shapes,
text_warp
),
cmd!(
"format.align",
"Align",
["Home", "Paragraph"],
None,
"{align: left|center|right|justify|justifyLow|distributed}",
has_text_or_shapes,
align
),
cmd!(
"format.rtl",
"Right-to-Left Text Direction",
["Home", "Paragraph"],
None,
"{on?: bool} (off = left-to-right; switching mirrors left/right alignment)",
has_text_or_shapes,
rtl
),
cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)),
cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)),
cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)),
cmd!("format.justify", "Justify", ["Home", "Paragraph"], Some("Cmd+J"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Justify)),
cmd!(
"format.bullets",
"Bullets",
["Home", "Paragraph"],
None,
"{on?: bool, char?: •|○|■|□|◆|➢|✓|–, color?, size?: % of text}",
has_text_or_shapes,
bullets
),
cmd!(
"format.numbering",
"Numbering",
["Home", "Paragraph"],
None,
"{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n, color?, size?: % of text}",
has_text_or_shapes,
numbering
),
cmd!("format.indent", "Increase List Level", ["Home", "Paragraph"], Some("Tab"), "{}", has_text_or_shapes, |s, _| level(s, 1)),
cmd!("format.outdent", "Decrease List Level", ["Home", "Paragraph"], Some("Shift+Tab"), "{}", has_text_or_shapes, |s, _| level(s, -1)),
cmd!(
"format.lineSpacing",
"Line Spacing",
["Home", "Paragraph"],
None,
"{lines?: 1.0|1.5|2.0…, pt?: exactly}",
has_text_or_shapes,
line_spacing
),
cmd!(
"format.paragraph",
"Paragraph…",
["Format"],
None,
"{align?, indentLeft?: pt, indentFirst?: pt (negative = hanging), spaceBefore?: pt, spaceAfter?: pt, lineSpacing?: lines}",
has_text_or_shapes,
paragraph
),
cmd!(query "format.ruler", "Ruler", [], None, "{} → {x, y, width, rotated, marginLeft, indent, tabs: [{pos, align}], defaultTab} for the paragraph with the caret (x, y: the text area's top-left in slide pt)", has_text, ruler),
cmd!(
"format.paragraphDialog",
"Paragraph…",
["Home", "Paragraph"],
None,
"{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt} — with none of these, opens the Paragraph dialog",
has_text_or_shapes,
paragraph_dialog
),
cmd!(
"format.tabs",
"Tabs…",
["Home", "Paragraph"],
None,
"{set?: [{pos, align?}] (replaces all), add?: {pos, align?: l|ctr|r|dec}, move?: {from, to}, remove?: pos, clear?: bool, defaultTab?: pt} on the selected paragraphs; with none of them, opens the Tabs dialog (pos in pt from the text area's left)",
has_text_or_shapes,
tabs
),
cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor),
cmd!(
"format.direction",
"Text Direction",
["Home", "Paragraph"],
None,
"{dir: horizontal|rotate90|rotate270|stacked}",
has_text_or_shapes,
direction
),
cmd!("format.columns", "Columns", ["Home", "Paragraph"], None, "{count, spacing?: pt}", has_text_or_shapes, columns),
cmd!("format.autofit", "Autofit", ["Format Shape", "Text Box"], None, "{mode: none|shrink|resize}", has_text_or_shapes, autofit),
cmd!("format.wrap", "Wrap Text in Shape", ["Format Shape", "Text Box"], None, "{on: bool}", has_text_or_shapes, wrap),
cmd!(
"format.margins",
"Text Box Margins",
["Format Shape", "Text Box"],
None,
"{left?: pt, top?: pt, right?: pt, bottom?: pt}",
has_text_or_shapes,
margins
),
cmd!(noundo "format.painter", "Format Painter", ["Home", "Clipboard"], Some("Cmd+Shift+C"), "{sticky?: bool}", has_text_or_shapes, painter_pick),
cmd!("format.painterApply", "Paste Formatting", [], Some("Cmd+Shift+V"), "{ids?}", has_text_or_shapes, painter_apply),
cmd!(query "format.state", "Formatting State", [], None, "{} → the effective formatting at the selection (font, size, bold, …)", has_doc, state),
]
}
fn on_param(p: &Value, current: Option<bool>) -> bool {
bool_param(p, "on").unwrap_or(!current.unwrap_or(false))
}
/// The effective run props at the selection (first selected run).
fn current_run(s: &Session) -> RunProps {
let Ok(st) = s.doc() else { return RunProps::default() };
let ctx = super::text::ctx_for(&st.doc, &st.selection);
let (shape, para, run) = match &st.selection.text {
Some(t) => {
let Some(body) = super::text::body_of(st, t) else { return RunProps::default() };
let a = t.ordered().0;
let rp = if t.is_range() { ed::props_at(body, (a.0, a.1 + 1)) } else { ed::props_at(body, a) };
let para = body.paragraphs.get(a.0).cloned().unwrap_or_default();
(st.shape(t.shape).cloned().unwrap_or_default(), para, rp)
}
None => {
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return RunProps::default() };
let para = sh.text.as_ref().and_then(|t| t.paragraphs.first().cloned()).unwrap_or_default();
let rp = para.runs.first().map(|r| r.props.clone()).unwrap_or_default();
(sh, para, rp)
}
};
match ctx {
Some(c) => resolve::run(&c, &shape, &para, &run),
None => run,
}
}
fn current_para(s: &Session) -> ParaProps {
let Ok(st) = s.doc() else { return ParaProps::default() };
let ctx = super::text::ctx_for(&st.doc, &st.selection);
let (shape, para) = match &st.selection.text {
Some(t) => {
let para = super::text::body_of(st, t).and_then(|b| b.paragraphs.get(t.caret.0).cloned()).unwrap_or_default();
(st.shape(t.shape).cloned().unwrap_or_default(), para)
}
None => {
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ParaProps::default() };
let para = sh.text.as_ref().and_then(|t| t.paragraphs.first().cloned()).unwrap_or_default();
(sh, para)
}
};
match ctx {
Some(c) => resolve::para(&c, &shape, &para),
None => para.props,
}
}
fn bold(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, current_run(s).bold);
format_run(s, &move |r| r.bold = Some(v))
}
fn italic(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, current_run(s).italic);
format_run(s, &move |r| r.italic = Some(v))
}
fn underline(s: &mut Session, p: &Value) -> Result<Value> {
let cur = current_run(s).underline.is_some_and(|u| u != "none");
let v = on_param(p, Some(cur));
let style = str_param(p, "style").unwrap_or("sng").to_string();
format_run(s, &move |r| r.underline = Some(if v { style.clone() } else { "none".into() }))
}
fn strike(s: &mut Session, p: &Value) -> Result<Value> {
let cur = current_run(s).strike.is_some_and(|x| x != Strike::None);
let v = on_param(p, Some(cur));
let d = bool_or(p, "double", false);
format_run(s, &move |r| {
r.strike = Some(if !v {
Strike::None
} else if d {
Strike::Double
} else {
Strike::Single
})
})
}
fn superscript(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) > 0.0));
format_run(s, &move |r| r.baseline = Some(if v { 0.3 } else { 0.0 }))
}
fn subscript(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) < 0.0));
format_run(s, &move |r| r.baseline = Some(if v { -0.25 } else { 0.0 }))
}
fn font(s: &mut Session, p: &Value) -> Result<Value> {
let f = str_param(p, "family").ok_or_else(|| bad("format.font", "missing `family`"))?.to_string();
format_run(s, &move |r| r.font = Some(f.clone()))
}
fn size(s: &mut Session, p: &Value) -> Result<Value> {
let v = f64_param(p, "size").ok_or_else(|| bad("format.size", "missing `size`"))?.clamp(1.0, 4000.0);
format_run(s, &move |r| r.size = Some(v))
}
/// PowerPoint's font size list (grow/shrink steps through it).
pub const SIZES: [f64; 20] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0];
fn step(cur: f64, up: bool) -> f64 {
if up {
SIZES.iter().copied().find(|v| *v > cur + 0.01).unwrap_or((cur + 8.0).min(4000.0))
} else {
SIZES.iter().rev().copied().find(|v| *v < cur - 0.01).unwrap_or((cur - 1.0).max(1.0))
}
}
fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
// Each run steps from its own size, like PowerPoint.
let st = s.doc()?;
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
let _ = st;
let base = ctx_sizes;
format_run(s, &move |r| {
let cur = r.size.unwrap_or(base);
r.size = Some(step(cur, up));
})
}
fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
grow_shrink(s, true)
}
fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {
grow_shrink(s, false)
}
fn color(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?;
format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone())))
}
/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it.
fn with_transparency(c: &mut ColorRef, transparency: f64) {
c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
if transparency > 0.0 {
c.mods.push(ColorTransform::Alpha(((1.0 - transparency) * 100_000.0).round() as i32));
}
}
/// Text Fill › Transparency: the text keeps its colour and gets an alpha. A run without its own
/// fill takes the colour it shows now (from the placeholder, master or theme) so only its
/// opacity changes.
fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> {
let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?;
if !v.is_finite() {
return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1"));
}
let v = v.clamp(0.0, 1.0);
let shown = match current_run(s).fill {
Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f,
_ => Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)),
};
format_run(s, &move |r| {
let mut f = match &r.fill {
Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f.clone(),
Some(Fill::None) => return,
_ => shown.clone(),
};
match &mut f {
Fill::Solid { color } => with_transparency(color, v),
Fill::Gradient(g) => g.stops.iter_mut().for_each(|st| with_transparency(&mut st.color, v)),
_ => {}
}
r.fill = Some(f);
})
}
fn language(s: &mut Session, p: &Value) -> Result<Value> {
let lang = str_param(p, "lang").map(str::to_string);
let no_proof = bool_param(p, "noProof");
if lang.is_none() && no_proof.is_none() {
return Err(bad("format.language", "give `lang` or `noProof`"));
}
if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) {
return Err(bad("format.language", "`lang` is a language tag such as en-US"));
}
format_run(s, &move |r| {
if let Some(l) = &lang {
r.lang = Some(l.clone());
}
if let Some(n) = no_proof {
r.no_proof = n.then_some(true);
}
})
}
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
format_run(s, &move |r| r.highlight = c.clone())
}
fn caps(s: &mut Session, p: &Value) -> Result<Value> {
let c = match str_param(p, "caps").unwrap_or("none") {
"small" => Caps::Small,
"all" => Caps::All,
_ => Caps::None,
};
format_run(s, &move |r| r.caps = Some(c))
}
fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0);
format_run(s, &move |r| r.spacing = Some(v))
}
/// Character formatting applied together, so the Font dialog is one undo step.
struct FontEdits {
family: Option<String>,
size: Option<f64>,
bold: Option<bool>,
italic: Option<bool>,
underline: Option<String>,
color: Option<deckcraft_model::ColorRef>,
strike: Option<Strike>,
baseline: Option<f64>,
caps: Option<Caps>,
spacing: Option<f64>,
}
impl FontEdits {
fn is_empty(&self) -> bool {
self.family.is_none()
&& self.size.is_none()
&& self.bold.is_none()
&& self.italic.is_none()
&& self.underline.is_none()
&& self.color.is_none()
&& self.strike.is_none()
&& self.baseline.is_none()
&& self.caps.is_none()
&& self.spacing.is_none()
}
fn write(&self, r: &mut RunProps) {
if let Some(f) = &self.family {
r.font = Some(f.clone());
}
if let Some(v) = self.size {
r.size = Some(v);
}
if let Some(v) = self.bold {
r.bold = Some(v);
}
if let Some(v) = self.italic {
r.italic = Some(v);
}
if let Some(u) = &self.underline {
r.underline = Some(u.clone());
}
if let Some(c) = &self.color {
r.fill = Some(Fill::solid(c.clone()));
}
if let Some(v) = self.strike {
r.strike = Some(v);
}
if let Some(v) = self.baseline {
r.baseline = Some(v);
}
if let Some(v) = self.caps {
r.caps = Some(v);
}
if let Some(v) = self.spacing {
r.spacing = Some(v);
}
}
}
fn font_bool(p: &Value, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))),
}
}
fn font_str<'a>(p: &'a Value, key: &str) -> Result<Option<&'a str>> {
match p.get(key) {
None => Ok(None),
Some(Value::String(s)) => Ok(Some(s)),
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))),
}
}
fn font_edits(p: &Value) -> Result<FontEdits> {
let family = match font_str(p, "family")? {
None => None,
Some(s) => {
let t = s.trim();
if t.is_empty() || t.chars().count() > 200 {
return Err(bad("format.fontDialog", "`family` must be 1–200 characters"));
}
Some(t.to_string())
}
};
let size = match p.get("size") {
None => None,
Some(v) => {
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?;
Some(n.clamp(1.0, 4000.0))
}
};
let underline = match font_str(p, "underline")? {
None => None,
Some(s) => {
if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") {
return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy"));
}
Some(s.to_string())
}
};
let color =
if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None };
let strike = match font_str(p, "strike")? {
None => None,
Some("none") => Some(Strike::None),
Some("sng" | "single") => Some(Strike::Single),
Some("dbl" | "double") => Some(Strike::Double),
Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")),
};
let baseline = match p.get("baseline") {
None => None,
Some(Value::String(s)) => match s.as_str() {
"none" => Some(0.0),
"super" | "superscript" => Some(0.3),
"sub" | "subscript" => Some(-0.25),
_ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
},
Some(Value::Number(n)) => {
let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?;
Some(f.clamp(-1.0, 1.0))
}
Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
};
let caps = match font_str(p, "caps")? {
None => None,
Some("none") => Some(Caps::None),
Some("small") => Some(Caps::Small),
Some("all") => Some(Caps::All),
Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")),
};
let spacing = match p.get("spacing") {
None => None,
Some(v) => {
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?;
Some(n.clamp(-100.0, 400.0))
}
};
Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing })
}
/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms.
fn color_command_value(c: &deckcraft_model::ColorRef) -> Value {
use deckcraft_model::ColorBase;
let mut o = serde_json::Map::new();
for m in &c.mods {
if let Some(v) = m.value() {
o.insert(m.xml_name().to_string(), json!(v));
}
}
match &c.base {
ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())),
ColorBase::Rgb { rgb } => {
o.insert("rgb".into(), json!(format!("#{}", rgb.hex())));
Value::Object(o)
}
ColorBase::Scheme { slot } => {
o.insert("scheme".into(), json!(slot.xml_name()));
Value::Object(o)
}
ColorBase::Preset { name } => json!(name),
ColorBase::System { last, .. } => json!(format!("#{}", last.hex())),
}
}
fn font_dialog_params(s: &Session) -> Value {
let r = current_run(s);
let family = s.doc().ok().and_then(|st| super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default();
let baseline = r.baseline.unwrap_or(0.0);
let mut v = json!({
"family": family,
"size": r.size.unwrap_or(18.0),
"bold": r.bold.unwrap_or(false),
"italic": r.italic.unwrap_or(false),
"underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"),
"strike": match r.strike {
Some(Strike::Double) => "dbl",
Some(Strike::Single) => "sng",
_ => "none",
},
"baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" },
"caps": match r.caps {
Some(Caps::Small) => "small",
Some(Caps::All) => "all",
_ => "none",
},
"spacing": r.spacing.unwrap_or(0.0),
});
if let Some(Fill::Solid { color }) = &r.fill {
v["color"] = color_command_value(color);
}
v
}
fn font_dialog(s: &mut Session, p: &Value) -> Result<Value> {
let edits = font_edits(p)?;
if edits.is_empty() {
s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) });
return ok();
}
format_run(s, &|r| edits.write(r))
}
fn clear(s: &mut Session, _p: &Value) -> Result<Value> {
format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() })
}
fn text_shadow(s: &mut Session, p: &Value) -> Result<Value> {
let v = on_param(p, Some(current_run(s).shadow.is_some()));
let sh = deckcraft_model::style::Shadow {
color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK).with(deckcraft_color::ColorTransform::Alpha(43000)),
blur: 3.0,
dist: 2.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
};
format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None })
}
fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
let w = f64_or(p, "width", 0.75);
format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w)))
}
fn text_warp(s: &mut Session, p: &Value) -> Result<Value> {
let preset = str_param(p, "preset").ok_or_else(|| bad("format.textWarp", "missing `preset`"))?;
let clear = preset == "none" || preset == "textNoShape";
if !clear && !deckcraft_geom::warp::is_supported(preset) {
return Err(bad("format.textWarp", format!("unknown text transform `{preset}`")));
}
let adj = match p.get("adj") {
None => Vec::new(),
Some(Value::Array(arr)) => {
if arr.len() > 8 {
return Err(bad("format.textWarp", "at most 8 adjust values"));
}
let mut v = Vec::with_capacity(arr.len());
for a in arr {
let Some(n) = a.as_f64().filter(|n| n.is_finite()) else {
return Err(bad("format.textWarp", "adjust values must be finite numbers"));
};
v.push(n.clamp(-2.0e9, 2.0e9));
}
v
}
Some(_) => return Err(bad("format.textWarp", "`adj` must be an array of numbers")),
};
let stored = if clear { None } else { Some(preset.to_string()) };
format_body(s, &|b| {
b.warp.clone_from(&stored);
b.warp_adj.clone_from(&adj);
})
}
fn case(s: &mut Session, p: &Value) -> Result<Value> {
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (mut a, mut b) = t.ordered();
let body = super::text::body_of(st, &t).cloned().unwrap_or_default();
let collapsed = a == b;
if collapsed {
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
let (wa, wb) = ed::word_at(&body, a);
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
return ok();
}
(a, b) = (wa, wb);
}
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
super::text::edit_text(s, move |body, t| {
for pi in a.0..=b.0 {
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
let n = para.char_len();
let from = if pi == a.0 { a.1 } else { 0 };
let to = if pi == b.0 { b.1 } else { n };
let mut pos = 0;
let mut pieces = Vec::new();
for (ri, r) in para.runs.iter().enumerate() {
// Breaks and fields are read for context (a break ends a word) but not rewritten.
let len = r.char_len();
let rs = from.max(pos).saturating_sub(pos);
let re = to.min(pos + len).saturating_sub(pos);
if re > rs {
pieces.push((ri, rs, re));
}
pos += len;
}
let mids: Vec<String> = pieces
.iter()
.map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default())
.collect();
let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::<Vec<_>>(), &m);
for (&(ri, rs, re), new) in pieces.iter().zip(changed) {
let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue };
let pre: String = r.text.chars().take(rs).collect();
let post: String = r.text.chars().skip(re).collect();
r.text = format!("{pre}{new}{post}");
}
}
if collapsed {
t.anchor = a;
t.caret = a;
}
})?;
return ok();
}
let mode = if mode == "cycle" {
let all: String =
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
"
",
);
ed::next_case(&all).to_string()
} else {
mode
};
edit_shapes(s, &json!({}), "format.case", |sh| {
if let Some(t) = sh.text.as_mut() {
for para in &mut t.paragraphs {
// Breaks and fields are read for context (a break ends a word) but not rewritten.
let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect();
let changed = ed::change_case_parts(&texts, &mode);
for (r, new) in para.runs.iter_mut().zip(changed) {
if r.kind == deckcraft_model::text::RunKind::Text {
r.text = new;
}
}
}
}
Ok(())
})
}
fn align_to(s: &mut Session, a: Align) -> Result<Value> {
format_para(s, &move |p| p.props.align = Some(a))
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let a = match str_param(p, "align").unwrap_or("left") {
"center" | "centre" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justifyLow" | "justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => Align::Left,
};
align_to(s, a)
}
/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat
/// at the start edge stays there.
fn rtl(s: &mut Session, p: &Value) -> Result<Value> {
let on = on_param(p, current_para(s).rtl);
format_para(s, &move |para| {
if para.props.rtl.unwrap_or(false) != on {
para.props.align = match para.props.align {
None | Some(Align::Left) => Some(Align::Right),
Some(Align::Right) => Some(Align::Left),
other => other,
};
}
para.props.rtl = Some(on);
})
}
fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. }));
let on = on_param(p, Some(cur));
let ch = str_param(p, "char").unwrap_or("•").to_string();
let col = color_param(p, "color");
let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0));
let explicit_char = p.get("char").is_some();
format_para(s, &move |para| {
if on {
para.props.bullet = Some(Bullet::Char { char: ch.clone() });
para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() };
if col.is_some() {
para.props.bullet_color = col.clone();
}
if size.is_some() {
para.props.bullet_size = size;
}
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64);
para.props.indent = Some(-18.0);
}
} else {
para.props.bullet = Some(Bullet::None);
para.props.indent = Some(0.0);
para.props.margin_left = Some(36.0 * para.level as f64);
}
})
}
fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
let cur = matches!(current_para(s).bullet, Some(Bullet::AutoNum { .. }));
let on = on_param(p, Some(cur));
let scheme = str_param(p, "scheme").unwrap_or("arabicPeriod").to_string();
let start = p.get("start").and_then(Value::as_u64).map(|v| v.clamp(1, 32767) as u32).unwrap_or(1);
let col = color_param(p, "color");
let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0));
format_para(s, &move |para| {
if on {
para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
if col.is_some() {
para.props.bullet_color = col.clone();
}
if size.is_some() {
para.props.bullet_size = size;
}
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
para.props.indent = Some(-27.0);
} else {
para.props.bullet = Some(Bullet::None);
para.props.indent = Some(0.0);
para.props.margin_left = Some(36.0 * para.level as f64);
}
})
}
fn level(s: &mut Session, d: i32) -> Result<Value> {
format_para(s, &move |p| {
let l = (p.level as i32 + d).clamp(0, 8) as u8;
// Explicit indents shift with the level.
if l != p.level {
if let Some(m) = p.props.margin_left.as_mut() {
*m = (*m + 36.0 * d as f64).max(0.0);
}
p.level = l;
}
})
}
fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let sp =
if let Some(pt) = f64_param(p, "pt") { Spacing::Pts(pt.clamp(0.0, 1584.0)) } else { Spacing::Pct(f64_or(p, "lines", 1.0).clamp(0.0, 9.99)) };
format_para(s, &move |para| para.props.line_spacing = Some(sp))
}
fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
let a = str_param(p, "align").map(|a| match a {
"center" => Align::Center,
"right" => Align::Right,
"justify" => Align::Justify,
"distributed" => Align::Distributed,
_ => Align::Left,
});
let ml = f64_param(p, "indentLeft");
let ind = f64_param(p, "indentFirst");
let sb = f64_param(p, "spaceBefore");
let sa = f64_param(p, "spaceAfter");
let ls = f64_param(p, "lineSpacing");
let lsp = f64_param(p, "lineSpacingPt");
format_para(s, &move |para| {
if a.is_some() {
para.props.align = a;
}
if ml.is_some() {
para.props.margin_left = ml.map(|v| v.max(0.0));
}
if ind.is_some() {
para.props.indent = ind;
}
if let Some(v) = sb {
para.props.space_before = Some(Spacing::Pts(v.max(0.0)));
}
if let Some(v) = sa {
para.props.space_after = Some(Spacing::Pts(v.max(0.0)));
}
if let Some(v) = ls {
para.props.line_spacing = Some(Spacing::Pct(v.clamp(0.0, 9.99)));
}
if let Some(v) = lsp {
para.props.line_spacing = Some(Spacing::Pts(v.max(0.0)));
}
})
}
const PARA_DIALOG_KEYS: &[&str] =
&["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"];
fn para_dialog_has_fields(p: &Value) -> bool {
PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some())
}
fn finite_key(p: &Value, cmd: &str, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None => Ok(None),
Some(v) => {
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?;
Ok(Some(n))
}
}
}
fn clamp_pt(v: f64) -> f64 {
v.clamp(0.0, 1584.0)
}
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
Ok(match s {
"left" | "l" => Align::Left,
"center" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
})
}
fn align_name(a: Align) -> &'static str {
match a {
Align::Left => "left",
Align::Center => "center",
Align::Right => "right",
Align::Justify => "justify",
Align::JustLow => "justLow",
Align::Distributed => "distributed",
}
}
fn spacing_parts(s: Option<Spacing>) -> (&'static str, f64) {
match s {
Some(Spacing::Pct(v)) => ("pct", v),
Some(Spacing::Pts(v)) => ("pts", v),
None => ("pts", 0.0),
}
}
fn paragraph_dialog_params(s: &Session) -> Value {
let pp = current_para(s);
let indent = pp.indent.unwrap_or(0.0);
let (special, by) = if indent > 0.05 {
("first", indent)
} else if indent < -0.05 {
("hanging", -indent)
} else {
("none", 0.0)
};
let (line_kind, line_value) = match pp.line_spacing {
Some(Spacing::Pts(v)) => ("exactly", v),
Some(Spacing::Pct(v)) => ("multiple", v),
None => ("multiple", 1.0),
};
let (before_kind, before) = spacing_parts(pp.space_before);
let (after_kind, after) = spacing_parts(pp.space_after);
let tabs: Vec<Value> = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
json!({
"align": align_name(pp.align.unwrap_or(Align::Left)),
"indentLeft": pp.margin_left.unwrap_or(0.0),
"indentRight": pp.margin_right.unwrap_or(0.0),
"special": special,
"specialBy": by,
"spaceBefore": before,
"spaceBeforeKind": before_kind,
"spaceAfter": after,
"spaceAfterKind": after_kind,
"lineKind": line_kind,
"lineValue": line_value,
"tabs": tabs,
"defaultTab": pp.default_tab.unwrap_or(72.0),
})
}
fn write_spacing(kind: Option<&str>, value: f64) -> Spacing {
if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) }
}
fn paragraph_dialog(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "format.paragraphDialog";
if !para_dialog_has_fields(p) {
s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) });
return ok();
}
let align = match p.get("align") {
None => None,
Some(Value::String(a)) => Some(parse_align(CMD, a)?),
Some(_) => return Err(bad(CMD, "`align` must be a string")),
};
let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt);
let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt);
let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0)));
let indent = match p.get("special") {
None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)),
Some(Value::String(kind)) => {
let by = special_by.unwrap_or(0.0);
Some(match kind.as_str() {
"none" => 0.0,
"first" | "firstLine" => by,
"hanging" | "hang" => -by,
_ => return Err(bad(CMD, "`special` must be none, first or hanging")),
})
}
Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")),
};
let space_before = finite_key(p, CMD, "spaceBefore")?;
let space_after = finite_key(p, CMD, "spaceAfter")?;
let before_kind = str_param(p, "spaceBeforeKind");
let after_kind = str_param(p, "spaceAfterKind");
for kind in [before_kind, after_kind].into_iter().flatten() {
if !matches!(kind, "pts" | "pct") {
return Err(bad(CMD, "spacing kind must be pts or pct"));
}
}
let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99));
let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt);
format_para(s, &move |para| {
if align.is_some() {
para.props.align = align;
}
if indent_left.is_some() {
para.props.margin_left = indent_left;
}
if indent_right.is_some() {
para.props.margin_right = indent_right;
}
if indent.is_some() {
para.props.indent = indent;
}
if let Some(v) = space_before {
para.props.space_before = Some(write_spacing(before_kind, v));
}
if let Some(v) = space_after {
para.props.space_after = Some(write_spacing(after_kind, v));
}
if let Some(v) = line_mult {
para.props.line_spacing = Some(Spacing::Pct(v));
}
if let Some(v) = line_pt {
para.props.line_spacing = Some(Spacing::Pts(v));
}
})
}
fn tab_align(s: &str) -> Option<&'static str> {
Some(match s {
"l" | "left" => "l",
"ctr" | "center" => "ctr",
"r" | "right" => "r",
"dec" | "decimal" => "dec",
_ => return None,
})
}
fn tabs_params(s: &Session) -> Value {
let pp = current_para(s);
let stops: Vec<Value> = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)})
}
fn anchor(s: &mut Session, p: &Value) -> Result<Value> {
let a = match str_param(p, "anchor").unwrap_or("top") {
"middle" | "center" | "ctr" => Anchor::Middle,
"bottom" | "b" => Anchor::Bottom,
_ => Anchor::Top,
};
format_body(s, &move |b| b.anchor = Some(a))
}
fn direction(s: &mut Session, p: &Value) -> Result<Value> {
let d = match str_param(p, "dir").unwrap_or("horizontal") {
"rotate90" | "vert" => TextDir::Vertical,
"rotate270" | "vert270" => TextDir::Vertical270,
"stacked" => TextDir::Stacked,
_ => TextDir::Horizontal,
};
format_body(s, &move |b| b.vert = Some(d))
}
fn columns(s: &mut Session, p: &Value) -> Result<Value> {
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 16) as u32;
let sp = f64_or(p, "spacing", 0.0).max(0.0);
format_body(s, &move |b| {
b.columns = Some(n);
b.col_spacing = Some(sp);
})
}
fn autofit(s: &mut Session, p: &Value) -> Result<Value> {
let m = match str_param(p, "mode").unwrap_or("none") {
"shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 },
"resize" | "shape" => AutoFit::Shape,
_ => AutoFit::None,
};
format_body(s, &move |b| b.autofit = Some(m))
}
fn wrap(s: &mut Session, p: &Value) -> Result<Value> {
let on = bool_or(p, "on", true);
format_body(s, &move |b| b.wrap = Some(on))
}
fn margins(s: &mut Session, p: &Value) -> Result<Value> {
let (l, t, r, b) = (f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom"));
format_body(s, &move |bp| {
if l.is_some() {
bp.inset_l = l.map(|v| v.max(0.0));
}
if t.is_some() {
bp.inset_t = t.map(|v| v.max(0.0));
}
if r.is_some() {
bp.inset_r = r.map(|v| v.max(0.0));
}
if b.is_some() {
bp.inset_b = b.map(|v| v.max(0.0));
}
})
}
/// Formatting picked up by the Format Painter.
#[derive(Clone, Debug, Default)]
pub struct Painted {
pub run: Option<RunProps>,
pub para: Option<ParaProps>,
pub fill: Option<Fill>,
pub line: Option<Line>,
pub effects: Option<Effects>,
pub style: Option<deckcraft_model::ShapeStyle>,
}
fn painter_pick(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let mut painted = Painted::default();
if let Some(t) = &st.selection.text {
if let Some(b) = super::text::body_of(st, t) {
let a = t.ordered().0;
painted.run = Some(ed::props_at(b, (a.0, a.1 + 1)));
painted.para = b.paragraphs.get(a.0).map(|x| x.props.clone());
}
} else if let Some(sh) = st.selected_shapes().first() {
painted.fill = sh.fill.clone();
painted.line = sh.line.clone();
painted.effects = sh.effects.clone();
painted.style = sh.style.clone();
painted.run = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone());
}
s.painter = Some((painted, bool_or(p, "sticky", false)));
Ok(json!({"armed": true}))
}
pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) {
if p.fill.is_some() || p.style.is_some() {
sh.fill.clone_from(&p.fill);
sh.line.clone_from(&p.line);
sh.effects.clone_from(&p.effects);
sh.style.clone_from(&p.style);
}
if let (Some(r), Some(t)) = (&p.run, sh.text.as_mut()) {
ed::format_all(t, &|rp| *rp = r.clone());
}
}
fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) };
if !sticky {
s.painter = None;
}
if s.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) && ids_param(p, "ids").is_none() {
if let Some(r) = painted.run.clone() {
format_run(s, &move |rp| *rp = r.clone())?;
}
if let Some(pp) = painted.para.clone() {
format_para(s, &move |para| para.props = pp.clone())?;
}
return ok();
}
edit_shapes(s, p, "format.painterApply", |sh| {
apply_painted(sh, &painted);
Ok(())
})
}
fn state(s: &mut Session, _p: &Value) -> Result<Value> {
let r = current_run(s);
let pp = current_para(s);
let st = s.doc()?;
let family = super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r)).unwrap_or_default();
Ok(json!({
"font": family,
"fontRef": r.font,
"size": r.size,
"bold": r.bold.unwrap_or(false),
"italic": r.italic.unwrap_or(false),
"underline": r.underline.as_deref().is_some_and(|u| u != "none"),
"strike": r.strike.is_some_and(|x| x != Strike::None),
"superscript": r.baseline.unwrap_or(0.0) > 0.0,
"subscript": r.baseline.unwrap_or(0.0) < 0.0,
"align": pp.align.map(|a| a.xml()),
"bullets": matches!(pp.bullet, Some(Bullet::Char { .. })),
"numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })),
"lang": r.lang,
"lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(),
"painter": s.painter.is_some(),
}))
}
/// Add a font from its bytes for this session (it then shows in the font menu and draws).
fn font_add(_s: &mut Session, p: &Value) -> Result<Value> {
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?;
if data.len() > 64 << 20 {
return Err(bad("font.add", "font file larger than 64 MB"));
}
let faces = deckcraft_fonts::FontDb::global().add_font(data);
if faces == 0 {
return Err(bad("font.add", "not a font DeckCraft can read (or already added)"));
}
Ok(json!({"faces": faces}))
}
/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear.
fn font_rescan(_s: &mut Session, _p: &Value) -> Result<Value> {
let db = deckcraft_fonts::FontDb::global();
#[cfg(not(target_arch = "wasm32"))]
let faces = db.load_system_fonts();
#[cfg(target_arch = "wasm32")]
let faces = 0usize;
Ok(json!({"faces": faces, "families": db.families().len()}))
}
/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops,
/// and where its text area sits on the slide.
fn ruler(s: &mut Session, _p: &Value) -> Result<Value> {
let props = current_para(s);
let st = s.doc()?;
let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?;
let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) {
(false, Some(sh)) => {
let ctx = super::text::ctx_for(&st.doc, &st.selection);
let x = match &ctx {
Some(c) => resolve::xfrm(c, sh),
None => sh.xfrm.unwrap_or_default(),
};
let body = match &ctx {
Some(c) => resolve::body(c, sh),
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
};
let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6));
(x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal))
}
_ => (0.0, 0.0, 0.0, true),
};
let tabs: Vec<Value> = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
Ok(json!({
"x": x, "y": y, "width": w, "rotated": rotated,
"marginLeft": props.margin_left.unwrap_or(0.0),
"indent": props.indent.unwrap_or(0.0),
"tabs": tabs,
"defaultTab": props.default_tab.unwrap_or(72.0),
}))
}
const MAX_TABS: usize = 32;
fn tabs(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "format.tabs";
let fin = |v: Option<f64>| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0));
let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") {
"ctr" | "center" => "ctr",
"r" | "right" => "r",
"dec" | "decimal" => "dec",
_ => "l",
};
let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string())));
let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?)));
let remove = fin(p.get("remove").and_then(Value::as_f64));
// `set` replaces every stop: entries without a usable position are dropped, an unknown
// alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins).
let set = match p.get("set") {
None => None,
Some(Value::Array(items)) => {
if items.len() > 64 {
return Err(bad(CMD, "`set` has more than 64 tab stops"));
}
let mut out: Vec<TabStop> = Vec::new();
for (i, item) in items.iter().enumerate() {
let Some(obj) = item.as_object() else {
return Err(bad(CMD, format!("tab stop {i} must be an object")));
};
let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else {
continue;
};
let align = match obj.get("align").and_then(Value::as_str) {
None => "l",
Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?,
};
if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) {
*prev = TabStop { pos, align: align.to_string() };
} else {
out.push(TabStop { pos, align: align.to_string() });
}
}
Some(out)
}
Some(_) => return Err(bad(CMD, "`set` must be an array")),
};
let clear = bool_or(p, "clear", false);
let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0));
let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear;
if !edits_stops && default_tab.is_none() {
// No fields: open the Tabs dialog on the caret's paragraph.
s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) });
return ok();
}
// The stops as the caret's paragraph shows them (inherited ones included), so editing a
// placeholder's tab that came from its layout works like editing one set on the paragraph.
let base = current_para(s).tabs.unwrap_or_default();
let near = |a: f64, b: f64| (a - b).abs() < 0.5;
let mut stops = if clear { vec![] } else { set.unwrap_or(base) };
if let Some(at) = remove {
stops.retain(|t| !near(t.pos, at));
}
if let Some((from, to)) = mv
&& let Some(t) = stops.iter_mut().find(|t| near(t.pos, from))
{
t.pos = to;
}
if let Some((pos, align)) = add {
stops.retain(|t| !near(t.pos, pos));
stops.push(TabStop { pos, align });
}
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
stops.dedup_by(|a, b| near(a.pos, b.pos));
stops.truncate(MAX_TABS);
format_para(s, &move |para| {
if edits_stops {
para.props.tabs = Some(stops.clone());
}
if let Some(d) = default_tab {
para.props.default_tab = Some(d);
}
})
}
#[cfg(test)]
#[path = "ruler_tests.rs"]
mod ruler_tests;
#[cfg(test)]
mod tests {
use deckcraft_model::text::{Align, Caps, ParaProps, Spacing, Strike};
use deckcraft_model::{ColorBase, Fill, SchemeSlot};
use serde_json::json;
use super::*;
use crate::{Session, UiRequest};
fn shape() -> Session {
let mut s = Session::with_new();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "Hello"})).unwrap();
s
}
fn props(s: &Session) -> RunProps {
s.doc()
.unwrap()
.selected_shapes()
.first()
.and_then(|sh| sh.text.as_ref())
.and_then(|t| t.paragraphs.first())
.and_then(|p| p.runs.first())
.unwrap()
.props
.clone()
}
#[test]
fn font_dialog_applies_character_formatting_in_one_undo() {
let mut s = shape();
let before = s.doc().unwrap().history.undo.len();
s.execute(
"format.fontDialog",
&json!({
"family": "Liberation Serif",
"size": 28,
"bold": true,
"italic": false,
"underline": "wavy",
"color": "#112233",
"strike": "dbl",
"baseline": "super",
"caps": "small",
"spacing": 3
}),
)
.unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), before + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Font…");
let r = props(&s);
assert_eq!(r.font.as_deref(), Some("Liberation Serif"));
assert_eq!(r.size, Some(28.0));
assert_eq!(r.bold, Some(true));
assert_eq!(r.italic, Some(false));
assert_eq!(r.underline.as_deref(), Some("wavy"));
assert!(matches!(r.fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Rgb { .. })));
assert_eq!(r.strike, Some(Strike::Double));
assert_eq!(r.baseline, Some(0.3));
assert_eq!(r.caps, Some(Caps::Small));
assert_eq!(r.spacing, Some(3.0));
s.execute("edit.undo", &json!({})).unwrap();
let r = props(&s);
assert!(r.font.is_none() && r.size.is_none() && r.bold.is_none() && r.underline.is_none() && r.fill.is_none());
assert_eq!(r.strike, None);
assert_eq!(r.caps, None);
}
#[test]
fn font_dialog_changes_only_the_fields_it_is_given() {
let mut s = shape();
s.execute("format.bold", &json!({"on": true})).unwrap();
s.execute("format.fontDialog", &json!({"size": 22, "color": {"scheme": "accent2", "lumMod": 50000}})).unwrap();
let r = props(&s);
assert_eq!(r.bold, Some(true));
assert_eq!(r.size, Some(22.0));
assert!(r.font.is_none() && r.italic.is_none());
let Some(Fill::Solid { color }) = r.fill else { panic!("solid fill") };
assert!(matches!(color.base, ColorBase::Scheme { slot: SchemeSlot::Accent2 }));
assert!(color.mods.iter().any(|m| m.xml_name() == "lumMod"));
s.execute("edit.undo", &json!({})).unwrap();
let r = props(&s);
assert_eq!(r.bold, Some(true));
assert!(r.size.is_none() && r.fill.is_none());
}
#[test]
fn font_dialog_with_no_fields_asks_the_ui_to_open() {
let mut s = shape();
s.execute("format.italic", &json!({"on": true})).unwrap();
let rev = s.doc().unwrap().revision;
let n = s.doc().unwrap().history.undo.len();
s.ui_requests.clear();
s.execute("format.fontDialog", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().revision, rev);
assert_eq!(s.doc().unwrap().history.undo.len(), n);
let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") };
assert_eq!(id, "font");
assert_eq!(params["italic"], true);
assert_eq!(params["bold"], false);
assert!(params["family"].as_str().is_some_and(|f| !f.is_empty()));
assert!(params["size"].as_f64().is_some_and(|n| n > 0.0));
}
#[test]
fn font_dialog_rejects_bad_fields_without_editing() {
let mut s = shape();
let n = s.doc().unwrap().history.undo.len();
let err = s.execute("format.fontDialog", &json!({"underline": "sparkle"})).unwrap_err();
assert!(err.to_string().contains("underline"), "{err}");
assert!(s.execute("format.fontDialog", &json!({"size": "large"})).is_err());
assert!(s.execute("format.fontDialog", &json!({"bold": 1})).is_err());
assert!(s.execute("format.fontDialog", &json!({"family": " "})).is_err());
assert!(s.execute("format.fontDialog", &json!({"color": "not-a-color"})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), n);
assert!(props(&s).underline.is_none());
s.execute("edit.deselect", &json!({})).unwrap();
assert!(s.execute("format.fontDialog", &json!({"bold": true})).is_err());
}
fn para(s: &Session) -> ParaProps {
s.doc().unwrap().selected_shapes().first().and_then(|sh| sh.text.as_ref()).and_then(|t| t.paragraphs.first()).unwrap().props.clone()
}
#[test]
fn paragraph_dialog_applies_alignment_indent_and_spacing_in_one_undo() {
let mut s = shape();
let before = s.doc().unwrap().history.undo.len();
s.execute(
"format.paragraphDialog",
&json!({
"align": "justify",
"indentLeft": 36.0,
"indentRight": 18.0,
"special": "hanging",
"specialBy": 18.0,
"spaceBefore": 6.0,
"spaceAfter": 8.0,
"lineSpacing": 1.5
}),
)
.unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), before + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Paragraph…");
let p = para(&s);
assert_eq!(p.align, Some(Align::Justify));
assert_eq!(p.margin_left, Some(36.0));
assert_eq!(p.margin_right, Some(18.0));
assert_eq!(p.indent, Some(-18.0));
assert_eq!(p.space_before, Some(Spacing::Pts(6.0)));
assert_eq!(p.space_after, Some(Spacing::Pts(8.0)));
assert_eq!(p.line_spacing, Some(Spacing::Pct(1.5)));
s.execute("edit.undo", &json!({})).unwrap();
let p = para(&s);
assert_eq!(p.align, Some(Align::Center), "undo restores the alignment shape.insert wrote");
assert!(p.margin_left.is_none() && p.indent.is_none() && p.space_before.is_none() && p.line_spacing.is_none());
}
#[test]
fn paragraph_dialog_first_line_and_exact_spacing_replace_only_those_fields() {
let mut s = shape();
s.execute("format.paragraphDialog", &json!({"align": "right"})).unwrap();
s.execute("format.paragraphDialog", &json!({"special": "first", "specialBy": 27.0, "lineSpacingPt": 20.0})).unwrap();
let p = para(&s);
assert_eq!(p.align, Some(Align::Right));
assert_eq!(p.indent, Some(27.0));
assert_eq!(p.line_spacing, Some(Spacing::Pts(20.0)));
assert!(p.margin_left.is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(para(&s).align, Some(Align::Right));
assert!(para(&s).indent.is_none());
}
#[test]
fn paragraph_dialog_with_no_fields_asks_the_ui_to_open() {
let mut s = shape();
let rev = s.doc().unwrap().revision;
let n = s.doc().unwrap().history.undo.len();
s.ui_requests.clear();
s.execute("format.paragraphDialog", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().revision, rev);
assert_eq!(s.doc().unwrap().history.undo.len(), n);
let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") };
assert_eq!(id, "paragraph");
assert_eq!(params["align"], "center", "a new shape's text is centred");
assert_eq!(params["special"], "none");
assert_eq!(params["lineKind"], "multiple");
assert!(params["lineValue"].as_f64().is_some_and(|v| v > 0.0));
}
#[test]
fn paragraph_dialog_rejects_bad_fields_without_editing() {
let mut s = shape();
let n = s.doc().unwrap().history.undo.len();
assert!(s.execute("format.paragraphDialog", &json!({"align": "sparkle"})).unwrap_err().to_string().contains("align"));
assert!(s.execute("format.paragraphDialog", &json!({"special": "middle"})).is_err());
assert!(s.execute("format.paragraphDialog", &json!({"indentLeft": "wide"})).is_err());
assert!(s.execute("format.paragraphDialog", &json!({"lineSpacing": "double"})).is_err());
assert!(s.execute("format.paragraphDialog", &json!({"spaceBeforeKind": "lines", "spaceBefore": 4.0})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), n);
assert_eq!(para(&s).align, Some(Align::Center));
assert!(para(&s).indent.is_none());
}
#[test]
fn tabs_dialog_sets_stops_in_one_undo_and_opens_with_no_fields() {
let mut s = shape();
let before = s.doc().unwrap().history.undo.len();
s.execute(
"format.tabs",
&json!({"set": [{"pos": 144.0, "align": "center"}, {"pos": 72.0, "align": "dec"}, {"pos": 72.02, "align": "right"}], "defaultTab": 36.0}),
)
.unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), before + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Tabs…");
let p = para(&s);
let tabs = p.tabs.unwrap();
assert_eq!(tabs.len(), 2, "stops within 0.05 pt collapse");
assert_eq!(tabs[0].pos, 72.02);
assert_eq!(tabs[0].align, "r");
assert_eq!(tabs[1].align, "ctr");
assert_eq!(p.default_tab, Some(36.0));
s.execute("edit.undo", &json!({})).unwrap();
assert!(para(&s).tabs.is_none() && para(&s).default_tab.is_none());
let n = s.doc().unwrap().history.undo.len();
s.ui_requests.clear();
s.execute("format.tabs", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n);
let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") };
assert_eq!(id, "tabs");
assert!(params["tabs"].as_array().is_some_and(|a| a.is_empty()));
assert!(s.execute("format.tabs", &json!({"set": [{"pos": 10.0, "align": "sparkle"}]})).is_err());
assert!(s.execute("format.tabs", &json!({"set": "nope"})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), n);
}
}