Files
deckcraft/crates/pptx/src/write/dml.rs
T
Brandon ThomasandClaude Opus 5.5 8b160d5b03 Text: wrap URLs, Korean and CJK punctuation where PowerPoint does (#276)
* 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>
2026-10-12 17:47:12 -04:00

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()))
}