Skip to main content

drawingml/
writer.rs

1//! Writing this crate's model out to DrawingML XML fragments.
2//!
3//! Every function here writes a **fragment**, never a whole XML document and never the
4//! `<a:spPr>`/`<xdr:spPr>`/`<p:spPr>`/`<wps:spPr>` wrapper element itself — that wrapper's name
5//! (and namespace prefix) is host-specific, so opening/closing it is each host crate's own
6//! responsibility (not yet wired into any of them — see the crate-level doc comment in `lib.rs`).
7//! Callers are expected to have already declared the
8//! `xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"` namespace on an ancestor
9//! element; nothing here writes that declaration.
10
11use std::io::Write;
12
13use xml_core::{BytesEnd, BytesStart, BytesText, Event, Writer};
14
15use crate::error::Result;
16use crate::model::{
17    BlipFill, BlipFillMode, BulletColor, BulletFont, BulletKind, BulletSize, Color, CropRect,
18    CustomGeometry, EffectList, Fill, Geometry, GeometryAdjustment, GradientFill, GradientStop,
19    Line, LineEnd, LineJoin, PathCommand, PatternFill, ShapeProperties, TextAutofit, TextBody,
20    TextBodyProperties, TextParagraph, TextParagraphProperties, TextRun, TextRunProperties,
21    TextSpacing, Transform2D,
22};
23
24/// Validates a [`TextRun::Field`]'s `id` against `ST_Guid`
25/// (`\{[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\}`, confirmed against the
26/// ECMA-376/ISO-IEC 29500-4 `dml-main.xsd` schema — `CT_TextField@id` is typed `s:ST_Guid`,
27/// required). Same bug class, and same fix shape, as `powerpoint-ooxml`'s table-style-id
28/// validation: a caller-supplied non-GUID id must fail loudly here rather than silently producing
29/// schema-invalid `<a:fld>` XML.
30fn validate_field_id(id: &str) -> Result<()> {
31    let bytes = id.as_bytes();
32    let is_hex_run = |s: &[u8], len: usize| s.len() == len && s.iter().all(u8::is_ascii_hexdigit);
33    let valid = bytes.len() == 38
34        && bytes[0] == b'{'
35        && bytes[37] == b'}'
36        && bytes[9] == b'-'
37        && bytes[14] == b'-'
38        && bytes[19] == b'-'
39        && bytes[24] == b'-'
40        && is_hex_run(&bytes[1..9], 8)
41        && is_hex_run(&bytes[10..14], 4)
42        && is_hex_run(&bytes[15..19], 4)
43        && is_hex_run(&bytes[20..24], 4)
44        && is_hex_run(&bytes[25..37], 12)
45        // ST_Guid's pattern is `[0-9A-F]`, not `[0-9A-Fa-f]` — reject lowercase too rather than
46        // silently accepting XML a strict validator would still flag.
47        && id.chars().all(|c| !c.is_ascii_hexdigit() || c.is_ascii_digit() || c.is_ascii_uppercase());
48    if valid {
49        Ok(())
50    } else {
51        Err(crate::error::Error::InvalidFieldId(id.to_string()))
52    }
53}
54
55/// Writes `<a:xfrm>`/`EG_Geometry`/`EG_FillProperties`/`<a:ln>`, in that schema order — the content
56/// of `CT_ShapeProperties`, i.e. what a host crate's own `<*:spPr>` wrapper should contain.
57pub fn write_shape_properties<W: Write>(
58    writer: &mut Writer<W>,
59    properties: &ShapeProperties,
60) -> Result<()> {
61    if let Some(transform) = &properties.transform {
62        write_transform(writer, transform)?;
63    }
64    if let Some(geometry) = &properties.geometry {
65        write_geometry(writer, geometry, &properties.geometry_adjustments)?;
66    }
67    if let Some(fill) = &properties.fill {
68        write_fill(writer, fill)?;
69    }
70    if let Some(line) = &properties.line {
71        write_line(writer, line)?;
72    }
73    if let Some(effects) = &properties.effects {
74        write_effect_list(writer, effects)?;
75    }
76    Ok(())
77}
78
79/// Writes `EG_Geometry`/`EG_FillProperties`/`<a:ln>`/`<a:effectLst>`, in that schema order — like
80/// [`write_shape_properties`], minus `<a:xfrm>`, and with one difference that matters in practice:
81/// when `properties.geometry` is unset, this writes a fixed `<a:prstGeom
82/// prst="{default_preset}"><a:avLst/></a:prstGeom>` fallback instead of omitting the geometry
83/// element entirely.
84///
85/// Shared by `word-ooxml`, `excel-ooxml`, and `powerpoint-ooxml`'s own picture/autoshape/connector
86/// write sites, since real Office silently refuses to render a shape whose `spPr` has a fill/line
87/// but no geometry element at all, so [`write_shape_properties`] alone (which only ever writes a
88/// geometry element when the caller explicitly set one) isn't safe to use for a picture or shape
89/// that must always render. A single shared implementation also avoids each host crate needing to
90/// remember every field independently — in particular `effects`, which is correctly read back into
91/// memory by the already-unified [`crate::read_shape_properties`] and must be written back out the
92/// same way. Callers write their own `<a:xfrm>` before
93/// calling this (deliberately excluded — see this function's own callers for why: Excel's picture
94/// position is anchor-driven and always writes a zero-valued placeholder `a:xfrm`; Word derives its
95/// `a:xfrm` from the image's own `width_emu`/`height_emu`, never from `properties.transform`;
96/// PowerPoint uses `properties.transform` directly).
97pub fn write_geometry_fill_line_effects<W: Write>(
98    writer: &mut Writer<W>,
99    properties: &ShapeProperties,
100    default_preset: &str,
101) -> Result<()> {
102    match &properties.geometry {
103        Some(geometry) => write_geometry(writer, geometry, &properties.geometry_adjustments)?,
104        None => {
105            let mut prst_geom = BytesStart::new("a:prstGeom");
106            prst_geom.push_attribute(("prst", default_preset));
107            writer.write_event(Event::Start(prst_geom))?;
108            writer.write_event(Event::Empty(BytesStart::new("a:avLst")))?;
109            writer.write_event(Event::End(BytesEnd::new("a:prstGeom")))?;
110        }
111    }
112    if let Some(fill) = &properties.fill {
113        write_fill(writer, fill)?;
114    }
115    if let Some(line) = &properties.line {
116        write_line(writer, line)?;
117    }
118    if let Some(effects) = &properties.effects {
119        write_effect_list(writer, effects)?;
120    }
121    Ok(())
122}
123
124/// Writes `<a:effectLst>`. Self-closing when every effect is unset (schema-valid: `CT_EffectList`'s
125/// own effect choices are all `minOccurs="0"`), otherwise wraps whichever of
126/// `<a:outerShdw>`/`<a:glow>`/`<a:reflection>`/`<a:softEdge>` are set, in that schema order
127/// (`EG_EffectProperties`'s own group definition).
128pub fn write_effect_list<W: Write>(writer: &mut Writer<W>, effects: &EffectList) -> Result<()> {
129    let has_children = effects.outer_shadow.is_some()
130        || effects.glow.is_some()
131        || effects.reflection.is_some()
132        || effects.soft_edge.is_some();
133    if !has_children {
134        writer.write_event(Event::Empty(BytesStart::new("a:effectLst")))?;
135        return Ok(());
136    }
137
138    writer.write_event(Event::Start(BytesStart::new("a:effectLst")))?;
139    if let Some(shadow) = &effects.outer_shadow {
140        let mut start = BytesStart::new("a:outerShdw");
141        if let Some(blur) = shadow.blur_radius_emu {
142            start.push_attribute(("blurRad", blur.to_string().as_str()));
143        }
144        if let Some(distance) = shadow.distance_emu {
145            start.push_attribute(("dist", distance.to_string().as_str()));
146        }
147        if let Some(direction) = shadow.direction_60000ths {
148            start.push_attribute(("dir", direction.to_string().as_str()));
149        }
150        writer.write_event(Event::Start(start))?;
151        write_color(writer, &shadow.color)?;
152        writer.write_event(Event::End(BytesEnd::new("a:outerShdw")))?;
153    }
154    if let Some(glow) = &effects.glow {
155        let mut start = BytesStart::new("a:glow");
156        start.push_attribute(("rad", glow.radius_emu.to_string().as_str()));
157        writer.write_event(Event::Start(start))?;
158        write_color(writer, &glow.color)?;
159        writer.write_event(Event::End(BytesEnd::new("a:glow")))?;
160    }
161    if let Some(reflection) = &effects.reflection {
162        let mut start = BytesStart::new("a:reflection");
163        if let Some(blur) = reflection.blur_radius_emu {
164            start.push_attribute(("blurRad", blur.to_string().as_str()));
165        }
166        if let Some(distance) = reflection.distance_emu {
167            start.push_attribute(("dist", distance.to_string().as_str()));
168        }
169        if let Some(direction) = reflection.direction_60000ths {
170            start.push_attribute(("dir", direction.to_string().as_str()));
171        }
172        if let Some(start_alpha) = reflection.start_alpha_1000ths_percent {
173            start.push_attribute(("stA", start_alpha.to_string().as_str()));
174        }
175        if let Some(end_alpha) = reflection.end_alpha_1000ths_percent {
176            start.push_attribute(("endA", end_alpha.to_string().as_str()));
177        }
178        writer.write_event(Event::Empty(start))?;
179    }
180    if let Some(soft_edge) = &effects.soft_edge {
181        let mut start = BytesStart::new("a:softEdge");
182        start.push_attribute(("rad", soft_edge.radius_emu.to_string().as_str()));
183        writer.write_event(Event::Empty(start))?;
184    }
185    writer.write_event(Event::End(BytesEnd::new("a:effectLst")))?;
186    Ok(())
187}
188
189/// Writes `<a:xfrm rot=".." flipH=".." flipV=".."><a:off../><a:ext../></a:xfrm>`.
190pub fn write_transform<W: Write>(writer: &mut Writer<W>, transform: &Transform2D) -> Result<()> {
191    let mut start = BytesStart::new("a:xfrm");
192    if transform.rotation_60000ths != 0 {
193        start.push_attribute(("rot", transform.rotation_60000ths.to_string().as_str()));
194    }
195    if transform.flip_horizontal {
196        start.push_attribute(("flipH", "1"));
197    }
198    if transform.flip_vertical {
199        start.push_attribute(("flipV", "1"));
200    }
201
202    if transform.offset.is_none() && transform.extent.is_none() {
203        writer.write_event(Event::Empty(start))?;
204        return Ok(());
205    }
206
207    writer.write_event(Event::Start(start))?;
208    if let Some((x, y)) = transform.offset {
209        let mut off = BytesStart::new("a:off");
210        off.push_attribute(("x", x.to_string().as_str()));
211        off.push_attribute(("y", y.to_string().as_str()));
212        writer.write_event(Event::Empty(off))?;
213    }
214    if let Some((cx, cy)) = transform.extent {
215        let mut ext = BytesStart::new("a:ext");
216        ext.push_attribute(("cx", cx.to_string().as_str()));
217        ext.push_attribute(("cy", cy.to_string().as_str()));
218        writer.write_event(Event::Empty(ext))?;
219    }
220    writer.write_event(Event::End(BytesEnd::new("a:xfrm")))?;
221    Ok(())
222}
223
224/// Writes `EG_Geometry` — `<a:prstGeom prst="..">` or `<a:custGeom>..` (see [`Geometry`]'s doc
225/// comment for what `custGeom` scope is modeled).
226///
227/// `adjustments` writes a preset shape's own `<a:avLst>` (adjustment handle values, e.g. a rounded
228/// rectangle's corner radius) — ignored for `Geometry::Custom` (which has no `avLst` of its own;
229/// see [`ShapeProperties::geometry_adjustments`]'s doc comment for why this is a separate
230/// parameter/field rather than living inside [`Geometry::Preset`] itself). Pass `&[]` when there
231/// are none to set (the overwhelmingly common case — most preset shapes use their own built-in
232/// defaults).
233pub fn write_geometry<W: Write>(
234    writer: &mut Writer<W>,
235    geometry: &Geometry,
236    adjustments: &[GeometryAdjustment],
237) -> Result<()> {
238    match geometry {
239        Geometry::Preset(shape) => {
240            let mut start = BytesStart::new("a:prstGeom");
241            start.push_attribute(("prst", shape.xml_token()));
242            if adjustments.is_empty() {
243                writer.write_event(Event::Empty(start))?;
244            } else {
245                writer.write_event(Event::Start(start))?;
246                writer.write_event(Event::Start(BytesStart::new("a:avLst")))?;
247                for adjustment in adjustments {
248                    let mut gd = BytesStart::new("a:gd");
249                    gd.push_attribute(("name", adjustment.name.as_str()));
250                    gd.push_attribute(("fmla", adjustment.formula.as_str()));
251                    writer.write_event(Event::Empty(gd))?;
252                }
253                writer.write_event(Event::End(BytesEnd::new("a:avLst")))?;
254                writer.write_event(Event::End(BytesEnd::new("a:prstGeom")))?;
255            }
256        }
257        Geometry::Custom(custom) => write_custom_geometry(writer, custom)?,
258    }
259    Ok(())
260}
261
262/// Writes `<a:custGeom><a:pathLst><a:path w="." h=".">`'s draw
263/// commands`</a:path></a:pathLst></a:custGeom>`. Omits
264/// `<a:avLst>`/`<a:gdLst>`/`<a:ahLst>`/`<a:cxnLst>`/`<a:rect>` entirely (all `minOccurs="0"`, none
265/// modeled — see [`Geometry::Custom`]'s doc comment).
266fn write_custom_geometry<W: Write>(writer: &mut Writer<W>, custom: &CustomGeometry) -> Result<()> {
267    writer.write_event(Event::Start(BytesStart::new("a:custGeom")))?;
268    writer.write_event(Event::Start(BytesStart::new("a:pathLst")))?;
269
270    let mut path = BytesStart::new("a:path");
271    path.push_attribute(("w", custom.width_emu.to_string().as_str()));
272    path.push_attribute(("h", custom.height_emu.to_string().as_str()));
273    if custom.commands.is_empty() {
274        writer.write_event(Event::Empty(path))?;
275    } else {
276        writer.write_event(Event::Start(path))?;
277        for command in &custom.commands {
278            write_path_command(writer, command)?;
279        }
280        writer.write_event(Event::End(BytesEnd::new("a:path")))?;
281    }
282
283    writer.write_event(Event::End(BytesEnd::new("a:pathLst")))?;
284    writer.write_event(Event::End(BytesEnd::new("a:custGeom")))?;
285    Ok(())
286}
287
288fn write_path_command<W: Write>(writer: &mut Writer<W>, command: &PathCommand) -> Result<()> {
289    match command {
290        PathCommand::MoveTo { x, y } => {
291            writer.write_event(Event::Start(BytesStart::new("a:moveTo")))?;
292            write_path_point(writer, *x, *y)?;
293            writer.write_event(Event::End(BytesEnd::new("a:moveTo")))?;
294        }
295        PathCommand::LineTo { x, y } => {
296            writer.write_event(Event::Start(BytesStart::new("a:lnTo")))?;
297            write_path_point(writer, *x, *y)?;
298            writer.write_event(Event::End(BytesEnd::new("a:lnTo")))?;
299        }
300        PathCommand::CubicBezierTo {
301            x1,
302            y1,
303            x2,
304            y2,
305            x,
306            y,
307        } => {
308            writer.write_event(Event::Start(BytesStart::new("a:cubicBezTo")))?;
309            write_path_point(writer, *x1, *y1)?;
310            write_path_point(writer, *x2, *y2)?;
311            write_path_point(writer, *x, *y)?;
312            writer.write_event(Event::End(BytesEnd::new("a:cubicBezTo")))?;
313        }
314        PathCommand::Close => {
315            writer.write_event(Event::Empty(BytesStart::new("a:close")))?;
316        }
317    }
318    Ok(())
319}
320
321fn write_path_point<W: Write>(writer: &mut Writer<W>, x: i64, y: i64) -> Result<()> {
322    let mut pt = BytesStart::new("a:pt");
323    pt.push_attribute(("x", x.to_string().as_str()));
324    pt.push_attribute(("y", y.to_string().as_str()));
325    writer.write_event(Event::Empty(pt))?;
326    Ok(())
327}
328
329/// Writes `EG_FillProperties` — `<a:noFill/>`, `<a:solidFill>..`, `<a:gradFill>..`,
330/// `<a:pattFill>..`, or `<a:blipFill>..`.
331pub fn write_fill<W: Write>(writer: &mut Writer<W>, fill: &Fill) -> Result<()> {
332    match fill {
333        Fill::None => writer.write_event(Event::Empty(BytesStart::new("a:noFill")))?,
334        Fill::Solid(color) => {
335            writer.write_event(Event::Start(BytesStart::new("a:solidFill")))?;
336            write_color(writer, color)?;
337            writer.write_event(Event::End(BytesEnd::new("a:solidFill")))?;
338        }
339        Fill::Gradient(gradient) => write_gradient_fill(writer, gradient)?,
340        Fill::Pattern(pattern) => write_pattern_fill(writer, pattern)?,
341        Fill::Image(blip_fill) => write_blip_fill(writer, blip_fill)?,
342    }
343    Ok(())
344}
345
346/// Writes `<a:blipFill>` — (`<a:blip>`/stretch-or-tile), (`<a:srcRect>`). See [`BlipFill`]'s doc
347/// comment for what else isn't modeled (no per-mode tiling/stretching sub-rects).
348pub fn write_blip_fill<W: Write>(writer: &mut Writer<W>, blip_fill: &BlipFill) -> Result<()> {
349    let has_children =
350        blip_fill.relationship_id.is_some() || blip_fill.crop.is_some() || blip_fill.mode.is_some();
351    if !has_children {
352        writer.write_event(Event::Empty(BytesStart::new("a:blipFill")))?;
353        return Ok(());
354    }
355
356    writer.write_event(Event::Start(BytesStart::new("a:blipFill")))?;
357    if let Some(relationship_id) = &blip_fill.relationship_id {
358        let mut blip = BytesStart::new("a:blip");
359        blip.push_attribute(("r:embed", relationship_id.as_str()));
360        writer.write_event(Event::Empty(blip))?;
361    }
362    if let Some(crop) = &blip_fill.crop {
363        write_crop_rect(writer, crop)?;
364    }
365    if let Some(mode) = &blip_fill.mode {
366        let element_name = match mode {
367            BlipFillMode::Stretch => "a:stretch",
368            BlipFillMode::Tile => "a:tile",
369        };
370        writer.write_event(Event::Empty(BytesStart::new(element_name)))?;
371    }
372    writer.write_event(Event::End(BytesEnd::new("a:blipFill")))?;
373    Ok(())
374}
375
376/// Writes `<a:srcRect l="." t="." r="." b="."/>`.
377fn write_crop_rect<W: Write>(writer: &mut Writer<W>, crop: &CropRect) -> Result<()> {
378    let mut start = BytesStart::new("a:srcRect");
379    if let Some(left) = crop.left_1000ths_percent {
380        start.push_attribute(("l", left.to_string().as_str()));
381    }
382    if let Some(top) = crop.top_1000ths_percent {
383        start.push_attribute(("t", top.to_string().as_str()));
384    }
385    if let Some(right) = crop.right_1000ths_percent {
386        start.push_attribute(("r", right.to_string().as_str()));
387    }
388    if let Some(bottom) = crop.bottom_1000ths_percent {
389        start.push_attribute(("b", bottom.to_string().as_str()));
390    }
391    writer.write_event(Event::Empty(start))?;
392    Ok(())
393}
394
395fn write_gradient_fill<W: Write>(writer: &mut Writer<W>, gradient: &GradientFill) -> Result<()> {
396    writer.write_event(Event::Start(BytesStart::new("a:gradFill")))?;
397
398    if !gradient.stops.is_empty() {
399        writer.write_event(Event::Start(BytesStart::new("a:gsLst")))?;
400        for stop in &gradient.stops {
401            write_gradient_stop(writer, stop)?;
402        }
403        writer.write_event(Event::End(BytesEnd::new("a:gsLst")))?;
404    }
405
406    if let Some(angle) = gradient.angle_60000ths {
407        let mut lin = BytesStart::new("a:lin");
408        lin.push_attribute(("ang", angle.to_string().as_str()));
409        writer.write_event(Event::Empty(lin))?;
410    }
411
412    writer.write_event(Event::End(BytesEnd::new("a:gradFill")))?;
413    Ok(())
414}
415
416fn write_gradient_stop<W: Write>(writer: &mut Writer<W>, stop: &GradientStop) -> Result<()> {
417    let mut start = BytesStart::new("a:gs");
418    start.push_attribute(("pos", stop.position_1000ths_percent.to_string().as_str()));
419    writer.write_event(Event::Start(start))?;
420    write_color(writer, &stop.color)?;
421    writer.write_event(Event::End(BytesEnd::new("a:gs")))?;
422    Ok(())
423}
424
425fn write_pattern_fill<W: Write>(writer: &mut Writer<W>, pattern: &PatternFill) -> Result<()> {
426    let mut start = BytesStart::new("a:pattFill");
427    start.push_attribute(("prst", pattern.preset.xml_token()));
428
429    if pattern.foreground.is_none() && pattern.background.is_none() {
430        writer.write_event(Event::Empty(start))?;
431        return Ok(());
432    }
433
434    writer.write_event(Event::Start(start))?;
435    if let Some(color) = &pattern.foreground {
436        writer.write_event(Event::Start(BytesStart::new("a:fgClr")))?;
437        write_color(writer, color)?;
438        writer.write_event(Event::End(BytesEnd::new("a:fgClr")))?;
439    }
440    if let Some(color) = &pattern.background {
441        writer.write_event(Event::Start(BytesStart::new("a:bgClr")))?;
442        write_color(writer, color)?;
443        writer.write_event(Event::End(BytesEnd::new("a:bgClr")))?;
444    }
445    writer.write_event(Event::End(BytesEnd::new("a:pattFill")))?;
446    Ok(())
447}
448
449/// Writes `EG_ColorChoice` — a single `<a:srgbClr../>`, `<a:scrgbClr../>`, `<a:hslClr../>`,
450/// `<a:sysClr../>`, or `<a:prstClr../>` element, with no wrapping element of its own (the caller —
451/// `<a:solidFill>`, `<a:fgClr>`, `<a:gs>`. — provides that).
452pub fn write_color<W: Write>(writer: &mut Writer<W>, color: &Color) -> Result<()> {
453    match color {
454        Color::Rgb(hex) => {
455            let mut start = BytesStart::new("a:srgbClr");
456            start.push_attribute(("val", hex.as_str()));
457            writer.write_event(Event::Empty(start))?;
458        }
459        Color::RgbPercent { red, green, blue } => {
460            let mut start = BytesStart::new("a:scrgbClr");
461            start.push_attribute(("r", red.to_string().as_str()));
462            start.push_attribute(("g", green.to_string().as_str()));
463            start.push_attribute(("b", blue.to_string().as_str()));
464            writer.write_event(Event::Empty(start))?;
465        }
466        Color::Hsl {
467            hue_60000ths,
468            saturation_1000ths_percent,
469            luminance_1000ths_percent,
470        } => {
471            let mut start = BytesStart::new("a:hslClr");
472            start.push_attribute(("hue", hue_60000ths.to_string().as_str()));
473            start.push_attribute(("sat", saturation_1000ths_percent.to_string().as_str()));
474            start.push_attribute(("lum", luminance_1000ths_percent.to_string().as_str()));
475            writer.write_event(Event::Empty(start))?;
476        }
477        Color::System { value, last_color } => {
478            let mut start = BytesStart::new("a:sysClr");
479            start.push_attribute(("val", value.as_str()));
480            if let Some(last_color) = last_color {
481                start.push_attribute(("lastClr", last_color.as_str()));
482            }
483            writer.write_event(Event::Empty(start))?;
484        }
485        Color::Preset(name) => {
486            let mut start = BytesStart::new("a:prstClr");
487            start.push_attribute(("val", name.as_str()));
488            writer.write_event(Event::Empty(start))?;
489        }
490    }
491    Ok(())
492}
493
494/// Writes `<a:ln w="..">EG_LineFillProperties, EG_LineDashProperties, EG_LineJoinProperties,
495/// headEnd?, tailEnd?</a:ln>`.
496pub fn write_line<W: Write>(writer: &mut Writer<W>, line: &Line) -> Result<()> {
497    write_line_named(writer, "a:ln", line)
498}
499
500/// Writes a `CT_LineProperties`-typed element under a caller-chosen name — same content model as
501/// [`write_line`] (which is just this called with `"a:ln"`), for the handful of other elements in
502/// the wider OOXML vocabulary that reuse `CT_LineProperties` under a different name, e.g.
503/// PresentationML table cell borders (`<a:lnL>`/`<a:lnR>`/`<a:lnT>`/ `<a:lnB>`,
504/// `powerpoint-ooxml`'s own [`crate`]-external `TableCellProperties`, point 8).
505pub fn write_line_named<W: Write>(
506    writer: &mut Writer<W>,
507    element_name: &str,
508    line: &Line,
509) -> Result<()> {
510    let mut start = BytesStart::new(element_name);
511    if let Some(width) = line.width_emu {
512        start.push_attribute(("w", width.to_string().as_str()));
513    }
514    if let Some(cap) = &line.cap {
515        start.push_attribute(("cap", cap.xml_token()));
516    }
517    if let Some(compound) = &line.compound {
518        start.push_attribute(("cmpd", compound.xml_token()));
519    }
520
521    let has_children = line.fill.is_some()
522        || line.dash.is_some()
523        || line.join.is_some()
524        || line.head_end.is_some()
525        || line.tail_end.is_some();
526    if !has_children {
527        writer.write_event(Event::Empty(start))?;
528        return Ok(());
529    }
530
531    writer.write_event(Event::Start(start))?;
532    if let Some(fill) = &line.fill {
533        write_fill(writer, fill)?;
534    }
535    if let Some(dash) = &line.dash {
536        let mut prst_dash = BytesStart::new("a:prstDash");
537        prst_dash.push_attribute(("val", dash.xml_token()));
538        writer.write_event(Event::Empty(prst_dash))?;
539    }
540    if let Some(join) = &line.join {
541        write_line_join(writer, join)?;
542    }
543    if let Some(head_end) = &line.head_end {
544        write_line_end(writer, "a:headEnd", head_end)?;
545    }
546    if let Some(tail_end) = &line.tail_end {
547        write_line_end(writer, "a:tailEnd", tail_end)?;
548    }
549    writer.write_event(Event::End(BytesEnd::new(element_name)))?;
550    Ok(())
551}
552
553fn write_line_join<W: Write>(writer: &mut Writer<W>, join: &LineJoin) -> Result<()> {
554    match join {
555        LineJoin::Round => writer.write_event(Event::Empty(BytesStart::new("a:round")))?,
556        LineJoin::Bevel => writer.write_event(Event::Empty(BytesStart::new("a:bevel")))?,
557        LineJoin::Miter {
558            limit_1000ths_percent,
559        } => {
560            let mut start = BytesStart::new("a:miter");
561            if let Some(limit) = limit_1000ths_percent {
562                start.push_attribute(("lim", limit.to_string().as_str()));
563            }
564            writer.write_event(Event::Empty(start))?;
565        }
566    }
567    Ok(())
568}
569
570fn write_line_end<W: Write>(
571    writer: &mut Writer<W>,
572    element_name: &str,
573    end: &LineEnd,
574) -> Result<()> {
575    let mut start = BytesStart::new(element_name);
576    if let Some(kind) = &end.kind {
577        start.push_attribute(("type", kind.xml_token()));
578    }
579    if let Some(width) = &end.width {
580        start.push_attribute(("w", width.xml_token()));
581    }
582    if let Some(length) = &end.length {
583        start.push_attribute(("len", length.xml_token()));
584    }
585    writer.write_event(Event::Empty(start))?;
586    Ok(())
587}
588
589// ================================================================================================
590// Text-in-shape (`<a:txBody>`)
591// ================================================================================================
592
593/// Writes `<a:bodyPr>../<a:p>..</a:p>*` — the content of `<a:txBody>` (`CT_TextBody`). Like
594/// [`write_shape_properties`], never writes the `<a:txBody>` wrapper itself (a sibling of
595/// `<a:spPr>` inside a host's own `<*:sp>`, not this crate's concern — see [`crate::TextBody`]'s
596/// doc comment).
597pub fn write_text_body<W: Write>(writer: &mut Writer<W>, body: &TextBody) -> Result<()> {
598    write_text_body_properties(writer, &body.properties)?;
599    for paragraph in &body.paragraphs {
600        write_text_paragraph(writer, paragraph)?;
601    }
602    Ok(())
603}
604
605/// Writes `<a:bodyPr>` (self-closing unless `autofit` is set — no 3D/preset warp modeled, see
606/// [`TextBody`]'s doc comment), matching the schema's own `minOccurs="1"` on this element even when
607/// everything is unset (`<a:bodyPr/>`).
608pub fn write_text_body_properties<W: Write>(
609    writer: &mut Writer<W>,
610    properties: &TextBodyProperties,
611) -> Result<()> {
612    let mut start = BytesStart::new("a:bodyPr");
613    if properties.rotation_60000ths != 0 {
614        start.push_attribute(("rot", properties.rotation_60000ths.to_string().as_str()));
615    }
616    if let Some(vertical_direction) = &properties.vertical_direction {
617        start.push_attribute(("vert", vertical_direction.xml_token()));
618    }
619    if let Some(wrap) = &properties.wrap {
620        start.push_attribute(("wrap", wrap.xml_token()));
621    }
622    if let Some(anchor) = &properties.anchor {
623        start.push_attribute(("anchor", anchor.xml_token()));
624    }
625    if properties.anchor_center {
626        start.push_attribute(("anchorCtr", "1"));
627    }
628    if let Some(inset) = properties.inset_left_emu {
629        start.push_attribute(("lIns", inset.to_string().as_str()));
630    }
631    if let Some(inset) = properties.inset_top_emu {
632        start.push_attribute(("tIns", inset.to_string().as_str()));
633    }
634    if let Some(inset) = properties.inset_right_emu {
635        start.push_attribute(("rIns", inset.to_string().as_str()));
636    }
637    if let Some(inset) = properties.inset_bottom_emu {
638        start.push_attribute(("bIns", inset.to_string().as_str()));
639    }
640
641    let Some(autofit) = &properties.autofit else {
642        writer.write_event(Event::Empty(start))?;
643        return Ok(());
644    };
645
646    writer.write_event(Event::Start(start))?;
647    match autofit {
648        TextAutofit::None => writer.write_event(Event::Empty(BytesStart::new("a:noAutofit")))?,
649        TextAutofit::Shape => writer.write_event(Event::Empty(BytesStart::new("a:spAutoFit")))?,
650        TextAutofit::Normal {
651            font_scale_1000ths_percent,
652            line_spacing_reduction_1000ths_percent,
653        } => {
654            let mut element = BytesStart::new("a:normAutofit");
655            if let Some(scale) = font_scale_1000ths_percent {
656                element.push_attribute(("fontScale", scale.to_string().as_str()));
657            }
658            if let Some(reduction) = line_spacing_reduction_1000ths_percent {
659                element.push_attribute(("lnSpcReduction", reduction.to_string().as_str()));
660            }
661            writer.write_event(Event::Empty(element))?;
662        }
663    }
664    writer.write_event(Event::End(BytesEnd::new("a:bodyPr")))?;
665    Ok(())
666}
667
668/// Writes `<a:p><a:pPr../>EG_TextRun*</a:p>` (`CT_TextParagraph`).
669pub fn write_text_paragraph<W: Write>(
670    writer: &mut Writer<W>,
671    paragraph: &TextParagraph,
672) -> Result<()> {
673    writer.write_event(Event::Start(BytesStart::new("a:p")))?;
674    if let Some(properties) = &paragraph.properties {
675        write_text_paragraph_properties(writer, properties)?;
676    }
677    for run in &paragraph.runs {
678        write_text_run(writer, run)?;
679    }
680    writer.write_event(Event::End(BytesEnd::new("a:p")))?;
681    Ok(())
682}
683
684/// Writes `<a:pPr>` — self-closing when nothing beyond its own attributes is set, otherwise wraps
685/// `<a:lnSpc>`/`<a:spcBef>`/`<a:spcAft>`/bullet elements/`<a:tabLst>`/`<a:defRPr>` children, in
686/// that order — the same child sequence confirmed on a real fixture
687/// (`<a:lnSpc>..<a:spcBef>..<a:spcAft>..<a:buClrTx/><a:buSzTx/>
688/// <a:buFontTx/><a:buNone/><a:tabLst/><a:defRPr/>`).
689pub fn write_text_paragraph_properties<W: Write>(
690    writer: &mut Writer<W>,
691    properties: &TextParagraphProperties,
692) -> Result<()> {
693    let mut start = BytesStart::new("a:pPr");
694    if let Some(margin) = properties.margin_left_emu {
695        start.push_attribute(("marL", margin.to_string().as_str()));
696    }
697    if let Some(margin) = properties.margin_right_emu {
698        start.push_attribute(("marR", margin.to_string().as_str()));
699    }
700    if let Some(level) = properties.level {
701        start.push_attribute(("lvl", level.to_string().as_str()));
702    }
703    if let Some(indent) = properties.indent_emu {
704        start.push_attribute(("indent", indent.to_string().as_str()));
705    }
706    if let Some(alignment) = &properties.alignment {
707        start.push_attribute(("algn", alignment.xml_token()));
708    }
709
710    let has_children = properties.line_spacing.is_some()
711        || properties.space_before.is_some()
712        || properties.space_after.is_some()
713        || properties.bullet_color.is_some()
714        || properties.bullet_size.is_some()
715        || properties.bullet_font.is_some()
716        || properties.bullet.is_some()
717        || !properties.tab_stops.is_empty()
718        || properties.default_run_properties.is_some();
719    if !has_children {
720        writer.write_event(Event::Empty(start))?;
721        return Ok(());
722    }
723
724    writer.write_event(Event::Start(start))?;
725    if let Some(spacing) = &properties.line_spacing {
726        write_text_spacing(writer, "a:lnSpc", spacing)?;
727    }
728    if let Some(spacing) = &properties.space_before {
729        write_text_spacing(writer, "a:spcBef", spacing)?;
730    }
731    if let Some(spacing) = &properties.space_after {
732        write_text_spacing(writer, "a:spcAft", spacing)?;
733    }
734    if let Some(color) = &properties.bullet_color {
735        match color {
736            BulletColor::FollowText => {
737                writer.write_event(Event::Empty(BytesStart::new("a:buClrTx")))?
738            }
739            BulletColor::Explicit(color) => {
740                writer.write_event(Event::Start(BytesStart::new("a:buClr")))?;
741                write_color(writer, color)?;
742                writer.write_event(Event::End(BytesEnd::new("a:buClr")))?;
743            }
744        }
745    }
746    if let Some(size) = &properties.bullet_size {
747        match size {
748            BulletSize::FollowText => {
749                writer.write_event(Event::Empty(BytesStart::new("a:buSzTx")))?
750            }
751            BulletSize::Percent(value) => {
752                let mut element = BytesStart::new("a:buSzPct");
753                element.push_attribute(("val", value.to_string().as_str()));
754                writer.write_event(Event::Empty(element))?;
755            }
756            BulletSize::Points(value) => {
757                let mut element = BytesStart::new("a:buSzPts");
758                element.push_attribute(("val", value.to_string().as_str()));
759                writer.write_event(Event::Empty(element))?;
760            }
761        }
762    }
763    if let Some(font) = &properties.bullet_font {
764        match font {
765            BulletFont::FollowText => {
766                writer.write_event(Event::Empty(BytesStart::new("a:buFontTx")))?
767            }
768            BulletFont::Typeface(typeface) => {
769                let mut element = BytesStart::new("a:buFont");
770                element.push_attribute(("typeface", typeface.as_str()));
771                writer.write_event(Event::Empty(element))?;
772            }
773        }
774    }
775    if let Some(bullet) = &properties.bullet {
776        match bullet {
777            BulletKind::None => writer.write_event(Event::Empty(BytesStart::new("a:buNone")))?,
778            BulletKind::Character(character) => {
779                let mut element = BytesStart::new("a:buChar");
780                element.push_attribute(("char", character.as_str()));
781                writer.write_event(Event::Empty(element))?;
782            }
783            BulletKind::AutoNumber { scheme, start_at } => {
784                let mut element = BytesStart::new("a:buAutoNum");
785                element.push_attribute(("type", scheme.as_str()));
786                if let Some(start_at) = start_at {
787                    element.push_attribute(("startAt", start_at.to_string().as_str()));
788                }
789                writer.write_event(Event::Empty(element))?;
790            }
791        }
792    }
793    if !properties.tab_stops.is_empty() {
794        writer.write_event(Event::Start(BytesStart::new("a:tabLst")))?;
795        for tab_stop in &properties.tab_stops {
796            let mut tab = BytesStart::new("a:tab");
797            tab.push_attribute(("pos", tab_stop.position_emu.to_string().as_str()));
798            if let Some(alignment) = &tab_stop.alignment {
799                tab.push_attribute(("algn", alignment.xml_token()));
800            }
801            writer.write_event(Event::Empty(tab))?;
802        }
803        writer.write_event(Event::End(BytesEnd::new("a:tabLst")))?;
804    }
805    if properties.default_run_properties.is_some() {
806        write_text_run_properties(
807            writer,
808            "a:defRPr",
809            properties.default_run_properties.as_deref(),
810        )?;
811    }
812    writer.write_event(Event::End(BytesEnd::new("a:pPr")))?;
813    Ok(())
814}
815
816/// Writes one `CT_TextSpacing` choice — `<a:spcPct val=".">` or `<a:spcPts val=".">` — wrapped in
817/// `element_name` (`<a:lnSpc>`/ `<a:spcBef>`/`<a:spcAft>`).
818fn write_text_spacing<W: Write>(
819    writer: &mut Writer<W>,
820    element_name: &str,
821    spacing: &TextSpacing,
822) -> Result<()> {
823    writer.write_event(Event::Start(BytesStart::new(element_name)))?;
824    let (child_name, value) = match spacing {
825        TextSpacing::Percent(value) => ("a:spcPct", *value),
826        TextSpacing::Points(value) => ("a:spcPts", *value),
827    };
828    let mut child = BytesStart::new(child_name);
829    child.push_attribute(("val", value.to_string().as_str()));
830    writer.write_event(Event::Empty(child))?;
831    writer.write_event(Event::End(BytesEnd::new(element_name)))?;
832    Ok(())
833}
834
835/// Writes one `EG_TextRun` choice — `<a:r>..</a:r>` for [`TextRun::Regular`],
836/// `<a:br../>`/`<a:br>..</a:br>` for [`TextRun::LineBreak`].
837pub fn write_text_run<W: Write>(writer: &mut Writer<W>, run: &TextRun) -> Result<()> {
838    match run {
839        TextRun::Regular { text, properties } => {
840            writer.write_event(Event::Start(BytesStart::new("a:r")))?;
841            write_text_run_properties(writer, "a:rPr", Some(properties))?;
842            writer.write_event(Event::Start(BytesStart::new("a:t")))?;
843            writer.write_event(Event::Text(BytesText::new(text)))?;
844            writer.write_event(Event::End(BytesEnd::new("a:t")))?;
845            writer.write_event(Event::End(BytesEnd::new("a:r")))?;
846        }
847        TextRun::LineBreak { properties } => {
848            if properties.is_none() {
849                writer.write_event(Event::Empty(BytesStart::new("a:br")))?;
850            } else {
851                writer.write_event(Event::Start(BytesStart::new("a:br")))?;
852                write_text_run_properties(writer, "a:rPr", properties.as_ref())?;
853                writer.write_event(Event::End(BytesEnd::new("a:br")))?;
854            }
855        }
856        TextRun::Field {
857            id,
858            field_type,
859            cached_text,
860            properties,
861        } => {
862            validate_field_id(id)?;
863            let mut start = BytesStart::new("a:fld");
864            start.push_attribute(("id", id.as_str()));
865            if let Some(field_type) = field_type {
866                start.push_attribute(("type", field_type.as_str()));
867            }
868            writer.write_event(Event::Start(start))?;
869            write_text_run_properties(writer, "a:rPr", Some(properties))?;
870            writer.write_event(Event::Start(BytesStart::new("a:t")))?;
871            writer.write_event(Event::Text(BytesText::new(cached_text)))?;
872            writer.write_event(Event::End(BytesEnd::new("a:t")))?;
873            writer.write_event(Event::End(BytesEnd::new("a:fld")))?;
874        }
875    }
876    Ok(())
877}
878
879/// Writes `<a:rPr b=".." i=".." u=".." strike=".." sz="..">EG_FillProperties?<a:latin
880/// typeface=".."/>?</a:rPr>`, or nothing at all when `properties` is `None` (a bare line break with
881/// no formatting override) — `element_name` is always `"a:rPr"` in this crate today
882/// (`<a:defRPr>`/`<a:endParaRPr>` aren't written, see [`TextRunProperties`]'s doc comment), kept as
883/// a parameter for that documented reason rather than hard-coded.
884fn write_text_run_properties<W: Write>(
885    writer: &mut Writer<W>,
886    element_name: &str,
887    properties: Option<&TextRunProperties>,
888) -> Result<()> {
889    let Some(properties) = properties else {
890        return Ok(());
891    };
892
893    let mut start = BytesStart::new(element_name);
894    if properties.bold {
895        start.push_attribute(("b", "1"));
896    }
897    if properties.italic {
898        start.push_attribute(("i", "1"));
899    }
900    if let Some(underline) = &properties.underline {
901        start.push_attribute(("u", underline.xml_token()));
902    }
903    if let Some(strike) = &properties.strike {
904        start.push_attribute(("strike", strike.xml_token()));
905    }
906    if let Some(size) = properties.font_size_100ths_point {
907        start.push_attribute(("sz", size.to_string().as_str()));
908    }
909    if let Some(caps) = &properties.text_caps {
910        start.push_attribute(("cap", caps.xml_token()));
911    }
912    if let Some(spacing) = properties.character_spacing_100ths_point {
913        start.push_attribute(("spc", spacing.to_string().as_str()));
914    }
915    if let Some(baseline) = properties.baseline_1000ths_percent {
916        start.push_attribute(("baseline", baseline.to_string().as_str()));
917    }
918
919    let has_children = properties.fill.is_some()
920        || properties.highlight.is_some()
921        || properties.font_family.is_some()
922        || properties.hyperlink.is_some();
923    if !has_children {
924        writer.write_event(Event::Empty(start))?;
925        return Ok(());
926    }
927
928    writer.write_event(Event::Start(start))?;
929    if let Some(fill) = &properties.fill {
930        write_fill(writer, fill)?;
931    }
932    if let Some(highlight) = &properties.highlight {
933        writer.write_event(Event::Start(BytesStart::new("a:highlight")))?;
934        write_color(writer, highlight)?;
935        writer.write_event(Event::End(BytesEnd::new("a:highlight")))?;
936    }
937    if let Some(font_family) = &properties.font_family {
938        let mut latin = BytesStart::new("a:latin");
939        latin.push_attribute(("typeface", font_family.as_str()));
940        writer.write_event(Event::Empty(latin))?;
941    }
942    if let Some(hyperlink) = &properties.hyperlink {
943        write_hyperlink(writer, "a:hlinkClick", hyperlink)?;
944    }
945    writer.write_event(Event::End(BytesEnd::new(element_name)))?;
946    Ok(())
947}
948
949/// Writes `<a:hlinkClick r:id="." tooltip="."/>` (`CT_Hyperlink`). See
950/// [`crate::model::Hyperlink`]'s own doc comment for why `relationship_id` is already resolved by
951/// the time it reaches here.
952fn write_hyperlink<W: Write>(
953    writer: &mut Writer<W>,
954    element_name: &str,
955    hyperlink: &crate::model::Hyperlink,
956) -> Result<()> {
957    let mut start = BytesStart::new(element_name);
958    if let Some(relationship_id) = &hyperlink.relationship_id {
959        start.push_attribute(("r:id", relationship_id.as_str()));
960    }
961    if let Some(tooltip) = &hyperlink.tooltip {
962        start.push_attribute(("tooltip", tooltip.as_str()));
963    }
964    writer.write_event(Event::Empty(start))?;
965    Ok(())
966}