mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
* Text: wrap lines where PowerPoint does - Without latinLnBrk (PowerPoint's default) a word doesn't break after '/' or '\': URLs and paths fill the line and break at its edge. - Korean wraps between words, not between syllables. - With hangingPunct, closing East Asian punctuation hangs past the end of the line instead of dragging the previous character down. ParaProps gains latin_line_break and hanging_punct, read and written as latinLnBrk and hangingPunct. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Text: simpler break guard (clippy) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
677 lines
24 KiB
Rust
677 lines
24 KiB
Rust
//! DrawingML writing: colours, fills, lines, effects, transforms and text.
|
|
|
|
use deckcraft_geom::{Xfrm, pt_to_emu};
|
|
use deckcraft_model::style::{ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientShape, Line, LineCap, LineJoin, PictureFill, PictureMode};
|
|
use deckcraft_model::text::{
|
|
Action, AutoFit, BodyProps, Bullet, Caps, Hyperlink, ListStyle, ParaProps, Paragraph, RunKind, RunProps, Spacing, Strike, TextBody,
|
|
};
|
|
|
|
use super::{Exp, Out};
|
|
use crate::xml::{A, W};
|
|
|
|
pub fn emu(pt: f64) -> i64 {
|
|
pt_to_emu(pt).clamp(-27_273_042_316_900, 27_273_042_316_900)
|
|
}
|
|
|
|
/// Positive coordinate extent (ST_PositiveCoordinate).
|
|
pub fn emu_pos(pt: f64) -> i64 {
|
|
emu(pt).max(0)
|
|
}
|
|
|
|
/// Fraction → 1/100 000.
|
|
pub fn pct(v: f64) -> i64 {
|
|
if v.is_finite() { (v * 100_000.0).round().clamp(-2.0e9, 2.0e9) as i64 } else { 0 }
|
|
}
|
|
|
|
/// Degrees → 1/60 000 degree, normalised to 0..360.
|
|
pub fn ang(deg: f64) -> i64 {
|
|
if !deg.is_finite() {
|
|
return 0;
|
|
}
|
|
let d = deg.rem_euclid(360.0);
|
|
((d * 60_000.0).round() as i64).clamp(0, 21_599_999)
|
|
}
|
|
|
|
pub fn color(w: &mut W, c: &ColorRef) {
|
|
let (tag, a) = match &c.base {
|
|
ColorBase::Rgb { rgb } => ("a:srgbClr", A::new().a("val", rgb.hex())),
|
|
ColorBase::Scheme { slot } => ("a:schemeClr", A::new().a("val", slot.xml_name())),
|
|
ColorBase::Preset { name } => ("a:prstClr", A::new().a("val", if name.is_empty() { "black" } else { name })),
|
|
ColorBase::System { name, last } => {
|
|
("a:sysClr", A::new().a("val", if name.is_empty() { "windowText" } else { name }).a("lastClr", last.hex()))
|
|
}
|
|
};
|
|
let rgb_alpha = match &c.base {
|
|
ColorBase::Rgb { rgb } if rgb.a < 255 && !c.mods.iter().any(|m| matches!(m, deckcraft_color::ColorTransform::Alpha(_))) => {
|
|
Some((rgb.a as i64 * 100_000 / 255) as i32)
|
|
}
|
|
_ => None,
|
|
};
|
|
if c.mods.is_empty() && rgb_alpha.is_none() {
|
|
w.empty(tag, a);
|
|
return;
|
|
}
|
|
w.open(tag, a);
|
|
for m in &c.mods {
|
|
let name = format!("a:{}", m.xml_name());
|
|
match m.value() {
|
|
Some(v) => w.val(&name, v),
|
|
None => w.empty0(&name),
|
|
}
|
|
}
|
|
if let Some(al) = rgb_alpha {
|
|
w.val("a:alpha", al);
|
|
}
|
|
w.close(tag);
|
|
}
|
|
|
|
pub fn solid(w: &mut W, c: &ColorRef) {
|
|
w.open0("a:solidFill");
|
|
color(w, c);
|
|
w.close("a:solidFill");
|
|
}
|
|
|
|
/// Write a fill. `Fill::Background` (only meaningful on `p:sp`) writes nothing.
|
|
pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
|
match f {
|
|
Fill::None => w.empty0("a:noFill"),
|
|
Fill::Solid { color: c } => solid(w, c),
|
|
Fill::Gradient(g) => {
|
|
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
|
w.open0("a:gsLst");
|
|
let mut stops = g.stops.clone();
|
|
if stops.is_empty() {
|
|
stops.push(deckcraft_model::style::GradientStop { pos: 0.0, color: ColorRef::rgb(deckcraft_color::Rgba::WHITE) });
|
|
}
|
|
if stops.len() == 1 {
|
|
let mut s = stops[0].clone();
|
|
s.pos = 1.0;
|
|
stops.push(s);
|
|
}
|
|
for s in &stops {
|
|
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
|
color(w, &s.color);
|
|
w.close("a:gs");
|
|
}
|
|
w.close("a:gsLst");
|
|
match &g.shape {
|
|
GradientShape::Linear { angle, scaled } => {
|
|
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
|
|
}
|
|
GradientShape::Path { path, focus } => {
|
|
let p = match path.as_str() {
|
|
"rect" | "shape" | "circle" => path.as_str(),
|
|
_ => "circle",
|
|
};
|
|
w.open("a:path", A::new().a("path", p));
|
|
w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3])));
|
|
w.close("a:path");
|
|
}
|
|
}
|
|
w.close("a:gradFill");
|
|
}
|
|
Fill::Picture(pf) => blip_fill(w, x, o, "a:blipFill", pf),
|
|
Fill::Pattern(p) => {
|
|
w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }));
|
|
w.open0("a:fgClr");
|
|
color(w, &p.fg);
|
|
w.close("a:fgClr");
|
|
w.open0("a:bgClr");
|
|
color(w, &p.bg);
|
|
w.close("a:bgClr");
|
|
w.close("a:pattFill");
|
|
}
|
|
Fill::Group => w.empty0("a:grpFill"),
|
|
Fill::Background => {}
|
|
}
|
|
}
|
|
|
|
/// `a:blipFill` / `p:blipFill`.
|
|
pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) {
|
|
let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o));
|
|
w.open(tag, A::new().a("rotWithShape", "1"));
|
|
let adj = &pf.adjust;
|
|
let has_kids =
|
|
pf.alpha.is_some() || adj.brightness != 0.0 || adj.contrast != 0.0 || adj.grayscale || adj.duotone.is_some() || adj.clear_color.is_some();
|
|
let a = A::new().a("r:embed", &rid);
|
|
if has_kids {
|
|
w.open("a:blip", a);
|
|
if let Some(c) = adj.clear_color {
|
|
w.open0("a:clrChange");
|
|
w.open0("a:clrFrom");
|
|
color(w, &ColorRef::rgb(c));
|
|
w.close("a:clrFrom");
|
|
w.open0("a:clrTo");
|
|
color(w, &ColorRef::rgb(c).with(deckcraft_color::ColorTransform::Alpha(0)));
|
|
w.close("a:clrTo");
|
|
w.close("a:clrChange");
|
|
}
|
|
if let Some(al) = pf.alpha {
|
|
w.empty("a:alphaModFix", A::new().a("amt", pct(al.clamp(0.0, 1.0))));
|
|
}
|
|
if let Some((d, l)) = &adj.duotone {
|
|
w.open0("a:duotone");
|
|
color(w, d);
|
|
color(w, l);
|
|
w.close("a:duotone");
|
|
}
|
|
if adj.grayscale {
|
|
w.empty0("a:grayscl");
|
|
}
|
|
if adj.brightness != 0.0 || adj.contrast != 0.0 {
|
|
w.empty("a:lum", A::new().a("bright", pct(adj.brightness.clamp(-1.0, 1.0))).a("contrast", pct(adj.contrast.clamp(-1.0, 1.0))));
|
|
}
|
|
w.close("a:blip");
|
|
} else {
|
|
w.empty("a:blip", a);
|
|
}
|
|
let c = pf.crop;
|
|
if c.iter().any(|v| *v != 0.0) {
|
|
w.empty("a:srcRect", A::new().a("l", pct(c[0])).a("t", pct(c[1])).a("r", pct(c[2])).a("b", pct(c[3])));
|
|
}
|
|
match &pf.mode {
|
|
PictureMode::Stretch { fill_rect: r } => {
|
|
w.open0("a:stretch");
|
|
if r.iter().any(|v| *v != 0.0) {
|
|
w.empty("a:fillRect", A::new().a("l", pct(r[0])).a("t", pct(r[1])).a("r", pct(r[2])).a("b", pct(r[3])));
|
|
} else {
|
|
w.empty0("a:fillRect");
|
|
}
|
|
w.close("a:stretch");
|
|
}
|
|
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
|
let flip = match flip.as_str() {
|
|
"x" | "y" | "xy" => flip.as_str(),
|
|
_ => "none",
|
|
};
|
|
let algn = match align.as_str() {
|
|
"tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br" => align.as_str(),
|
|
_ => "tl",
|
|
};
|
|
w.empty("a:tile", A::new().a("tx", emu(*tx)).a("ty", emu(*ty)).a("sx", pct(*sx)).a("sy", pct(*sy)).a("flip", flip).a("algn", algn));
|
|
}
|
|
}
|
|
w.close(tag);
|
|
}
|
|
|
|
pub fn line(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, l: &Line) {
|
|
let a = A::new()
|
|
.o("w", l.width.map(|v| emu(v.clamp(0.0, 1584.0))))
|
|
.o(
|
|
"cap",
|
|
l.cap.map(|c| match c {
|
|
LineCap::Round => "rnd",
|
|
LineCap::Square => "sq",
|
|
LineCap::Flat => "flat",
|
|
}),
|
|
)
|
|
.o("cmpd", l.compound.map(Compound::xml));
|
|
w.open(tag, a);
|
|
if let Some(f) = &l.fill {
|
|
match f {
|
|
Fill::None | Fill::Solid { .. } | Fill::Gradient(_) | Fill::Pattern(_) => fill(w, x, o, f),
|
|
_ => {}
|
|
}
|
|
}
|
|
match &l.dash {
|
|
Some(Dash::Preset { name }) => {
|
|
let n = if Dash::PRESETS.contains(&name.as_str()) { name.as_str() } else { "solid" };
|
|
w.val("a:prstDash", n);
|
|
}
|
|
Some(Dash::Custom { pattern }) if !pattern.is_empty() => {
|
|
w.open0("a:custDash");
|
|
for (d, s) in pattern.iter().take(64) {
|
|
w.empty("a:ds", A::new().a("d", pct(*d).max(0)).a("sp", pct(*s).max(0)));
|
|
}
|
|
w.close("a:custDash");
|
|
}
|
|
_ => {}
|
|
}
|
|
match l.join {
|
|
Some(LineJoin::Round) => w.empty0("a:round"),
|
|
Some(LineJoin::Bevel) => w.empty0("a:bevel"),
|
|
Some(LineJoin::Miter) => w.empty0("a:miter"),
|
|
None => {}
|
|
}
|
|
for (t, e) in [("a:headEnd", &l.head), ("a:tailEnd", &l.tail)] {
|
|
if let Some(e) = e {
|
|
let kind = match e.kind.as_str() {
|
|
"triangle" | "stealth" | "diamond" | "oval" | "arrow" => e.kind.as_str(),
|
|
_ => "none",
|
|
};
|
|
let sz = |s: &str| match s {
|
|
"sm" | "lg" => s.to_string(),
|
|
_ => "med".to_string(),
|
|
};
|
|
w.empty(t, A::new().a("type", kind).a("w", sz(&e.w)).a("len", sz(&e.len)));
|
|
}
|
|
}
|
|
w.close(tag);
|
|
}
|
|
|
|
fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) {
|
|
let mut a = A::new().a("blurRad", emu(s.blur).max(0)).a("dist", emu(s.dist).max(0)).a("dir", ang(s.dir));
|
|
if tag == "a:outerShdw" {
|
|
if s.sx != 1.0 {
|
|
a = a.a("sx", pct(s.sx));
|
|
}
|
|
if s.sy != 1.0 {
|
|
a = a.a("sy", pct(s.sy));
|
|
}
|
|
if s.kx != 0.0 {
|
|
a = a.a("kx", (s.kx.clamp(-89.0, 89.0) * 60_000.0).round() as i64);
|
|
}
|
|
if s.ky != 0.0 {
|
|
a = a.a("ky", (s.ky.clamp(-89.0, 89.0) * 60_000.0).round() as i64);
|
|
}
|
|
let algn = match s.align.as_str() {
|
|
"tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br" => s.align.as_str(),
|
|
_ => "b",
|
|
};
|
|
a = a.a("algn", algn).a("rotWithShape", if s.rotate_with_shape { "1" } else { "0" });
|
|
}
|
|
w.open(tag, a);
|
|
color(w, &s.color);
|
|
w.close(tag);
|
|
}
|
|
|
|
pub fn effects(w: &mut W, fx: &Effects) {
|
|
w.open0("a:effectLst");
|
|
if let Some(g) = &fx.glow {
|
|
w.open("a:glow", A::new().a("rad", emu(g.radius).max(0)));
|
|
color(w, &g.color);
|
|
w.close("a:glow");
|
|
}
|
|
if let Some(s) = &fx.inner_shadow {
|
|
shadow(w, "a:innerShdw", s);
|
|
}
|
|
if let Some(s) = &fx.outer_shadow {
|
|
shadow(w, "a:outerShdw", s);
|
|
}
|
|
if let Some(r) = &fx.reflection {
|
|
w.empty(
|
|
"a:reflection",
|
|
A::new()
|
|
.a("blurRad", emu(r.blur).max(0))
|
|
.a("stA", pct(r.start_alpha.clamp(0.0, 1.0)))
|
|
.a("endA", pct(r.end_alpha.clamp(0.0, 1.0)))
|
|
.a("endPos", pct(r.end_pos.clamp(0.0, 1.0)))
|
|
.a("dist", emu(r.dist).max(0))
|
|
.a("dir", 5_400_000)
|
|
.a("sy", -100_000)
|
|
.a("algn", "bl")
|
|
.a("rotWithShape", "0"),
|
|
);
|
|
}
|
|
if let Some(r) = fx.soft_edge {
|
|
w.empty("a:softEdge", A::new().a("rad", emu(r).max(0)));
|
|
}
|
|
w.close("a:effectLst");
|
|
}
|
|
|
|
pub fn xfrm(w: &mut W, tag: &str, x: &Xfrm, child: Option<&Xfrm>) {
|
|
let x = if x.is_finite() { *x } else { Xfrm::default() };
|
|
let rot = ang(x.rot);
|
|
w.open(tag, A::new().o("rot", (rot != 0).then_some(rot)).t("flipH", x.flip_h).t("flipV", x.flip_v));
|
|
w.empty("a:off", A::new().a("x", emu(x.x)).a("y", emu(x.y)));
|
|
w.empty("a:ext", A::new().a("cx", emu_pos(x.w)).a("cy", emu_pos(x.h)));
|
|
if let Some(c) = child {
|
|
let c = if c.is_finite() { *c } else { Xfrm::default() };
|
|
w.empty("a:chOff", A::new().a("x", emu(c.x)).a("y", emu(c.y)));
|
|
w.empty("a:chExt", A::new().a("cx", emu_pos(c.w)).a("cy", emu_pos(c.h)));
|
|
}
|
|
w.close(tag);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Text
|
|
|
|
pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink) {
|
|
let (rid, action) = match &h.action {
|
|
Action::Url { url } => {
|
|
if url.is_empty() {
|
|
return;
|
|
}
|
|
(o.rels.add("hyperlink", url, true), None)
|
|
}
|
|
Action::Slide { slide } => match x.slide_parts.get(slide) {
|
|
Some(target) => {
|
|
let rel = crate::opc::relative(&o.name, target);
|
|
(o.rels.add("slide", &rel, false), Some("ppaction://hlinksldjump".to_string()))
|
|
}
|
|
None => return,
|
|
},
|
|
Action::NextSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=nextslide".into())),
|
|
Action::PreviousSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=previousslide".into())),
|
|
Action::FirstSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=firstslide".into())),
|
|
Action::LastSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=lastslide".into())),
|
|
Action::EndShow => (String::new(), Some("ppaction://hlinkshowjump?jump=endshow".into())),
|
|
Action::LastViewed => (String::new(), Some("ppaction://hlinkshowjump?jump=lastslideviewed".into())),
|
|
Action::CustomShow { name } => {
|
|
let id = x.p.custom_shows.iter().position(|c| c.name == *name).unwrap_or(0);
|
|
(String::new(), Some(format!("ppaction://customshow?id={id}&return=true")))
|
|
}
|
|
Action::Program { path } => {
|
|
if path.is_empty() {
|
|
return;
|
|
}
|
|
(o.rels.add("hyperlink", path, true), Some("ppaction://program".into()))
|
|
}
|
|
Action::PlayMedia => (String::new(), Some("ppaction://media".into())),
|
|
};
|
|
w.empty(
|
|
tag,
|
|
A::new()
|
|
.a("r:id", rid)
|
|
.o("action", action)
|
|
.o("tooltip", (!h.tooltip.is_empty()).then_some(h.tooltip.as_str()))
|
|
.t("highlightClick", h.highlight_click),
|
|
);
|
|
}
|
|
|
|
pub fn body_pr(w: &mut W, b: &BodyProps) {
|
|
let a = A::new()
|
|
.o("rot", b.rot.map(|r| (r * 60_000.0).round().clamp(-21_600_000.0, 21_600_000.0) as i64))
|
|
.o("vert", b.vert.map(|v| v.xml()))
|
|
.o("wrap", b.wrap.map(|v| if v { "square" } else { "none" }))
|
|
.o("lIns", b.inset_l.map(emu))
|
|
.o("tIns", b.inset_t.map(emu))
|
|
.o("rIns", b.inset_r.map(emu))
|
|
.o("bIns", b.inset_b.map(emu))
|
|
.o("numCol", b.columns.map(|c| c.clamp(1, 16)))
|
|
.o("spcCol", b.col_spacing.map(|v| emu(v).max(0)))
|
|
.b("rtlCol", b.rtl_col)
|
|
.o("anchor", b.anchor.map(|a| a.xml()))
|
|
.b("anchorCtr", b.anchor_ctr)
|
|
.b("upright", b.upright);
|
|
let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
|
|
if warp.is_none() && b.autofit.is_none() {
|
|
w.empty("a:bodyPr", a);
|
|
return;
|
|
}
|
|
w.open("a:bodyPr", a);
|
|
if let Some(p) = warp {
|
|
w.open("a:prstTxWarp", A::new().a("prst", p));
|
|
w.empty0("a:avLst");
|
|
w.close("a:prstTxWarp");
|
|
}
|
|
match b.autofit {
|
|
Some(AutoFit::None) => w.empty0("a:noAutofit"),
|
|
Some(AutoFit::Shape) => w.empty0("a:spAutoFit"),
|
|
Some(AutoFit::Shrink { font_scale, line_reduction }) => {
|
|
let fs = pct(font_scale.clamp(0.01, 1.0));
|
|
let lr = pct(line_reduction.clamp(0.0, 1.0));
|
|
w.empty("a:normAutofit", A::new().o("fontScale", (fs != 100_000).then_some(fs)).o("lnSpcReduction", (lr != 0).then_some(lr)));
|
|
}
|
|
None => {}
|
|
}
|
|
w.close("a:bodyPr");
|
|
}
|
|
|
|
fn spacing(w: &mut W, tag: &str, s: &Spacing) {
|
|
w.open0(tag);
|
|
match s {
|
|
Spacing::Pct(v) => w.val("a:spcPct", pct(v.clamp(0.0, 132.0))),
|
|
Spacing::Pts(v) => w.val("a:spcPts", (v.clamp(0.0, 1584.0) * 100.0).round() as i64),
|
|
}
|
|
w.close(tag);
|
|
}
|
|
|
|
pub fn is_default_ppr(p: &ParaProps) -> bool {
|
|
*p == ParaProps::default()
|
|
}
|
|
|
|
/// `a:pPr` / `a:lvlNpPr`.
|
|
pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p: &ParaProps, def: Option<&RunProps>) {
|
|
let a = A::new()
|
|
.o("marL", p.margin_left.map(|v| emu(v.clamp(0.0, 4032.0))))
|
|
.o("marR", p.margin_right.map(|v| emu(v.clamp(0.0, 4032.0))))
|
|
.o("lvl", level.filter(|l| *l > 0).map(|l| l.min(8)))
|
|
.o("indent", p.indent.map(|v| emu(v.clamp(-4032.0, 4032.0))))
|
|
.o("algn", p.align.map(|a| a.xml()))
|
|
.o("defTabSz", p.default_tab.map(|v| emu(v).max(0)))
|
|
.b("rtl", p.rtl)
|
|
.b("eaLnBrk", p.east_asian_line_break)
|
|
.b("latinLnBrk", p.latin_line_break)
|
|
.b("hangingPunct", p.hanging_punct)
|
|
.o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b")));
|
|
let has_kids = p.line_spacing.is_some()
|
|
|| p.space_before.is_some()
|
|
|| p.space_after.is_some()
|
|
|| p.bullet.is_some()
|
|
|| p.bullet_font.is_some()
|
|
|| p.bullet_color.is_some()
|
|
|| p.bullet_size.is_some()
|
|
|| p.tabs.is_some()
|
|
|| def.is_some();
|
|
if !has_kids {
|
|
w.empty(tag, a);
|
|
return;
|
|
}
|
|
w.open(tag, a);
|
|
if let Some(s) = &p.line_spacing {
|
|
spacing(w, "a:lnSpc", s);
|
|
}
|
|
if let Some(s) = &p.space_before {
|
|
spacing(w, "a:spcBef", s);
|
|
}
|
|
if let Some(s) = &p.space_after {
|
|
spacing(w, "a:spcAft", s);
|
|
}
|
|
if let Some(c) = &p.bullet_color {
|
|
w.open0("a:buClr");
|
|
color(w, c);
|
|
w.close("a:buClr");
|
|
}
|
|
if let Some(s) = p.bullet_size {
|
|
w.val("a:buSzPct", pct(s.clamp(0.25, 4.0)));
|
|
}
|
|
if let Some(f) = &p.bullet_font {
|
|
w.empty("a:buFont", A::new().a("typeface", f));
|
|
}
|
|
match &p.bullet {
|
|
Some(Bullet::None) => w.empty0("a:buNone"),
|
|
Some(Bullet::Char { char }) => w.empty("a:buChar", A::new().a("char", if char.is_empty() { "•" } else { char })),
|
|
Some(Bullet::AutoNum { scheme, start_at }) => {
|
|
let s = if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric()) { "arabicPeriod" } else { scheme };
|
|
w.empty("a:buAutoNum", A::new().a("type", s).o("startAt", (*start_at != 1).then_some((*start_at).clamp(1, 32767))));
|
|
}
|
|
Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") {
|
|
Some(rid) => {
|
|
w.open0("a:buBlip");
|
|
w.empty("a:blip", A::new().a("r:embed", rid));
|
|
w.close("a:buBlip");
|
|
}
|
|
None => w.empty("a:buChar", A::new().a("char", "•")),
|
|
},
|
|
None => {}
|
|
}
|
|
if let Some(t) = &p.tabs {
|
|
w.open0("a:tabLst");
|
|
for tab in t.iter().take(32) {
|
|
let al = match tab.align.as_str() {
|
|
"ctr" | "r" | "dec" => tab.align.as_str(),
|
|
_ => "l",
|
|
};
|
|
w.empty("a:tab", A::new().a("pos", emu(tab.pos)).a("algn", al));
|
|
}
|
|
w.close("a:tabLst");
|
|
}
|
|
if let Some(d) = def {
|
|
rpr(w, x, o, "a:defRPr", d);
|
|
}
|
|
w.close(tag);
|
|
}
|
|
|
|
fn typeface(w: &mut W, tag: &str, f: &Option<String>) {
|
|
if let Some(f) = f {
|
|
w.empty(tag, A::new().a("typeface", f));
|
|
}
|
|
}
|
|
|
|
/// `a:rPr`, `a:defRPr`, `a:endParaRPr`.
|
|
pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) {
|
|
let a = A::new()
|
|
.o("lang", r.lang.as_deref().filter(|l| !l.is_empty()))
|
|
.o("sz", r.size.map(|s| (s.clamp(1.0, 4000.0) * 100.0).round() as i64))
|
|
.b("b", r.bold)
|
|
.b("i", r.italic)
|
|
.o("u", r.underline.as_deref().filter(|u| !u.is_empty() && u.chars().all(|c| c.is_ascii_alphanumeric())))
|
|
.o(
|
|
"strike",
|
|
r.strike.map(|s| match s {
|
|
Strike::None => "noStrike",
|
|
Strike::Single => "sngStrike",
|
|
Strike::Double => "dblStrike",
|
|
}),
|
|
)
|
|
.o("kern", r.kern.map(|k| (k.clamp(0.0, 4000.0) * 100.0).round() as i64))
|
|
.o(
|
|
"cap",
|
|
r.caps.map(|c| match c {
|
|
Caps::None => "none",
|
|
Caps::Small => "small",
|
|
Caps::All => "all",
|
|
}),
|
|
)
|
|
.o("spc", r.spacing.map(|s| (s.clamp(-4000.0, 4000.0) * 100.0).round() as i64))
|
|
.o("baseline", r.baseline.map(|b| pct(b.clamp(-10.0, 10.0))))
|
|
.b("noProof", r.no_proof);
|
|
let has_kids = r.outline.is_some()
|
|
|| r.fill.is_some()
|
|
|| r.shadow.is_some()
|
|
|| r.glow.is_some()
|
|
|| r.highlight.is_some()
|
|
|| r.underline_color.is_some()
|
|
|| r.font.is_some()
|
|
|| r.font_ea.is_some()
|
|
|| r.font_cs.is_some()
|
|
|| r.font_sym.is_some()
|
|
|| r.link.is_some();
|
|
if !has_kids {
|
|
w.empty(tag, a);
|
|
return;
|
|
}
|
|
w.open(tag, a);
|
|
if let Some(l) = &r.outline {
|
|
line(w, x, o, "a:ln", l);
|
|
}
|
|
if let Some(f) = &r.fill {
|
|
match f {
|
|
Fill::Background => {}
|
|
f => fill(w, x, o, f),
|
|
}
|
|
}
|
|
if r.shadow.is_some() || r.glow.is_some() {
|
|
effects(w, &Effects { outer_shadow: r.shadow.clone(), glow: r.glow.clone(), ..Default::default() });
|
|
}
|
|
if let Some(h) = &r.highlight {
|
|
w.open0("a:highlight");
|
|
color(w, h);
|
|
w.close("a:highlight");
|
|
}
|
|
if let Some(c) = &r.underline_color {
|
|
w.open0("a:uFill");
|
|
solid(w, c);
|
|
w.close("a:uFill");
|
|
}
|
|
typeface(w, "a:latin", &r.font);
|
|
typeface(w, "a:ea", &r.font_ea);
|
|
typeface(w, "a:cs", &r.font_cs);
|
|
typeface(w, "a:sym", &r.font_sym);
|
|
if let Some(h) = &r.link {
|
|
hyperlink(w, x, o, "a:hlinkClick", h);
|
|
}
|
|
w.close(tag);
|
|
}
|
|
|
|
/// `a:lstStyle`-like element (`a:lstStyle`, `p:titleStyle`, `p:defaultTextStyle`…).
|
|
pub fn list_style(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, ls: &ListStyle) {
|
|
if ls.is_empty() {
|
|
w.empty0(tag);
|
|
return;
|
|
}
|
|
w.open0(tag);
|
|
for (i, l) in ls.levels.iter().enumerate().take(9) {
|
|
if let Some(l) = l {
|
|
let t = format!("a:lvl{}pPr", i + 1);
|
|
let def = (l.run != RunProps::default()).then_some(&l.run);
|
|
ppr(w, x, o, &t, None, &l.para, def);
|
|
}
|
|
}
|
|
w.close(tag);
|
|
}
|
|
|
|
/// Write `<tag>` text body (`p:txBody`, `a:txBody`). `math` selects the OMML form of equations.
|
|
pub fn text_body(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, tb: &TextBody, math: bool) {
|
|
w.open0(tag);
|
|
body_pr(w, &tb.body);
|
|
list_style(w, x, o, "a:lstStyle", &tb.list_style);
|
|
if tb.paragraphs.is_empty() {
|
|
w.empty0("a:p");
|
|
}
|
|
for (pi, p) in tb.paragraphs.iter().enumerate() {
|
|
paragraph(w, x, o, p, math, pi);
|
|
}
|
|
w.close(tag);
|
|
}
|
|
|
|
fn run_text(w: &mut W, x: &mut Exp, o: &mut Out, text: &str, props: &RunProps) {
|
|
for (i, piece) in text.split(['\n', '\u{b}']).enumerate() {
|
|
if i > 0 {
|
|
w.open0("a:br");
|
|
rpr(w, x, o, "a:rPr", props);
|
|
w.close("a:br");
|
|
}
|
|
if piece.is_empty() && i > 0 {
|
|
continue;
|
|
}
|
|
w.open0("a:r");
|
|
rpr(w, x, o, "a:rPr", props);
|
|
w.elt("a:t", piece);
|
|
w.close("a:r");
|
|
}
|
|
}
|
|
|
|
pub fn paragraph(w: &mut W, x: &mut Exp, o: &mut Out, p: &Paragraph, math: bool, index: usize) {
|
|
w.open0("a:p");
|
|
if p.level > 0 || !is_default_ppr(&p.props) {
|
|
ppr(w, x, o, "a:pPr", Some(p.level), &p.props, None);
|
|
}
|
|
for (ri, r) in p.runs.iter().enumerate() {
|
|
match &r.kind {
|
|
RunKind::Text => run_text(w, x, o, &r.text, &r.props),
|
|
RunKind::Break => {
|
|
w.open0("a:br");
|
|
rpr(w, x, o, "a:rPr", &r.props);
|
|
w.close("a:br");
|
|
}
|
|
RunKind::Field { field } => {
|
|
let id = crate::tables::guid(&format!("{}:{index}:{ri}:{field}", o.name));
|
|
let ty = (!field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')).then_some(field.as_str());
|
|
w.open("a:fld", A::new().a("id", id).o("type", ty));
|
|
rpr(w, x, o, "a:rPr", &r.props);
|
|
w.elt("a:t", &r.text);
|
|
w.close("a:fld");
|
|
}
|
|
RunKind::Math { omml } => {
|
|
if math && !omml.is_empty() {
|
|
w.open("a14:m", A::new().a("xmlns:a14", crate::opc::NS_A14));
|
|
w.raw(omml);
|
|
w.close("a14:m");
|
|
} else {
|
|
run_text(w, x, o, &r.text, &r.props);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if p.end_props != RunProps::default() {
|
|
rpr(w, x, o, "a:endParaRPr", &p.end_props);
|
|
}
|
|
w.close("a:p");
|
|
}
|
|
|
|
pub fn has_math(tb: &TextBody) -> bool {
|
|
tb.paragraphs.iter().flat_map(|p| &p.runs).any(|r| matches!(&r.kind, RunKind::Math { omml } if !omml.is_empty()))
|
|
}
|