Files
wordcraft/crates/doc/src/para/inline_object.rs
T

422 lines
17 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::float::{Float, Wrap, cap_alt};
use super::inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind};
use super::{CharProps, Deserialize, Serialize};
/// An inline object anchored at a U+FFFC in the paragraph text.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum InlineObject {
Image {
/// Key in `Document::media`.
media: String,
/// Display size, points.
w: f32,
h: f32,
#[serde(default)]
alt: String,
#[serde(default)]
float: Float,
/// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)]
crop: [f32; 4],
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// read from a file: saving writes the object back, not only its picture. See
/// [`crate::graphic::Embedded`].
#[serde(default, skip_serializing_if = "Option::is_none")]
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
Graphic {
w: f32,
h: f32,
#[serde(default)]
alt: String,
#[serde(default)]
float: Float,
graphic: std::sync::Arc<crate::graphic::Graphic>,
},
Shape {
kind: ShapeKind,
w: f32,
h: f32,
fill: Option<crate::fill::Fill>,
stroke: Option<crate::props::Rgb>,
#[serde(default)]
stroke_width: f32,
#[serde(default)]
float: Float,
/// Text box content: `Document::parts` id.
#[serde(default)]
story: Option<u32>,
/// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke.
#[serde(default, skip_serializing_if = "Option::is_none")]
freeform: Option<std::sync::Arc<crate::freeform::Freeform>>,
/// Shadow, glow and soft edges.
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
effects: crate::effects::ShapeEffects,
/// Drawing id, text warp, connector ends and arrowheads.
#[serde(default, skip_serializing_if = "crate::connector::ShapeExtra::is_empty")]
extra: crate::connector::ShapeExtra,
},
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
/// `a:chExt` and `a:ext`), so resizing the group scales them.
Group {
w: f32,
h: f32,
#[serde(default)]
float: Float,
/// Size of the members' coordinate space (their offsets and sizes are in it).
ch_w: f32,
ch_h: f32,
children: Vec<GroupChild>,
/// A Drawing Canvas (Insert › Shapes › New Drawing Canvas, DrawingML's `wpc:wpc`) rather
/// than a group: it has its own background and outline, and its frame is independent of
/// its members: resizing it doesn't scale them (`ch_w` × `ch_h` follow `w` × `h`), and
/// what lies outside the frame is clipped.
#[serde(default, skip_serializing_if = "Option::is_none")]
canvas: Option<CanvasStyle>,
},
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
/// `result` the cached display text. A `locked` field keeps its result when fields update.
Field {
instr: String,
#[serde(default)]
result: String,
#[serde(default)]
locked: bool,
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
/// saves; cleared when WordCraft computes a result, like Word's F9.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
/// toggle: never written to files.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
code: bool,
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
#[serde(default, skip_serializing_if = "String::is_empty")]
form: String,
},
/// The start of a field whose result is ordinary content — formatted, possibly spanning
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
/// [`crate::fields`].
FieldStart {
instr: String,
#[serde(default)]
locked: bool,
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
},
/// The end of the innermost open [`InlineObject::FieldStart`].
FieldEnd,
/// The start of a content control whose content is what follows, up to the matching
/// [`InlineObject::ControlEnd`]. See [`crate::control`].
ControlStart {
control: Box<crate::control::ContentControl>,
},
/// The end of the innermost open [`InlineObject::ControlStart`].
ControlEnd,
NoteRef {
kind: NoteKind,
/// `Document::parts` id of the note's story.
id: u32,
/// Custom mark (empty = automatic number).
#[serde(default)]
custom: String,
},
BookmarkStart {
name: String,
},
BookmarkEnd {
name: String,
},
CommentStart {
id: u32,
},
CommentEnd {
id: u32,
},
/// The start of a range that stays editable while the document is protected (Restrict
/// Editing exceptions, `w:permStart`): for everyone (`group` = `everyone`), another editing
/// group, or one editor (`editor`, a user name or address).
PermStart {
id: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
group: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
editor: String,
},
/// The end of the editable range [`InlineObject::PermStart`] with the same `id` opened.
PermEnd {
id: u32,
},
/// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure.
/// A display equation sits on a line of its own.
Equation {
linear: String,
#[serde(default)]
display: bool,
/// The structure; empty = parse `linear`.
#[serde(default, skip_serializing_if = "crate::math::Math::is_empty")]
math: crate::math::Math,
},
/// Ruby (Phonetic Guide): small `ruby` text (furigana, pinyin…) set over `base` text. The base
/// text takes the formatting of the object's run; it reads as the paragraph's text.
Ruby {
base: String,
ruby: String,
#[serde(default)]
align: RubyAlign,
/// Ruby text size, points (`w:hps`).
size: f32,
/// From the base text's baseline up to the ruby text's baseline, points (`w:hpsRaise`).
raise: f32,
/// The base text size the ruby was set for, points (`w:hpsBaseText`).
#[serde(default)]
base_size: f32,
/// Language of the ruby (`w:lid`, e.g. `ja-JP`); empty = unspecified.
#[serde(default, skip_serializing_if = "String::is_empty")]
lang: String,
/// Formatting of the ruby text over the run's (font, colour…); its size is `size`.
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
props: Box<CharProps>,
},
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
/// text after it lines up there. Text already past that place sends it to the next line.
PositionalTab {
#[serde(default)]
align: PTabAlign,
#[serde(default)]
relative_to: PTabBase,
#[serde(default)]
leader: crate::props::TabLeader,
},
/// Something we don't model, kept for round-trip (raw XML of the source format).
Opaque {
format: String,
xml: String,
#[serde(default)]
text: String,
},
}
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupChild {
pub x: f32,
pub y: f32,
pub obj: InlineObject,
}
/// Most members a group keeps (hostile files).
pub const MAX_GROUP_CHILDREN: usize = 1000;
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CanvasStyle {
pub fill: Option<crate::fill::Fill>,
pub stroke: Option<crate::props::Rgb>,
/// Outline width, points.
pub stroke_width: f32,
}
impl InlineObject {
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
pub fn is_drawing(&self) -> bool {
matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
}
/// A drawing's size and placement.
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
match self {
InlineObject::Image { w, h, float, .. }
| InlineObject::Graphic { w, h, float, .. }
| InlineObject::Shape { w, h, float, .. }
| InlineObject::Group { w, h, float, .. } => Some((*w, *h, float)),
_ => None,
}
}
/// A drawing's placement, to change.
pub fn float_mut(&mut self) -> Option<&mut Float> {
match self {
InlineObject::Image { float, .. }
| InlineObject::Graphic { float, .. }
| InlineObject::Shape { float, .. }
| InlineObject::Group { float, .. } => Some(float),
_ => None,
}
}
/// Resize a drawing (a group's members scale with it; a canvas's keep their size and place).
pub fn set_size(&mut self, nw: f32, nh: f32) {
if let InlineObject::Image { w, h, .. }
| InlineObject::Graphic { w, h, .. }
| InlineObject::Shape { w, h, .. }
| InlineObject::Group { w, h, .. } = self
{
*w = nw;
*h = nh;
}
if let InlineObject::Group { ch_w, ch_h, canvas: Some(_), .. } = self {
*ch_w = nw;
*ch_h = nh;
}
}
/// A Drawing Canvas's background and outline (`None` for anything else).
pub fn canvas_style(&self) -> Option<&CanvasStyle> {
match self {
InlineObject::Group { canvas, .. } => canvas.as_ref(),
_ => None,
}
}
/// Whether this is a Drawing Canvas.
pub fn is_canvas(&self) -> bool {
self.canvas_style().is_some()
}
/// The text box story this object shows, if it is a text box.
pub fn text_box(&self) -> Option<u32> {
match self {
InlineObject::Shape { story, .. } => *story,
_ => None,
}
}
/// The text box stories this object shows: a text box's, or its group members'.
pub fn text_boxes(&self) -> Vec<u32> {
match self {
InlineObject::Shape { story: Some(id), .. } => vec![*id],
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
_ => Vec::new(),
}
}
/// The text box story slots of this object and its group members, to repoint.
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
match self {
InlineObject::Shape { story, .. } => vec![story],
InlineObject::Group { children, .. } => children
.iter_mut()
.filter_map(|c| match &mut c.obj {
InlineObject::Shape { story, .. } => Some(story),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
/// (x, y, w, h) and the member. Empty for anything else.
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
children
.iter()
.take(MAX_GROUP_CHILDREN)
.filter_map(|c| {
let (cw, chh, _) = c.obj.frame()?;
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
})
.collect()
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
match self {
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
_ => None,
}
}
/// Zero-width markers don't take part in layout.
pub fn is_marker(&self) -> bool {
matches!(
self,
InlineObject::BookmarkStart { .. }
| InlineObject::BookmarkEnd { .. }
| InlineObject::CommentStart { .. }
| InlineObject::CommentEnd { .. }
| InlineObject::PermStart { .. }
| InlineObject::PermEnd { .. }
| InlineObject::FieldStart { .. }
| InlineObject::FieldEnd
| InlineObject::ControlStart { .. }
| InlineObject::ControlEnd
)
}
/// A drawing's description (alternative text), wherever it is kept.
pub fn alt_text(&self) -> &str {
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text,
_ => "",
}
}
/// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing.
pub fn set_alt_text(&mut self, text: &str) -> bool {
let text = cap_alt(text);
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text,
_ => return false,
}
true
}
/// A drawing marked decorative.
pub fn is_decorative(&self) -> bool {
self.frame().is_some_and(|(_, _, f)| f.alt.decorative)
}
pub fn is_floating(&self) -> bool {
match self {
InlineObject::Image { float, .. }
| InlineObject::Graphic { float, .. }
| InlineObject::Shape { float, .. }
| InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
_ => false,
}
}
/// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one.
pub fn ink(&self) -> Option<&crate::freeform::Freeform> {
match self {
InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f),
_ => None,
}
}
/// The line a footnote or endnote separator draws (ECMA-376 §17.11: the `w:separator` and
/// `w:continuationSeparator` run elements): a short line, or with `continuation` one across
/// the column. Kept as the run element itself.
pub fn note_separator(continuation: bool) -> InlineObject {
let el = if continuation { "w:continuationSeparator" } else { "w:separator" };
InlineObject::Opaque { format: "docx".into(), xml: format!("<w:r><{el}/></w:r>"), text: String::new() }
}
/// For a [`InlineObject::note_separator`]: whether it is the continuation separator.
pub fn as_note_separator(&self) -> Option<bool> {
match self {
InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() {
"<w:r><w:separator/></w:r>" => Some(false),
"<w:r><w:continuationSeparator/></w:r>" => Some(true),
_ => None,
},
_ => None,
}
}
/// The text this object contributes to plain-text extraction.
pub fn plain_text(&self) -> &str {
match self {
InlineObject::Field { result, .. } => result,
InlineObject::Equation { linear, .. } => linear,
InlineObject::Ruby { base, .. } => base,
InlineObject::PositionalTab { .. } => "\t",
InlineObject::Opaque { text, .. } => text,
_ => "",
}
}
}