Files
deckcraft/crates/engine/src/cmd/format/font.rs
T
Brandon Thomas fb05eaa30f Merge pull request #1490
Make layout/master placeholder formatting reach slides

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-31 00:49:37 -04:00

469 lines
18 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.
use super::{
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param,
};
pub(super) 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))
}
pub(super) 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))
}
pub(super) 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() }))
}
pub(super) 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
})
})
}
pub(super) 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 }))
}
pub(super) 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 }))
}
pub(super) 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()))
}
pub(super) 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;
// A layout/master level steps from its own effective size, not the first run's.
format_run_levels(
s,
&move |r| {
let cur = r.size.unwrap_or(base);
r.size = Some(step(cur, up));
},
&move |r, eff| {
let cur = r.size.or(eff.size).unwrap_or(18.0);
r.size = Some(step(cur, up));
},
)
}
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
grow_shrink(s, true)
}
pub(super) fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {
grow_shrink(s, false)
}
pub(super) 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`"))?;
// A selected chart has no runs: Font Color colours its text (title, axes, legend, labels),
// which used to answer and change nothing.
let st = s.doc()?;
if st.selection.text.is_none() {
let charts: Vec<ShapeId> =
st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect();
let only_charts = charts.len() == st.selection.shapes.len();
if !charts.is_empty() {
let tc = c.clone();
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
if let ShapeKind::Chart(ch) = &mut sh.kind {
ch.text_color = Some(tc.clone());
ch.raw = None;
}
Ok(())
})?;
if only_charts {
return ok();
}
}
}
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.
pub(super) 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);
})
}
/// The `lang` and `noProof` params of the language commands, checked: a run-props setter.
fn language_edit(id: &str, p: &Value) -> Result<impl Fn(&mut RunProps) + Send + Sync + 'static> {
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(id, "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(id, "`lang` is a language tag such as en-US"));
}
Ok(move |r: &mut RunProps| {
if let Some(l) = &lang {
r.lang = Some(l.clone());
}
if let Some(n) = no_proof {
r.no_proof = n.then_some(true);
}
})
}
/// Review › Language on the selected text (or the selected shapes' text).
pub(super) fn language(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.language", p)?;
format_run(s, &f)
}
/// Every run in the deck, and the text styles new text inherits from, in one language.
pub(super) fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.languageAll", p)?;
/// Runs, end marks and the body's own list style levels (never creating one).
fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) {
ed::format_all(t, f);
t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
/// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep).
fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) {
if depth > 64 {
return;
}
for sh in list {
if let Some(t) = &mut sh.text {
body(t, f);
}
match &mut sh.kind {
deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)),
deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1),
_ => {}
}
}
}
/// A master or presentation text style's levels: what new text inherits.
fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) {
// Level 1 always, so a deck without a default text style gets one.
if l.level(0).is_none() {
l.set(0, Default::default());
}
l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
s.edit(|doc, _| {
for sl in &mut doc.slides {
let sl = Arc::make_mut(sl);
shapes(&mut sl.shapes, &f, 0);
body(&mut sl.notes, &f);
}
let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()];
for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) {
let m = Arc::make_mut(m);
shapes(&mut m.shapes, &f, 0);
m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0));
[&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f));
}
styles(&mut doc.default_text_style, &f);
Ok(())
})?;
ok()
}
pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
if c.is_none() && p.get("color").is_some_and(|v| !v.is_null()) {
return Err(bad("format.highlight", "`color` is not a valid colour"));
}
format_run(s, &move |r| r.highlight = c.clone())
}
pub(super) 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))
}
pub(super) 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| crate::cmd::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
}
pub(super) 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))
}
pub(super) 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() })
}