mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 05:47:00 -06:00
refactor(docx): split read/story.rs into read/story/
This commit is contained in:
+6
-2338
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
use super::*;
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Read a story's blocks from a container element (`w:body`, `w:hdr`, `w:tc`, `w:txbxContent`…).
|
||||
pub fn read_blocks(&mut self, sc: &mut StoryCtx, parent: &El, rels: &Rels, out: &mut Blocks, depth: usize) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for e in parent.els() {
|
||||
match e.name.as_str() {
|
||||
"w:p" => {
|
||||
let p = self.read_para(sc, e, rels, depth);
|
||||
// A framed drop-cap paragraph merges into the paragraph it starts.
|
||||
if let Some(Block::Para(prev)) = out.last().map(|b| &**b)
|
||||
&& prev.props.drop_cap.is_some()
|
||||
&& prev.section.is_none()
|
||||
&& prev.text.len() <= 16
|
||||
&& p.props.drop_cap.is_none()
|
||||
&& let Some(Block::Para(mut first)) = out.pop().map(|b| (*b).clone())
|
||||
{
|
||||
let mut props = p.props.clone();
|
||||
props.drop_cap = first.props.drop_cap;
|
||||
props.drop_cap_margin = first.props.drop_cap_margin;
|
||||
props.drop_cap_distance = first.props.drop_cap_distance;
|
||||
// A letter in a font of its own: the drop cap's font, the letter itself
|
||||
// in the text's (so taking the drop cap away gives the letter its font back).
|
||||
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
|
||||
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
|
||||
if cap_font.is_some() && cap_font != text_font {
|
||||
for r in &mut first.runs {
|
||||
r.props.font = text_font.clone();
|
||||
}
|
||||
props.drop_cap_font = cap_font;
|
||||
}
|
||||
first.append(p);
|
||||
first.props = props;
|
||||
out.push(para_block(first));
|
||||
continue;
|
||||
}
|
||||
out.push(para_block(p));
|
||||
}
|
||||
"w:tbl" => {
|
||||
if depth / 4 >= MAX_TABLE_DEPTH {
|
||||
out.push(para_block(Paragraph::with_text(&clean_text(&e.deep_text()), CharProps::default())));
|
||||
} else if let Some(t) = self.read_table(sc, e, rels, depth) {
|
||||
out.push(Arc::new(Block::Table(t)));
|
||||
}
|
||||
}
|
||||
"w:sdt" => self.read_block_sdt(sc, e, rels, out, depth),
|
||||
"w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => self.read_blocks(sc, e, rels, out, depth + 1),
|
||||
// Math is allowed straight in a story (EG_BlockLevelElts takes EG_RunLevelElements,
|
||||
// which takes EG_MathContent): it gets a paragraph of its own (#1273).
|
||||
"m:oMathPara" | "m:oMath" => {
|
||||
let wrapped = El { name: "w:p".into(), attrs: Vec::new(), kids: vec![Node::El(e.clone())] };
|
||||
let p = self.read_para(sc, &wrapped, rels, depth);
|
||||
out.push(para_block(p));
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(c) = children_of_choice(e) {
|
||||
self.read_blocks(sc, c, rels, out, depth + 1);
|
||||
}
|
||||
}
|
||||
"w:bookmarkStart" | "w:bookmarkEnd" | "w:commentRangeStart" | "w:commentRangeEnd" | "w:permStart" | "w:permEnd" => {
|
||||
if let Some(o) = self.marker(e) {
|
||||
sc.pending.push(o);
|
||||
}
|
||||
}
|
||||
"w:moveFromRangeStart" | "w:moveFromRangeEnd" | "w:moveToRangeStart" | "w:moveToRangeEnd" => self.move_range(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A block-level content control: its content's blocks, between a start marker at the start
|
||||
/// of the first paragraph and an end marker at the end of the last (a table at either end
|
||||
/// carries the control instead).
|
||||
fn read_block_sdt(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, out: &mut Blocks, depth: usize) {
|
||||
let Some(content) = e.child("w:sdtContent") else { return };
|
||||
let mut ctl = crate::read::sdt::sdt_pr(e.child("w:sdtPr"), true);
|
||||
ctl.end_pr_xml = crate::read::sdt::sdt_end_pr(e.child("w:sdtEndPr"));
|
||||
ctl.run_props = e.child("w:sdtPr").and_then(|pr| pr.child("w:rPr")).map(|r| self.pc.rpr(r));
|
||||
let mut inner = Blocks::new();
|
||||
self.read_blocks(sc, content, rels, &mut inner, depth + 1);
|
||||
// A table of contents' control is handed to the TOC later, when the writer puts it back
|
||||
// (`toc_control_to_toc`).
|
||||
if inner.is_empty() {
|
||||
out.extend(inner);
|
||||
return;
|
||||
}
|
||||
if ctl.showing_placeholder
|
||||
&& let Some(Block::Para(p)) = inner.first().map(|b| &**b)
|
||||
{
|
||||
ctl.placeholder_text = placeholder_of(&p.plain_text());
|
||||
}
|
||||
if let Some(first) = inner.first_mut() {
|
||||
match Arc::make_mut(first) {
|
||||
Block::Para(p) => {
|
||||
let props = p.props_of_char(0).clone();
|
||||
let _ = p.insert_object(0, InlineObject::ControlStart { control: Box::new(ctl) }, &props);
|
||||
}
|
||||
Block::Table(t) => t.controls.open.insert(0, ctl),
|
||||
}
|
||||
}
|
||||
if let Some(last) = inner.last_mut() {
|
||||
match Arc::make_mut(last) {
|
||||
Block::Para(p) => {
|
||||
let (at, props) = (p.len(), p.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| p.mark.clone()));
|
||||
let _ = p.insert_object(at, InlineObject::ControlEnd, &props);
|
||||
}
|
||||
Block::Table(t) => t.controls.close = t.controls.close.saturating_add(1),
|
||||
}
|
||||
}
|
||||
out.extend(inner);
|
||||
}
|
||||
|
||||
/// Markers left over at the end of a story go to its last paragraph (after a final table:
|
||||
/// the last paragraph of its last cell, so a bookmark around the table keeps its end).
|
||||
pub fn flush_pending(&mut self, sc: &mut StoryCtx, out: &mut Blocks) {
|
||||
if sc.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = last_para_mut(out, 0) {
|
||||
for o in sc.pending.drain(..) {
|
||||
let props = p.runs.last().map(|r| r.props.clone()).unwrap_or_default();
|
||||
p.text.push(wordcraft_doc::para::OBJ);
|
||||
p.objects.push(o);
|
||||
p.runs.push(Run { len: wordcraft_doc::para::OBJ.len_utf8(), props });
|
||||
}
|
||||
p.normalize();
|
||||
}
|
||||
sc.pending.clear();
|
||||
}
|
||||
|
||||
pub(super) fn marker(&mut self, e: &El) -> Option<InlineObject> {
|
||||
let id = e.attr("w:id").map(str::trim).unwrap_or("").to_string();
|
||||
match e.name.as_str() {
|
||||
"w:bookmarkStart" => {
|
||||
if self.bookmarks.len() >= 100_000 {
|
||||
return None;
|
||||
}
|
||||
let name = e.attr("w:name").map(str::trim).unwrap_or("").to_string();
|
||||
if name.is_empty() || name.len() > 256 {
|
||||
return None;
|
||||
}
|
||||
self.bookmarks.insert(id, name.clone());
|
||||
Some(InlineObject::BookmarkStart { name })
|
||||
}
|
||||
"w:bookmarkEnd" => self
|
||||
.bookmarks
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.or_else(|| e.attr("w:name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 256).map(str::to_string))
|
||||
.map(|name| InlineObject::BookmarkEnd { name }),
|
||||
"w:commentRangeStart" => self.comment_id(&id).map(|id| InlineObject::CommentStart { id }),
|
||||
"w:permStart" => {
|
||||
// Bounded: a hostile file can't grow the map without end.
|
||||
if self.perms.len() >= 100_000 {
|
||||
return None;
|
||||
}
|
||||
let n = self.perms.len() as u32;
|
||||
let id = *self.perms.entry(id).or_insert(n);
|
||||
let text = |a: &str| e.attr(a).filter(|v| v.len() <= 256).unwrap_or("").to_string();
|
||||
let (group, editor) = (text("w:edGrp"), text("w:ed"));
|
||||
if group.is_empty() && editor.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::PermStart { id, group, editor })
|
||||
}
|
||||
"w:permEnd" => self.perms.get(&id).map(|id| InlineObject::PermEnd { id: *id }),
|
||||
"w:commentRangeEnd" => {
|
||||
let cid = self.comment_id(&id)?;
|
||||
self.comments_ended.insert(cid);
|
||||
Some(InlineObject::CommentEnd { id: cid })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn comment_id(&self, file_id: &str) -> Option<u32> {
|
||||
self.comment_map.get(file_id).copied()
|
||||
}
|
||||
|
||||
fn read_para(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, depth: usize) -> Paragraph {
|
||||
let mut pb = PB::default();
|
||||
let mut para = Paragraph::new();
|
||||
let mut mark_moves: [Option<&El>; 2] = [None, None];
|
||||
self.moves.closed_here = [None, None];
|
||||
for o in std::mem::take(&mut sc.pending) {
|
||||
pb.push_obj(o, &CharProps::default());
|
||||
}
|
||||
if let Some(ppr) = e.child("w:pPr") {
|
||||
let (mut props, mark) = self.pc.ppr(ppr);
|
||||
crate::read::props::resolve_level_only(&self.doc.styles, &mut props, "Normal");
|
||||
para.props = props;
|
||||
para.mark = mark;
|
||||
// A tracked paragraph mark (ECMA-376 §17.13.5.16 / §17.13.5.15): `w:ins` / `w:del`
|
||||
// in the mark's `w:rPr`.
|
||||
if let Some(rpr) = ppr.child("w:rPr") {
|
||||
for k in rpr.els() {
|
||||
match k.name.as_str() {
|
||||
"w:ins" => para.mark.ins = Some(self.revision(RevisionKind::Insert, k)),
|
||||
"w:del" => para.mark.del = Some(self.revision(RevisionKind::Delete, k)),
|
||||
// A moved paragraph mark (§17.13.5.21, §17.13.5.24): its move range
|
||||
// usually starts in the paragraph's content, so it is read after that.
|
||||
"w:moveTo" => mark_moves[1] = Some(k),
|
||||
"w:moveFrom" => mark_moves[0] = Some(k),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
para.mark.fmt_change = self.rpr_change(rpr);
|
||||
}
|
||||
// Tracked formatting changes (§17.13.5.29, §17.13.5.19): the paragraph properties
|
||||
// before the change, and a change of the list numbering.
|
||||
if let Some(ch) = ppr.child("w:pPrChange") {
|
||||
let old = ch
|
||||
.child("w:pPr")
|
||||
.map(|o| {
|
||||
let mut old = self.pc.ppr(o).0;
|
||||
crate::read::props::resolve_level_only(&self.doc.styles, &mut old, "Normal");
|
||||
old.formatting()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
para.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
if para.props.numbering.is_some()
|
||||
&& let Some(ch) = ppr.child("w:numPr").and_then(|n| n.child("w:numberingChange"))
|
||||
{
|
||||
let original = ch.attr("w:original").unwrap_or("").chars().take(256).collect();
|
||||
para.props.num_change = Some(Box::new(NumChange { rev: self.revision(RevisionKind::Format, ch), original }));
|
||||
}
|
||||
if let Some(s) = ppr.child("w:sectPr") {
|
||||
para.section = Some(Box::new(self.read_section(s, rels)));
|
||||
}
|
||||
}
|
||||
let ctx = RunCtx::default();
|
||||
self.read_inline_children(sc, &mut pb, e, rels, &ctx, depth + 1);
|
||||
self.spill_fields(sc, &mut pb);
|
||||
if let Some(k) = mark_moves[0] {
|
||||
para.mark.del = Some(self.move_revision(false, k, true));
|
||||
}
|
||||
if let Some(k) = mark_moves[1] {
|
||||
para.mark.ins = Some(self.move_revision(true, k, true));
|
||||
}
|
||||
para.text = pb.text;
|
||||
para.runs = pb.runs;
|
||||
para.objects = pb.objects;
|
||||
// Paragraph boundaries inside an instruction are hidden with the field code.
|
||||
// The result paragraph still takes a line, even when its cached text is empty.
|
||||
if sc.fields.iter().any(|f| f.phase == Phase::Instr) && para.plain_text().is_empty() {
|
||||
para.mark.hidden = Some(true);
|
||||
}
|
||||
para.normalize();
|
||||
para
|
||||
}
|
||||
|
||||
/// A tracked run formatting change in `rpr` (`w:rPrChange`, §17.13.5.31): the formatting
|
||||
/// before it. A change nested inside it is ignored.
|
||||
pub(super) fn rpr_change(&mut self, rpr: &El) -> Option<Box<PropChange<CharProps>>> {
|
||||
let ch = rpr.child("w:rPrChange")?;
|
||||
let old = ch.child("w:rPr").map(|o| self.pc.rpr(o).formatting()).unwrap_or_default();
|
||||
PropChange::boxed(self.revision(RevisionKind::Format, ch), old)
|
||||
}
|
||||
|
||||
pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps {
|
||||
let (mut sp, refs) = sectpr(s);
|
||||
// §17.13.5.32: the section's properties before a tracked change (no header references).
|
||||
if let Some(ch) = s.child("w:sectPrChange") {
|
||||
let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default();
|
||||
sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
for r in refs {
|
||||
let Some(rel) = rels.by_id(&r.rid) else { continue };
|
||||
if rel.external {
|
||||
continue;
|
||||
}
|
||||
let target = rel.target.clone();
|
||||
let Some(id) = self.load_header_footer(&target, r.footer) else { continue };
|
||||
let set = if r.footer { &mut sp.footers } else { &mut sp.headers };
|
||||
match r.kind.as_str() {
|
||||
"first" => set.first = Some(id),
|
||||
"even" => set.even = Some(id),
|
||||
_ => set.default = Some(id),
|
||||
}
|
||||
}
|
||||
sp
|
||||
}
|
||||
|
||||
// ---- tables ----
|
||||
|
||||
fn read_table(&mut self, sc: &mut StoryCtx, t: &El, rels: &Rels, depth: usize) -> Option<Table> {
|
||||
let mut table = Table::default();
|
||||
if let Some(p) = t.child("w:tblPr") {
|
||||
table.props = self.pc.tblpr(p);
|
||||
if let Some(ch) = p.child("w:tblPrChange") {
|
||||
let old = ch.child("w:tblPr").map(|o| self.pc.tblpr(o)).unwrap_or_default();
|
||||
table.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
}
|
||||
if let Some(g) = t.child("w:tblGrid") {
|
||||
table.grid = g
|
||||
.children("w:gridCol")
|
||||
.take(MAX_COLS)
|
||||
.map(|c| crate::units::tw(c, "w:w").unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT))
|
||||
.collect();
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
collect(t, "w:tr", &mut rows, &mut Vec::new(), 0);
|
||||
for (tr, row_controls) in rows.into_iter().take(MAX_ROWS) {
|
||||
let mut row = Row { props: tr.child("w:trPr").map(trpr).unwrap_or_default(), cells: Vec::new(), controls: row_controls };
|
||||
row.props.exceptions = tr.child("w:tblPrEx").map(|e| Box::new(self.pc.tblpr(e)));
|
||||
if let Some(ch) = tr.child("w:trPr").and_then(|p| p.child("w:trPrChange")) {
|
||||
let old = ch.child("w:trPr").map(trpr).unwrap_or_default();
|
||||
row.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
let mut tcs = Vec::new();
|
||||
collect(tr, "w:tc", &mut tcs, &mut Vec::new(), 0);
|
||||
for (tc, cell_controls) in tcs.into_iter().take(MAX_COLS) {
|
||||
let (mut props, hcont) = tc
|
||||
.child("w:tcPr")
|
||||
.map(|p| tcpr(&self.pc, p))
|
||||
.unwrap_or_else(|| (wordcraft_doc::props::CellProps { span: 1, ..Default::default() }, false));
|
||||
if let Some(ch) = tc.child("w:tcPr").and_then(|p| p.child("w:tcPrChange")) {
|
||||
let old = ch.child("w:tcPr").map(|o| tcpr(&self.pc, o).0).unwrap_or_default();
|
||||
props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
if hcont && let Some(prev) = row.cells.last_mut() {
|
||||
prev.props.span = (prev.props.span + props.span.max(1)).min(63);
|
||||
continue;
|
||||
}
|
||||
let mut blocks = Blocks::new();
|
||||
self.read_blocks(sc, tc, rels, &mut blocks, depth + 4);
|
||||
self.flush_pending(sc, &mut blocks);
|
||||
row.cells.push(Cell { props, blocks, controls: cell_controls });
|
||||
}
|
||||
if !row.cells.is_empty() {
|
||||
table.rows.push(row);
|
||||
}
|
||||
}
|
||||
if table.rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if table.grid.is_empty() {
|
||||
let cols = table.cols().clamp(1, MAX_COLS);
|
||||
table.grid = vec![72.0; cols];
|
||||
}
|
||||
Some(table)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
use super::*;
|
||||
|
||||
impl Reader<'_> {
|
||||
// ---- drawings ----
|
||||
|
||||
/// Read a `w:drawing` frame and dispatch its payload to the picture, shape or graphic reader.
|
||||
/// Frame dimensions are converted from EMUs to bounded point values; placement, alternative
|
||||
/// text and drawing ids are carried into the object. Returns `None` when the frame or payload
|
||||
/// cannot be read. Ink written by WordCraft keeps its tool's caps rather than an outline cap.
|
||||
pub(super) fn read_drawing(&mut self, sc: &mut StoryCtx, d: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let (c, anchored) = match d.child("wp:inline") {
|
||||
Some(c) => (c, false),
|
||||
None => (d.child("wp:anchor")?, true),
|
||||
};
|
||||
let ext = c.child("wp:extent");
|
||||
let dim = |n: &str| ext.and_then(|e| e.attr(n)).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
|
||||
let (w, h) = (dim("cx"), dim("cy"));
|
||||
let (alt, meta) = nv_alt(c.child("wp:docPr"));
|
||||
let mut float = if anchored { anchor_float(c) } else { Float::default() };
|
||||
float.alt = meta;
|
||||
float.hidden = c.child("wp:docPr").is_some_and(|p| on_off_attr(p, "hidden"));
|
||||
if let Some(e) = c.child("wp:effectExtent") {
|
||||
for (slot, n) in float.effect.iter_mut().zip(["l", "t", "r", "b"]) {
|
||||
// Negative for a rotated object narrower than its frame; settled below.
|
||||
*slot = e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(-1584.0, 1584.0);
|
||||
}
|
||||
}
|
||||
let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?;
|
||||
// Checked by URI before the blip search: a diagram's data can hold a blip further down.
|
||||
let uri = gd.attr("uri").unwrap_or("");
|
||||
if uri == INK_URI {
|
||||
let content = gd.child("w14:contentPart")?;
|
||||
let part = crate::read::part_of(rels, content.attr("r:id")?, INK_REL)?;
|
||||
let pkg = self.pkg;
|
||||
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") {
|
||||
return None;
|
||||
}
|
||||
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
|
||||
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
|
||||
if *e != 0.0 {
|
||||
*e = (*e - p).clamp(0.0, 1584.0);
|
||||
}
|
||||
}
|
||||
// The renderer's existing missing-picture box marks ink without a usable preview.
|
||||
// InkML stays opaque: no approximation of its strokes or conversion to freeforms.
|
||||
let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media: String::new() }];
|
||||
let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, items, w, h, source: Some(source), ..Default::default() });
|
||||
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
|
||||
}
|
||||
let graphic_kind = if uri.ends_with("/chart") {
|
||||
Some(GraphicKind::Chart)
|
||||
} else if uri.ends_with("/diagram") {
|
||||
Some(GraphicKind::Diagram)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(kind) = graphic_kind {
|
||||
// Kept so saving writes the chart or diagram back: the frame around it is written
|
||||
// from the object's (possibly moved or resized) size and position.
|
||||
let extra: Vec<String> = match kind {
|
||||
GraphicKind::Chart | GraphicKind::Ink => Vec::new(),
|
||||
GraphicKind::Diagram => self.diagram_drawing_rel(gd, rels).into_iter().collect(),
|
||||
};
|
||||
let source = c.child("a:graphic").and_then(|g| self.keep_embedded(g, rels, &extra));
|
||||
let graphic = self.graphic(kind, gd, rels, w, h, source);
|
||||
float.effect = float.effect.map(|e| e.max(0.0));
|
||||
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
|
||||
}
|
||||
// A textbox may contain a picture; its descendant blip is not the outer payload.
|
||||
let direct_shape = gd.child("wps:wsp").filter(|shape| {
|
||||
// Picture fills still use the image fallback; the shape model has no image fill.
|
||||
shape.child("wps:spPr").and_then(|props| props.child("a:blipFill")).is_none()
|
||||
});
|
||||
let mut obj = if let Some(c) = gd.child("wpc:wpc") {
|
||||
self.read_canvas(sc, c, rels, w, h, float)
|
||||
} else if let Some(g) = gd.child("wpg:wgp") {
|
||||
self.read_group(sc, g, rels, w, h, float)?
|
||||
} else if direct_shape.is_none() && gd.find("a:blip").is_some() {
|
||||
self.read_pic(gd, rels, w, h, alt.clone(), float)?
|
||||
} else {
|
||||
let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?;
|
||||
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
|
||||
// A freeform: whether it's ink WordCraft wrote.
|
||||
if let InlineObject::Shape { freeform: Some(f), extra, .. } = &mut obj
|
||||
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
|
||||
&& let Some(tool) = crate::read::freeform::ink_tool(name)
|
||||
{
|
||||
let f = Arc::make_mut(f);
|
||||
f.ink = Some(tool);
|
||||
f.order = crate::read::freeform::ink_order(name);
|
||||
// Ink's round ends belong to its tool, not the optional shape outline. Keep
|
||||
// its canonical model unchanged when reading the round cap the writer adds.
|
||||
extra.cap = Default::default();
|
||||
}
|
||||
obj
|
||||
};
|
||||
if !alt.is_empty() {
|
||||
obj.set_alt_text(&alt);
|
||||
}
|
||||
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
|
||||
// Word's effect extent also covers a rotated object's overhang; the model keeps that
|
||||
// apart (it follows from the angle), so take it back out.
|
||||
if let Some(f) = obj.float_mut() {
|
||||
let (px, py) = f.spin_pad(w, h);
|
||||
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
|
||||
// Zero adds no effects room when rotation contracts this axis.
|
||||
if *e != 0.0 {
|
||||
*e = (*e - p).clamp(0.0, 1584.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(obj)
|
||||
}
|
||||
|
||||
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
|
||||
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
|
||||
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
|
||||
let drawing = choice.child("w:drawing")?;
|
||||
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
|
||||
return None;
|
||||
}
|
||||
let fallback = ac.child("mc:Fallback");
|
||||
let preview = fallback.and_then(|f| f.child("w:pict")).and_then(|p| self.read_vml(sc, p, rels));
|
||||
let Some(mut object) = self.read_drawing(sc, drawing, rels) else { return preview };
|
||||
if let InlineObject::Graphic { w, h, graphic, .. } = &mut object {
|
||||
let graphic = Arc::make_mut(graphic);
|
||||
if let Some(InlineObject::Image { media, .. }) = preview {
|
||||
graphic.items = vec![GraphicItem::Image { rect: [0.0, 0.0, *w, *h], media }];
|
||||
}
|
||||
if let Some(fallback) = fallback {
|
||||
let kept =
|
||||
El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] };
|
||||
if let Some(source) = self.keep_embedded(&kept, rels, &[]) {
|
||||
graphic.source = Some(source);
|
||||
}
|
||||
// A malformed or over-budget fallback must not discard a valid native part.
|
||||
}
|
||||
}
|
||||
Some(object)
|
||||
}
|
||||
|
||||
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
|
||||
/// fallback picture, keeping both branches so saving writes the chart back rather than only
|
||||
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
|
||||
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = ac
|
||||
.children("mc:Choice")
|
||||
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
|
||||
// A requirement in a namespace we can't name couldn't be written back.
|
||||
let requires = choice.attr("Requires").unwrap_or("");
|
||||
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
|
||||
return None;
|
||||
}
|
||||
let fallback = ac.child("mc:Fallback")?;
|
||||
let kept = El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] };
|
||||
let source = self.keep_embedded(&kept, rels, &[])?;
|
||||
let Some(InlineObject::Image { media, w, h, alt, float, .. }) = self.read_drawing(sc, fallback.child("w:drawing")?, rels) else {
|
||||
return None;
|
||||
};
|
||||
let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media }];
|
||||
let graphic = Arc::new(Graphic { kind: GraphicKind::Chart, items, w, h, source: Some(source), ..Default::default() });
|
||||
Some(InlineObject::Graphic { w, h, alt, float, graphic })
|
||||
}
|
||||
|
||||
/// A group or canvas member that is native ink (`content`, a `w14:contentPart`, inside
|
||||
/// `alternate` when it has a picture fallback): what to write back (the whole alternate when
|
||||
/// it can be kept, else the ink alone) and its fallback picture (a `Document::media` key).
|
||||
/// `None` when the ink's part is missing or isn't InkML.
|
||||
fn ink_member(&mut self, content: &El, alternate: Option<&El>, rels: &Rels) -> Option<(Arc<Embedded>, Option<String>)> {
|
||||
let InlineObject::Graphic { graphic, .. } = self.read_ink_content_part(content, rels)? else { return None };
|
||||
let mut source = graphic.source.clone()?;
|
||||
let mut preview = None;
|
||||
let choice = alternate.and_then(children_of_choice).filter(|c| c.name == "mc:Choice");
|
||||
if let (Some(choice), Some(fallback)) = (choice, alternate.and_then(|a| a.child("mc:Fallback"))) {
|
||||
let kept = El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] };
|
||||
// A malformed or over-budget fallback must not discard a valid native part.
|
||||
if let Some(whole) = self.keep_embedded(&kept, rels, &[]) {
|
||||
source = whole;
|
||||
preview = match fallback.child("pic:pic").and_then(|p| self.read_pic(p, rels, 0.0, 0.0, String::new(), Float::default())) {
|
||||
Some(InlineObject::Image { media, .. }) => Some(media),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some((source, preview))
|
||||
}
|
||||
|
||||
pub(super) fn read_ink_content_part(&mut self, content: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let part = crate::read::part_of(rels, content.attr("r:id")?, INK_REL)?;
|
||||
let pkg = self.pkg;
|
||||
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") {
|
||||
return None;
|
||||
}
|
||||
let source = self.keep_embedded(content, rels, &[])?;
|
||||
let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, source: Some(source), ..Default::default() });
|
||||
Some(InlineObject::Graphic { w: 0.0, h: 0.0, alt: String::new(), float: Float::default(), graphic })
|
||||
}
|
||||
|
||||
/// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left
|
||||
/// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word
|
||||
/// stores beside them. Built once per part and size; once the file's graphic budget is spent,
|
||||
/// later ones are empty.
|
||||
fn graphic(&mut self, kind: GraphicKind, gd: &El, rels: &Rels, w: f32, h: f32, source: Option<Arc<Embedded>>) -> Arc<Graphic> {
|
||||
let part = match kind {
|
||||
GraphicKind::Chart => gd.child("c:chart").and_then(|c| c.attr("r:id")).and_then(|id| crate::read::part_of(rels, id, rt::CHART)),
|
||||
GraphicKind::Diagram => self.diagram_part(gd, rels),
|
||||
GraphicKind::Ink => None,
|
||||
};
|
||||
let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source, chart: None, smart_art: None }) };
|
||||
let key = (kind, path, w.to_bits(), h.to_bits());
|
||||
if let Some(g) = self.graphics.get(&key) {
|
||||
return g.clone();
|
||||
}
|
||||
let items = if self.graphic_budget == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) };
|
||||
self.graphic_budget = self.graphic_budget.saturating_sub(graphic_work(&items));
|
||||
// A chart or SmartArt that is exactly what WordCraft writes for its model can be edited
|
||||
// (and is written from the model); any other stays as it was read.
|
||||
let chart = match kind {
|
||||
GraphicKind::Chart if !items.is_empty() => self.graphic_part(&key.1).and_then(|space| crate::chart_spec::spec_of(&space)).map(Arc::new),
|
||||
_ => None,
|
||||
};
|
||||
let smart_art = match kind {
|
||||
GraphicKind::Diagram if !items.is_empty() => self.smart_art_spec(gd, rels).map(Arc::new),
|
||||
_ => None,
|
||||
};
|
||||
let source = if chart.is_some() || smart_art.is_some() { None } else { source };
|
||||
let g = Arc::new(Graphic { kind, items, w, h, source, chart, smart_art });
|
||||
self.graphics.insert(key, g.clone());
|
||||
g
|
||||
}
|
||||
|
||||
/// The items of the chart part or diagram drawing part at `path`.
|
||||
fn graphic_items(&mut self, kind: GraphicKind, path: &str, w: f32, h: f32) -> Vec<GraphicItem> {
|
||||
match kind {
|
||||
GraphicKind::Chart => match self.graphic_part(path) {
|
||||
Some(space) => crate::read::chart::chart_items(&space, &self.doc.settings.theme_colors, w, h),
|
||||
None => Vec::new(),
|
||||
},
|
||||
GraphicKind::Diagram => self.diagram_drawing(path, w, h),
|
||||
GraphicKind::Ink => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture: the first `a:blip` in `pic` and its crop.
|
||||
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, mut float: Float) -> Option<InlineObject> {
|
||||
let blip = pic.find("a:blip")?;
|
||||
float.set_spin(xfrm_spin(pic.find("pic:spPr")));
|
||||
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
|
||||
let mut crop = [0.0f32; 4];
|
||||
if let Some(sr) = pic.find("a:srcRect") {
|
||||
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
|
||||
if let Some(v) = sr.attr(n).and_then(int)
|
||||
&& let Some(slot) = crop.get_mut(i)
|
||||
{
|
||||
*slot = (v.clamp(0, 100_000) as f32 / 100_000.0).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None })
|
||||
}
|
||||
|
||||
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
|
||||
/// flattened into it. `None` when it has no member we can show.
|
||||
fn read_group(&mut self, sc: &mut StoryCtx, g: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> Option<InlineObject> {
|
||||
let x = group_xfrm(g.child("wpg:grpSpPr"));
|
||||
float.set_spin(xfrm_spin(g.child("wpg:grpSpPr")));
|
||||
// The members' space; without `a:chExt` it is the group's own size.
|
||||
let (ch_w, ch_h) = match x.ch_ext {
|
||||
Some((cw, ch)) if cw > 0.0 && ch > 0.0 => (cw, ch),
|
||||
_ => (w.max(1.0), h.max(1.0)),
|
||||
};
|
||||
let (cx, cy) = x.ch_off.unwrap_or((0.0, 0.0));
|
||||
let mut children = Vec::new();
|
||||
let outer = self.group_fill.take();
|
||||
self.group_fill = own_fill(g.child("wpg:grpSpPr"), &self.doc.settings.theme_colors, None).flatten();
|
||||
self.group_members(sc, g, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
|
||||
self.group_fill = outer;
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None })
|
||||
}
|
||||
|
||||
/// A `wpc:wpc` Drawing Canvas (`w` × `h`, ECMA-376 Part 1 §20.4 and the Word 2010 canvas
|
||||
/// schema): its `wpc:bg` fill, `wpc:whole` outline and members, which sit in the canvas's
|
||||
/// own space (offsets from its top-left corner, unscaled). An empty canvas is kept.
|
||||
/// The canvas schema has no `a:xfrm` of its own (nor does the anchor), so a turned or
|
||||
/// flipped canvas keeps its turn in our `wpc:extLst` extension ([`CANVAS_SPIN_EXT`]).
|
||||
fn read_canvas(&mut self, sc: &mut StoryCtx, c: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> InlineObject {
|
||||
let spin_ext = c.child("wpc:extLst").and_then(|l| l.els().find(|e| e.name == "a:ext" && e.attr("uri") == Some(crate::CANVAS_SPIN_EXT)));
|
||||
float.set_spin(xfrm_spin(spin_ext));
|
||||
let solid = |e: Option<&El>| {
|
||||
e.and_then(|f| f.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| c.attr("val")).and_then(Rgb::parse)
|
||||
};
|
||||
let ln = c.child("wpc:whole").and_then(|e| e.child("a:ln"));
|
||||
let style = wordcraft_doc::para::CanvasStyle {
|
||||
fill: own_fill(c.child("wpc:bg"), &self.doc.settings.theme_colors, None).flatten(),
|
||||
stroke: solid(ln),
|
||||
stroke_width: ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0),
|
||||
};
|
||||
let mut children = Vec::new();
|
||||
self.group_members(sc, c, rels, (0.0, 0.0, 1.0, 1.0), 0, &mut children);
|
||||
InlineObject::Group { w, h, float, ch_w: w.max(1.0), ch_h: h.max(1.0), children, canvas: Some(style) }
|
||||
}
|
||||
|
||||
/// The members of group `g` into `out`, mapped into the outermost group's space by `t`
|
||||
/// (x0, y0, sx, sy: outer = x0 + inner × sx).
|
||||
fn group_members(
|
||||
&mut self,
|
||||
sc: &mut StoryCtx,
|
||||
g: &El,
|
||||
rels: &Rels,
|
||||
t: (f32, f32, f32, f32),
|
||||
depth: usize,
|
||||
out: &mut Vec<wordcraft_doc::para::GroupChild>,
|
||||
) {
|
||||
let (x0, y0, sx, sy) = t;
|
||||
for e in g.els() {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
return;
|
||||
}
|
||||
// Native ink (`w14:contentPart`), alone or with a picture fallback: kept whole, in its
|
||||
// place among the members. Ink that can't be kept gives way to its fallback.
|
||||
let (e, ink) = match e.name.as_str() {
|
||||
"w14:contentPart" => (e, self.ink_member(e, None, rels)),
|
||||
"mc:AlternateContent" => {
|
||||
let choice = children_of_choice(e);
|
||||
match choice.filter(|c| c.name == "mc:Choice").and_then(|c| c.els().next()).filter(|c| c.name == "w14:contentPart") {
|
||||
Some(content) => match self.ink_member(content, Some(e), rels) {
|
||||
Some(ink) => (content, Some(ink)),
|
||||
None => match e.child("mc:Fallback").and_then(|f| f.els().next()) {
|
||||
Some(c) => (c, None),
|
||||
None => continue,
|
||||
},
|
||||
},
|
||||
None => match choice.and_then(|c| c.els().next()) {
|
||||
Some(c) => (c, None),
|
||||
None => continue,
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => (e, None),
|
||||
};
|
||||
let sppr = match e.name.as_str() {
|
||||
"wps:wsp" => e.child("wps:spPr"),
|
||||
"pic:pic" => e.child("pic:spPr"),
|
||||
"w14:contentPart" if ink.is_some() => Some(e),
|
||||
// A group inside a canvas is a whole `wpg:wgp`.
|
||||
"wpg:grpSp" | "wpg:wgp" => e.child("wpg:grpSpPr"),
|
||||
_ => continue,
|
||||
};
|
||||
let x = group_xfrm(sppr);
|
||||
let ((ox, oy), (ew, eh)) = (x.off, x.ext);
|
||||
if e.name == "wpg:grpSp" || e.name == "wpg:wgp" {
|
||||
if depth >= 8 {
|
||||
continue;
|
||||
}
|
||||
// Its members' space maps onto its own box.
|
||||
let (cox, coy) = x.ch_off.unwrap_or((0.0, 0.0));
|
||||
let (cw, ch) = x.ch_ext.filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
|
||||
let (kx, ky) = (ew / cw, eh / ch);
|
||||
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
|
||||
// Its own fill, for its members' `a:grpFill` (#1500).
|
||||
// One without a fill of its own passes its group's on.
|
||||
let own = own_fill(sppr, &self.doc.settings.theme_colors, self.group_fill.as_ref()).unwrap_or_else(|| self.group_fill.clone());
|
||||
let outer = std::mem::replace(&mut self.group_fill, own);
|
||||
self.group_members(sc, e, rels, nt, depth + 1, out);
|
||||
self.group_fill = outer;
|
||||
continue;
|
||||
}
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
|
||||
let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0));
|
||||
let nv = match e.name.as_str() {
|
||||
"pic:pic" => e.find("pic:cNvPr"),
|
||||
"w14:contentPart" => e.child("w14:nvContentPartPr").and_then(|p| p.child("w14:cNvPr")),
|
||||
_ => e.child("wps:cNvPr"),
|
||||
};
|
||||
let (alt, meta) = nv_alt(nv);
|
||||
let float = Float { alt: meta, hidden: nv.is_some_and(|p| on_off_attr(p, "hidden")), ..Float::default() };
|
||||
let obj = if let Some((source, preview)) = ink {
|
||||
let mut float = float;
|
||||
float.set_spin(xfrm_spin(e.child("w14:xfrm")));
|
||||
// As drawing-level ink: its fallback picture, else the missing-picture box.
|
||||
let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media: preview.unwrap_or_default() }];
|
||||
let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, items, w, h, source: Some(source), ..Default::default() });
|
||||
Some(InlineObject::Graphic { w, h, alt: alt.clone(), float, graphic })
|
||||
} else if e.name == "pic:pic" {
|
||||
self.read_pic(e, rels, w, h, alt.clone(), float)
|
||||
} else {
|
||||
let mut o = self.read_wsp(sc, e, rels, w, h, float);
|
||||
self.set_shape_id(&mut o, e.child("wps:cNvPr").and_then(|p| p.attr("id")));
|
||||
Some(o)
|
||||
};
|
||||
let obj = obj.map(|mut o| {
|
||||
o.set_alt_text(&alt);
|
||||
o
|
||||
});
|
||||
if let Some(obj) = obj {
|
||||
out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + ox * sx, -max), y: fit(y0 + oy * sy, -max), obj });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a shape from `wps:wsp`, using frame dimensions `w` and `h` in points and the caller's
|
||||
/// placement. Reads geometry, paint, effects and connector ends, and creates a text-box story
|
||||
/// when present within the nesting limit. Explicit outline caps are preserved; missing or
|
||||
/// unknown caps use flat ends, without resolving theme-inherited caps.
|
||||
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> InlineObject {
|
||||
let sppr = wsp.child("wps:spPr");
|
||||
float.set_spin(xfrm_spin(sppr));
|
||||
let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
|
||||
let txbx = wsp.child("wps:txbx").and_then(|t| t.child("w:txbxContent"));
|
||||
let mut kind = preset_kind(prst);
|
||||
let is_tb = wsp.child("wps:cNvSpPr").and_then(|c| c.attr("txBox")).is_some_and(|v| v == "1" || v == "true");
|
||||
if is_tb || (txbx.is_some() && kind == ShapeKind::Rectangle) {
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
// Explicit paint, else the theme's through the style references (#611).
|
||||
let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors, &self.theme_fills, self.group_fill.as_ref());
|
||||
let (stroke, stroke_width) = (paint.stroke, paint.stroke_width);
|
||||
let fill = if kind.is_open() { None } else { paint.fill };
|
||||
let ln = sppr.and_then(|s| s.child("a:ln"));
|
||||
let effects = sppr.and_then(|s| s.child("a:effectLst")).map(effect_list).unwrap_or_default();
|
||||
let story = match txbx {
|
||||
Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)),
|
||||
_ => None,
|
||||
};
|
||||
// A box continuing another's text (Create Link): its own, empty story, linked up once the
|
||||
// whole document is read.
|
||||
let linked = wsp.child("wps:linkedTxbx").filter(|_| txbx.is_none());
|
||||
let story = match linked {
|
||||
Some(l) => {
|
||||
kind = ShapeKind::TextBox;
|
||||
let id = l.attr("id").and_then(|v| v.trim().parse::<u32>().ok());
|
||||
let seq = l.attr("seq").and_then(|v| v.trim().parse::<u32>().ok());
|
||||
match (id, seq) {
|
||||
(Some(id), Some(seq)) if seq > 0 => {
|
||||
let part = self.doc.add_part(PartKind::TextBox, Blocks::new());
|
||||
self.txbx_links.push((id, seq, part));
|
||||
Some(part)
|
||||
}
|
||||
_ => story,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let (Some(part), Some(id)) = (story, wsp.child("wps:txbx").and_then(|t| t.attr("id")).and_then(|v| v.trim().parse::<u32>().ok())) {
|
||||
self.txbx_links.push((id, 0, part));
|
||||
}
|
||||
story
|
||||
}
|
||||
};
|
||||
// Text direction and alignment (`wps:bodyPr`).
|
||||
if let (Some(id), Some(bp)) = (story, wsp.child("wps:bodyPr"))
|
||||
&& let Some(part) = self.doc.parts.get_mut(&id)
|
||||
{
|
||||
if let Some(v) = bp.attr("vert") {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::from_ooxml(v);
|
||||
}
|
||||
if let Some(a) = bp.attr("anchor") {
|
||||
part.body.anchor = wordcraft_doc::props::valign_from_anchor(a);
|
||||
}
|
||||
// Internal margins, each defaulting to Word's (#743).
|
||||
let mut inset = wordcraft_doc::TEXT_BOX_INSET;
|
||||
for (slot, n) in inset.iter_mut().zip(["lIns", "tIns", "rIns", "bIns"]) {
|
||||
if let Some(v) = bp.attr(n).and_then(int) {
|
||||
*slot = u32::try_from(v.clamp(0, i64::from(wordcraft_doc::MAX_TEXT_BOX_INSET))).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
|
||||
part.body.auto_fit = bp.child("a:spAutoFit").is_some();
|
||||
}
|
||||
// The shape style's text colour (`a:fontRef`, often `lt1`: white on the accent fill).
|
||||
if let Some(id) = story
|
||||
&& let Some(c) = wsp.child("wps:style").and_then(|s| s.child("a:fontRef"))
|
||||
&& let Some(rgb) = crate::read::drawing_color::color(c, &self.doc.settings.theme_colors)
|
||||
&& let Some(part) = self.doc.parts.get_mut(&id)
|
||||
{
|
||||
part.body.auto_color = Some(rgb);
|
||||
}
|
||||
let mut extra = ShapeExtra {
|
||||
dash: crate::read::shape_style::line_dash(ln),
|
||||
cap: wordcraft_doc::connector::LineCap::from_ooxml(ln.and_then(|e| e.attr("cap")).unwrap_or("flat")),
|
||||
outline_fill: paint.outline_fill,
|
||||
..ShapeExtra::default()
|
||||
};
|
||||
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
|
||||
// rounded rectangles keep the default.
|
||||
if prst == "roundRect" {
|
||||
extra.corner = sppr
|
||||
.and_then(|s| s.child("a:prstGeom"))
|
||||
.and_then(|g| preset_adj(g, "adj"))
|
||||
.and_then(|v| u32::try_from(v.clamp(0, i64::from(wordcraft_doc::connector::MAX_CORNER))).ok());
|
||||
}
|
||||
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.
|
||||
let arrow = |tag: &str| ln.and_then(|l| l.child(tag)).and_then(|e| e.attr("type")).is_some_and(|t| t != "none");
|
||||
extra.arrow_start = arrow("a:headEnd");
|
||||
extra.arrow_end = arrow("a:tailEnd");
|
||||
// A connector's glued ends (ECMA-376 §20.1.2.2.36, §20.1.2.2.13).
|
||||
if let Some(cn) = wsp.child("wps:cNvCnPr") {
|
||||
let end = |tag: &str| {
|
||||
let e = cn.child(tag)?;
|
||||
let id = e.attr("id").and_then(u32_of).filter(|v| *v != 0)?;
|
||||
let site = e.attr("idx").and_then(u32_of).unwrap_or(0).min(wordcraft_doc::connector::MAX_SITE);
|
||||
Some(ConnEnd { id, site })
|
||||
};
|
||||
extra.start = end("a:stCxn");
|
||||
extra.end = end("a:endCxn");
|
||||
self.glued.extend(extra.start.iter().chain(extra.end.iter()).map(|e| e.id));
|
||||
}
|
||||
extra.warp = wsp.child("wps:bodyPr").and_then(|b| b.child("a:prstTxWarp")).and_then(text_warp);
|
||||
// A freeform: its custom geometry (#743: in a group too).
|
||||
let freeform = sppr.and_then(|s| crate::read::freeform::cust_geom(s, w, h)).map(|mut f| {
|
||||
f.alpha = crate::read::freeform::line_alpha(ln);
|
||||
kind = ShapeKind::Freeform;
|
||||
Arc::new(f)
|
||||
});
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform, effects, extra }
|
||||
}
|
||||
|
||||
/// Keep drawing id `id` on a shape read from a file (see [`Reader::glued`]).
|
||||
fn set_shape_id(&mut self, obj: &mut InlineObject, id: Option<&str>) {
|
||||
if let InlineObject::Shape { extra, .. } = obj
|
||||
&& let Some(id) = id.and_then(u32_of).filter(|v| *v != 0)
|
||||
{
|
||||
extra.id = id;
|
||||
self.shape_ids = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
|
||||
let mut inner = StoryCtx { story_depth: sc.story_depth + 1, ..Default::default() };
|
||||
let mut blocks = Blocks::new();
|
||||
// Shapes in the text aren't members of a group the box may be in.
|
||||
let group = self.group_fill.take();
|
||||
self.read_blocks(&mut inner, content, rels, &mut blocks, 0);
|
||||
self.group_fill = group;
|
||||
self.flush_pending(&mut inner, &mut blocks);
|
||||
self.doc.add_part(PartKind::TextBox, blocks)
|
||||
}
|
||||
}
|
||||
|
||||
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
|
||||
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
|
||||
fn nv_alt(p: Option<&El>) -> (String, AltText) {
|
||||
let Some(p) = p else { return Default::default() };
|
||||
let title = cap_alt(p.attr("title").unwrap_or(""));
|
||||
let descr = p.attr("descr").filter(|s| !s.is_empty()).map(cap_alt).unwrap_or_else(|| title.clone());
|
||||
let decorative = p
|
||||
.child("a:extLst")
|
||||
.into_iter()
|
||||
.flat_map(|l| l.children("a:ext"))
|
||||
.filter(|x| x.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(crate::xml::DECORATIVE_EXT)))
|
||||
.flat_map(|x| x.els())
|
||||
.any(|d| d.local() == "decorative" && on_off_attr(d, "val"));
|
||||
(descr, AltText { text: String::new(), title, decorative })
|
||||
}
|
||||
|
||||
fn anchor_float(c: &El) -> Float {
|
||||
// `relativeHeight`: the stacking order Word draws floating objects in (higher on top), which
|
||||
// need not be their order in the file.
|
||||
let z = c.attr("relativeHeight").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0);
|
||||
let mut f = Float { wrap: Wrap::Square, z, ..Float::default() };
|
||||
let behind = on_off_attr(c, "behindDoc");
|
||||
// Word wraps a position relative to the page's size (`wp14:pctPosVOffset`) in
|
||||
// `mc:AlternateContent`; its Fallback gives the same position as an offset (#743).
|
||||
let kids = c.els().flat_map(|k| match k.name.as_str() {
|
||||
"mc:AlternateContent" => k.child("mc:Fallback").map(|f| f.els().collect()).unwrap_or_default(),
|
||||
_ => vec![k],
|
||||
});
|
||||
for k in kids {
|
||||
match k.name.as_str() {
|
||||
"wp:wrapSquare" => {
|
||||
f.wrap = Wrap::Square;
|
||||
f.wrap_text = k.attr("wrapText").and_then(WrapText::from_ooxml).unwrap_or_default();
|
||||
}
|
||||
"wp:wrapTight" | "wp:wrapThrough" => {
|
||||
f.wrap = if k.name == "wp:wrapTight" { Wrap::Tight } else { Wrap::Through };
|
||||
f.wrap_text = k.attr("wrapText").and_then(WrapText::from_ooxml).unwrap_or_default();
|
||||
f.wrap_polygon = k.child("wp:wrapPolygon").and_then(wrap_polygon).map(Arc::new);
|
||||
}
|
||||
"wp:wrapTopAndBottom" => f.wrap = Wrap::TopAndBottom,
|
||||
"wp:wrapNone" => f.wrap = if behind { Wrap::BehindText } else { Wrap::InFrontOfText },
|
||||
"wp:positionH" | "wp:positionV" => {
|
||||
let horiz = k.name == "wp:positionH";
|
||||
let rel = match (k.attr("relativeFrom").unwrap_or(""), horiz) {
|
||||
("page", _) => Anchor::Page,
|
||||
("margin", _) => Anchor::Margin,
|
||||
("leftMargin", true) => Anchor::LeftMargin,
|
||||
("rightMargin", true) => Anchor::RightMargin,
|
||||
("insideMargin", _) => Anchor::InsideMargin,
|
||||
("outsideMargin", _) => Anchor::OutsideMargin,
|
||||
("character", true) => Anchor::Character,
|
||||
("topMargin", false) => Anchor::TopMargin,
|
||||
("bottomMargin", false) => Anchor::BottomMargin,
|
||||
("line", false) => Anchor::Line,
|
||||
("paragraph", false) => Anchor::Paragraph,
|
||||
(_, true) => Anchor::Column,
|
||||
(_, false) => Anchor::Paragraph,
|
||||
};
|
||||
let align = k.child("wp:align").and_then(|a| FloatAlign::from_ooxml(a.text().trim()));
|
||||
let off = k.child("wp:posOffset").and_then(|o| measure(&o.text(), 12_700.0)).unwrap_or(0.0);
|
||||
if horiz {
|
||||
f.h_rel = rel;
|
||||
f.h_align = align;
|
||||
f.x = off;
|
||||
} else {
|
||||
f.v_rel = rel;
|
||||
f.v_align = align;
|
||||
f.y = off;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let dist = |n: &str| c.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 1584.0);
|
||||
f.dist = dist("distL").max(dist("distR"));
|
||||
f.dist_top = dist("distT");
|
||||
f.dist_bottom = dist("distB");
|
||||
// Required by the schema; a file leaving it out lays out in the cell, as before.
|
||||
f.outside_cell = !on_off_attr(c, "layoutInCell") && c.attr("layoutInCell").is_some();
|
||||
f
|
||||
}
|
||||
|
||||
/// A `wp:wrapPolygon`: `wp:start`, then the `wp:lineTo` points (ECMA-376 Part 1, §20.4.2.16).
|
||||
fn wrap_polygon(e: &El) -> Option<wordcraft_doc::wrap::WrapPolygon> {
|
||||
let pts = e
|
||||
.els()
|
||||
.filter(|k| k.name == "wp:start" || k.name == "wp:lineTo")
|
||||
.take(wordcraft_doc::wrap::MAX_WRAP_POINTS + 1)
|
||||
.filter_map(|k| Some((int(k.attr("x")?)?, int(k.attr("y")?)?)));
|
||||
let mut pts: Vec<(i64, i64)> = pts.collect();
|
||||
// The last point usually repeats the start to close the polygon.
|
||||
if pts.len() > 3 && pts.first() == pts.last() {
|
||||
pts.pop();
|
||||
}
|
||||
let edited = on_off_attr(e, "edited");
|
||||
// An unedited polygon round the whole frame says nothing the object doesn't (it's what we
|
||||
// write when there's no outline): keep deriving the outline.
|
||||
let s = i64::from(wordcraft_doc::wrap::WRAP_SPACE);
|
||||
let mut corners = pts.clone();
|
||||
corners.sort_unstable();
|
||||
if !edited && corners == [(0, 0), (0, s), (s, 0), (s, s)] {
|
||||
return None;
|
||||
}
|
||||
wordcraft_doc::wrap::WrapPolygon::new(edited, pts)
|
||||
}
|
||||
|
||||
/// What a graphic's items cost to keep and draw: one per item, plus one per path segment.
|
||||
fn graphic_work(items: &[GraphicItem]) -> usize {
|
||||
items.iter().map(|it| if let GraphicItem::Path { segs, .. } = it { segs.len() + 1 } else { 1 }).sum()
|
||||
}
|
||||
|
||||
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26): its outer shadow, glow and soft edges.
|
||||
/// Other effects are dropped.
|
||||
/// A preset text warp (`a:prstTxWarp`, ECMA-376 §20.1.9.19) and its adjust values
|
||||
/// (`a:avLst/a:gd` with `fmla="val N"`). Unknown presets are dropped.
|
||||
fn text_warp(e: &El) -> Option<TextWarp> {
|
||||
let mut w = TextWarp::new(e.attr("prst")?)?;
|
||||
for gd in e.child("a:avLst").into_iter().flat_map(|l| l.children("a:gd")) {
|
||||
let (Some(name), Some(f)) = (gd.attr("name"), gd.attr("fmla")) else { continue };
|
||||
if let Some(v) = f.trim().strip_prefix("val").and_then(|v| v.trim().parse::<i64>().ok()) {
|
||||
w.set_adj(name, v);
|
||||
}
|
||||
}
|
||||
Some(w)
|
||||
}
|
||||
|
||||
/// A preset geometry's adjust value `name` (`a:avLst/a:gd` with `fmla="val N"`).
|
||||
fn preset_adj(geom: &El, name: &str) -> Option<i64> {
|
||||
let gd = geom.child("a:avLst")?.children("a:gd").find(|g| g.attr("name") == Some(name))?;
|
||||
gd.attr("fmla")?.trim().strip_prefix("val")?.trim().parse::<i64>().ok()
|
||||
}
|
||||
|
||||
fn effect_list(l: &El) -> ShapeEffects {
|
||||
let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0);
|
||||
let shadow = l.child("a:outerShdw").map(|e| {
|
||||
let (color, transparency) = effect_color(e);
|
||||
// `dir`: 60000ths of a degree, clockwise.
|
||||
let angle = e.attr("dir").and_then(int).map(|v| (v.rem_euclid(21_600_000) as f32) / 60_000.0).unwrap_or(0.0);
|
||||
// `rotWithShape` is true unless it says otherwise (ECMA-376 §20.1.8.49).
|
||||
let rot_with_shape = e.attr("rotWithShape").is_none_or(|v| !matches!(v, "0" | "false" | "off"));
|
||||
Shadow { color: color.unwrap_or(Rgb::BLACK), transparency, blur: pt(e, "blurRad"), distance: pt(e, "dist"), angle, rot_with_shape }
|
||||
});
|
||||
let glow = l.child("a:glow").map(|e| {
|
||||
let (color, transparency) = effect_color(e);
|
||||
Glow { color: color.unwrap_or(Glow::default().color), size: pt(e, "rad"), transparency }
|
||||
});
|
||||
let soft_edge = l.child("a:softEdge").map(|e| pt(e, "rad"));
|
||||
ShapeEffects { shadow, glow, soft_edge }.sanitized()
|
||||
}
|
||||
|
||||
/// The colour inside an effect (`a:srgbClr`, `a:prstClr`, `a:sysClr`; theme colours aren't
|
||||
/// resolved here) and its transparency, percent (from `a:alpha`, opaque when absent).
|
||||
fn effect_color(e: &El) -> (Option<Rgb>, f32) {
|
||||
let Some(c) = e.els().find(|c| matches!(c.name.as_str(), "a:srgbClr" | "a:prstClr" | "a:sysClr" | "a:schemeClr" | "a:scrgbClr" | "a:hslClr"))
|
||||
else {
|
||||
return (None, 0.0);
|
||||
};
|
||||
let rgb = match c.name.as_str() {
|
||||
"a:srgbClr" => c.attr("val").and_then(Rgb::parse),
|
||||
"a:sysClr" => c.attr("lastClr").and_then(Rgb::parse),
|
||||
"a:prstClr" => match c.attr("val").unwrap_or("") {
|
||||
"black" => Some(Rgb::BLACK),
|
||||
"white" => Some(Rgb::WHITE),
|
||||
"gray" | "grey" => Some(Rgb(0x80, 0x80, 0x80)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
// `a:alpha`: opacity in 1000ths of a percent.
|
||||
let opacity = c.child("a:alpha").and_then(|a| a.attr("val")).and_then(int).map(|v| v.clamp(0, 100_000) as f32 / 1000.0).unwrap_or(100.0);
|
||||
(rgb, 100.0 - opacity)
|
||||
}
|
||||
|
||||
/// A DrawingML `a:xfrm` (ECMA-376 §20.1.7.5/6) in points: offset, extent and, for a group, its
|
||||
/// members' offset and extent.
|
||||
struct Xfrm {
|
||||
off: (f32, f32),
|
||||
ext: (f32, f32),
|
||||
ch_off: Option<(f32, f32)>,
|
||||
ch_ext: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
/// The rotation and flips of the `a:xfrm` in shape properties `sppr` (ECMA-376 §20.1.7.6:
|
||||
/// `rot` in 60000ths of a degree, clockwise; `flipH`, `flipV`). None when missing or junk.
|
||||
fn xfrm_spin(sppr: Option<&El>) -> wordcraft_geom::Spin {
|
||||
let Some(x) = sppr.and_then(|s| if matches!(s.name.as_str(), "a:xfrm" | "w14:xfrm") { Some(s) } else { s.child("a:xfrm") }) else {
|
||||
return wordcraft_geom::Spin::default();
|
||||
};
|
||||
let rot = x.attr("rot").and_then(int).map(|v| v.rem_euclid(21_600_000) as f32 / 60_000.0).unwrap_or(0.0);
|
||||
wordcraft_geom::Spin::new(rot, on_off_attr(x, "flipH"), on_off_attr(x, "flipV"))
|
||||
}
|
||||
|
||||
/// The `a:xfrm` in shape properties `sppr` (zeros when missing).
|
||||
fn group_xfrm(sppr: Option<&El>) -> Xfrm {
|
||||
// A content part (native ink) has its own `w14:xfrm`.
|
||||
let x = sppr.and_then(|s| s.child("a:xfrm").or_else(|| s.child("w14:xfrm")));
|
||||
let pair = |name: &str, a: &str, b: &str, lim: f32| {
|
||||
let e = x.and_then(|x| x.child(name))?;
|
||||
let v = |n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).map(|v| v.clamp(-lim, lim)).unwrap_or(0.0);
|
||||
Some((v(a), v(b)))
|
||||
};
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let pos = |p: Option<(f32, f32)>| p.map(|(a, b)| (a.max(0.0), b.max(0.0)));
|
||||
Xfrm {
|
||||
off: pair("a:off", "x", "y", max).unwrap_or((0.0, 0.0)),
|
||||
ext: pos(pair("a:ext", "cx", "cy", max)).unwrap_or((0.0, 0.0)),
|
||||
ch_off: pair("a:chOff", "x", "y", max),
|
||||
ch_ext: pos(pair("a:chExt", "cx", "cy", max)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use super::*;
|
||||
|
||||
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
|
||||
/// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text
|
||||
/// (smaller than the symbol).
|
||||
fn enclosure(f: &FieldState) -> Option<wordcraft_doc::eq::Enclosure> {
|
||||
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
|
||||
let mut e = Enclosure::parse(&f.instr)?;
|
||||
let size_at = |at: usize| f.sizes.iter().rev().find(|(start, _)| *start <= at).and_then(|(_, s)| *s);
|
||||
let sym = f.instr.char_indices().find(|(_, c)| EncloseShape::from_symbol(&c.to_string()) == Some(e.shape)).map(|(i, _)| i)?;
|
||||
let txt = e.text.chars().next().and_then(|c| f.instr.get(sym..)?.find(c).map(|i| i + sym)).unwrap_or(sym);
|
||||
let base = f.props.size;
|
||||
e.style = match (size_at(sym), size_at(txt)) {
|
||||
(Some(s), Some(t)) if s > t + 0.01 && base.is_none_or(|b| t >= b - 0.01) => EncloseStyle::Enlarge,
|
||||
(Some(s), None) if base.is_none_or(|b| s > b + 0.01) => EncloseStyle::Enlarge,
|
||||
_ => EncloseStyle::Shrink,
|
||||
};
|
||||
Some(e)
|
||||
}
|
||||
|
||||
/// Fields kept as ranges: those whose result another program writes, with formatting and often
|
||||
/// several paragraphs (TOCs and citation managers' `ADDIN` fields).
|
||||
fn is_range_instr(instr: &str) -> bool {
|
||||
crate::read::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
|
||||
}
|
||||
|
||||
/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or
|
||||
/// `#bookmark`. None for any other field, or one with no target.
|
||||
fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
|
||||
if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let args = field_args(instr);
|
||||
let mut args = args.iter();
|
||||
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let (mut url, mut anchor) = (None, None);
|
||||
while let Some((a, quoted)) = args.next() {
|
||||
if *quoted || !a.starts_with('\\') {
|
||||
url = url.or(Some(a.as_str()));
|
||||
continue;
|
||||
}
|
||||
// `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none.
|
||||
let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten();
|
||||
if a.eq_ignore_ascii_case("\\l") {
|
||||
anchor = value.map(|(v, _)| v.as_str());
|
||||
}
|
||||
}
|
||||
let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty()));
|
||||
match (url, anchor) {
|
||||
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
|
||||
(Some(u), None) => Some(u.to_string()),
|
||||
(None, Some(a)) => Some(format!("#{a}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field code's words: the field's name, its switches and its arguments, each with whether
|
||||
/// it was quoted. In quotes, `\"` is a quote and `\\` a backslash.
|
||||
fn field_args(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
let mut word = String::new();
|
||||
if c == '"' {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => word.extend(chars.next()),
|
||||
_ => word.push(c),
|
||||
}
|
||||
}
|
||||
out.push((word, true));
|
||||
} else {
|
||||
word.push(c);
|
||||
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
|
||||
word.push(c);
|
||||
}
|
||||
out.push((word, false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
// ---- fields ----
|
||||
|
||||
pub(super) fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool, dirty: bool, form: String) {
|
||||
if sc.fields.len() >= MAX_FIELD_DEPTH || sc.ignored_begins > 0 {
|
||||
sc.ignored_begins += 1;
|
||||
return;
|
||||
}
|
||||
// Fields nested deeper than the cap are ignored (their content flows to the enclosing field).
|
||||
sc.fields.push(FieldState {
|
||||
instr,
|
||||
phase: Phase::Instr,
|
||||
spilled: false,
|
||||
buf: Vec::new(),
|
||||
props,
|
||||
locked,
|
||||
dirty,
|
||||
range: false,
|
||||
sizes: Vec::new(),
|
||||
form,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the field below the innermost one is still collecting its code (a field nested
|
||||
/// in another's code contributes text to that code, never content).
|
||||
fn parent_in_instr(sc: &StoryCtx) -> bool {
|
||||
let n = sc.fields.len();
|
||||
n >= 2 && sc.fields.get(n - 2).is_some_and(|p| !p.spilled && !p.range && p.phase == Phase::Instr)
|
||||
}
|
||||
|
||||
/// `separate`: the code is complete; a range field starts its result here.
|
||||
pub(super) fn separate_field(&mut self, sc: &mut StoryCtx, pb: &mut PB) {
|
||||
if sc.ignored_begins > 0 {
|
||||
return;
|
||||
}
|
||||
let nested_in_instr = Self::parent_in_instr(sc);
|
||||
let Some(f) = sc.fields.last_mut() else { return };
|
||||
if f.range {
|
||||
return;
|
||||
}
|
||||
f.phase = Phase::Result;
|
||||
if !f.spilled && !nested_in_instr && is_range_instr(&f.instr) {
|
||||
f.range = true;
|
||||
let (obj, props) = (InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, f.props.clone());
|
||||
self.emit_obj(sc, pb, obj, &props);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn end_field(&mut self, sc: &mut StoryCtx, pb: &mut PB) {
|
||||
if sc.ignored_begins > 0 {
|
||||
sc.ignored_begins -= 1;
|
||||
return;
|
||||
}
|
||||
let nested_in_instr = Self::parent_in_instr(sc);
|
||||
let Some(f) = sc.fields.pop() else { return };
|
||||
if f.range {
|
||||
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
|
||||
return;
|
||||
}
|
||||
if f.spilled {
|
||||
return;
|
||||
}
|
||||
if f.phase == Phase::Instr && !nested_in_instr && is_range_instr(&f.instr) {
|
||||
// A range field with no result yet.
|
||||
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, &f.props);
|
||||
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
|
||||
return;
|
||||
}
|
||||
// A `HYPERLINK` field is a link, as a `w:hyperlink` is: its result stays ordinary content
|
||||
// carrying the link, so a tab in it reaches its tab stop and the `PAGEREF` in a table of
|
||||
// contents entry (Word for the web writes them this way) stays a field of its own (#2174).
|
||||
if !nested_in_instr && let Some(target) = hyperlink_target(&f.instr) {
|
||||
for (it, mut p) in f.buf {
|
||||
p.link.get_or_insert_with(|| target.clone());
|
||||
match it {
|
||||
Item::Text(t) => self.emit_text(sc, pb, &t, &p),
|
||||
Item::Obj(o) => self.emit_obj(sc, pb, *o, &p),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded
|
||||
// picture) can't be a text result: keep the field as a range around it so the picture
|
||||
// stays in the document and is saved again. The linked source is never read.
|
||||
// A tracked insertion or deletion inside the result (one the field itself isn't wrapped
|
||||
// in) keeps the field a range too: a cached text result has one revision at most, so
|
||||
// each piece stays an ordinary run with its own `w:ins` / `w:del` (#1037).
|
||||
let tracked_inside = f.buf.iter().any(|(_, p)| (p.ins, p.del) != (f.props.ins, f.props.del));
|
||||
if !nested_in_instr && (tracked_inside || f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some()))) {
|
||||
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, &f.props);
|
||||
for (it, p) in f.buf {
|
||||
match it {
|
||||
Item::Text(t) => self.emit_text(sc, pb, &t, &p),
|
||||
Item::Obj(o) => self.emit_obj(sc, pb, *o, &p),
|
||||
}
|
||||
}
|
||||
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
|
||||
return;
|
||||
}
|
||||
let mut result = String::new();
|
||||
for (it, _) in &f.buf {
|
||||
match it {
|
||||
Item::Text(t) => result.push_str(t),
|
||||
Item::Obj(o) => result.push_str(o.plain_text()),
|
||||
}
|
||||
}
|
||||
let (instr, result) = match enclosure(&f) {
|
||||
// Word writes no result for enclosed characters: the text is their plain text.
|
||||
Some(e) => (e.instr(), if result.is_empty() { e.text } else { result }),
|
||||
None => (f.instr.trim().to_string(), result),
|
||||
};
|
||||
// The visible result owns the field's formatting; empty results keep the begin's.
|
||||
let props = f
|
||||
.buf
|
||||
.iter()
|
||||
.find_map(|(it, p)| match it {
|
||||
Item::Text(t) if !t.is_empty() => Some(p),
|
||||
Item::Obj(o) if !o.plain_text().is_empty() => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(&f.props)
|
||||
.clone();
|
||||
let obj = InlineObject::Field { instr, result, locked: f.locked, dirty: f.dirty, code: false, form: f.form };
|
||||
if let Some(parent) = sc.fields.last_mut()
|
||||
&& !parent.spilled
|
||||
&& parent.phase == Phase::Instr
|
||||
{
|
||||
parent.instr.push_str(obj.plain_text());
|
||||
return;
|
||||
}
|
||||
self.emit_obj(sc, pb, obj, &props);
|
||||
}
|
||||
|
||||
/// At a paragraph end, open fields can't keep buffering: emit them (empty result) and
|
||||
/// let the rest of their result flow as ordinary text.
|
||||
pub(super) fn spill_fields(&mut self, sc: &mut StoryCtx, pb: &mut PB) {
|
||||
for f in sc.fields.iter_mut() {
|
||||
if f.spilled || f.range {
|
||||
continue;
|
||||
}
|
||||
f.spilled = true;
|
||||
let form = std::mem::take(&mut f.form);
|
||||
pb.push_obj(
|
||||
InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, dirty: f.dirty, code: false, form },
|
||||
&f.props,
|
||||
);
|
||||
for (it, p) in f.buf.drain(..) {
|
||||
match it {
|
||||
Item::Text(t) => pb.push_text(&t, &p),
|
||||
Item::Obj(o) => pb.push_obj(*o, &p),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
use super::*;
|
||||
|
||||
impl Reader<'_> {
|
||||
pub(super) fn read_inline_children(&mut self, sc: &mut StoryCtx, pb: &mut PB, e: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for k in e.els() {
|
||||
self.read_inline(sc, pb, k, rels, ctx, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_inline(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
|
||||
match k.name.as_str() {
|
||||
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
|
||||
"w:hyperlink" => {
|
||||
let mut c = ctx.clone();
|
||||
let url = k.attr("r:id").and_then(|id| rels.by_id(id)).map(|r| r.target.trim().to_string()).filter(|u| !u.is_empty());
|
||||
let anchor = k.attr("w:anchor").filter(|a| !a.is_empty());
|
||||
c.link = match (url, anchor) {
|
||||
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
|
||||
(Some(u), None) => Some(u),
|
||||
(None, Some(a)) => Some(format!("#{a}")),
|
||||
(None, None) => ctx.link.clone(),
|
||||
};
|
||||
self.read_inline_children(sc, pb, k, rels, &c, depth + 1);
|
||||
}
|
||||
"w:ins" | "w:moveTo" => {
|
||||
let mut c = ctx.clone();
|
||||
c.ins = Some(if k.name == "w:moveTo" { self.move_revision(true, k, false) } else { self.revision(RevisionKind::Insert, k) });
|
||||
self.read_inline_children(sc, pb, k, rels, &c, depth + 1);
|
||||
}
|
||||
"w:del" | "w:moveFrom" => {
|
||||
let mut c = ctx.clone();
|
||||
c.del = Some(if k.name == "w:moveFrom" { self.move_revision(false, k, false) } else { self.revision(RevisionKind::Delete, k) });
|
||||
self.read_inline_children(sc, pb, k, rels, &c, depth + 1);
|
||||
}
|
||||
"w:moveFromRangeStart" | "w:moveFromRangeEnd" | "w:moveToRangeStart" | "w:moveToRangeEnd" => self.move_range(k),
|
||||
"w:fldSimple" => {
|
||||
let instr = k.attr("w:instr").unwrap_or("").to_string();
|
||||
let props = k.child("w:r").and_then(|r| r.child("w:rPr")).map(|p| self.run_props(p, ctx)).unwrap_or_else(|| self.run_props_none(ctx));
|
||||
self.begin_field(sc, props, instr, on_off_attr(k, "w:fldLock"), on_off_attr(k, "w:dirty"), String::new());
|
||||
self.separate_field(sc, pb);
|
||||
self.read_inline_children(sc, pb, k, rels, ctx, depth + 1);
|
||||
self.end_field(sc, pb);
|
||||
}
|
||||
"w:bookmarkStart" | "w:bookmarkEnd" | "w:commentRangeStart" | "w:commentRangeEnd" | "w:permStart" | "w:permEnd" => {
|
||||
if let Some(o) = self.marker(k) {
|
||||
let props = self.run_props_none(ctx);
|
||||
// Markers go straight into the paragraph (never into a field's cached result).
|
||||
pb.push_obj(o, &props);
|
||||
}
|
||||
}
|
||||
"w:sdt" => {
|
||||
let Some(c) = k.child("w:sdtContent") else { return };
|
||||
let mut ctl = crate::read::sdt::sdt_pr(k.child("w:sdtPr"), false);
|
||||
ctl.end_pr_xml = crate::read::sdt::sdt_end_pr(k.child("w:sdtEndPr"));
|
||||
ctl.run_props = k.child("w:sdtPr").and_then(|pr| pr.child("w:rPr")).map(|r| self.pc.rpr(r));
|
||||
// The markers take the formatting of the content's first run (typing at the
|
||||
// start of the content continues it).
|
||||
let props = c.find("w:rPr").map(|p| self.run_props(p, ctx)).unwrap_or_else(|| self.run_props_none(ctx));
|
||||
let (at, k0) = (pb.text.len(), pb.objects.len());
|
||||
pb.push_obj(InlineObject::ControlStart { control: Box::new(ctl) }, &props);
|
||||
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
|
||||
if let Some(InlineObject::ControlStart { control }) = pb.objects.get_mut(k0)
|
||||
&& control.showing_placeholder
|
||||
{
|
||||
let shown: String = pb.text.get(at..).unwrap_or("").chars().filter(|c| *c != wordcraft_doc::para::OBJ).collect();
|
||||
control.placeholder_text = placeholder_of(&shown);
|
||||
}
|
||||
let end_props = pb.runs.last().map(|r| r.props.clone()).unwrap_or(props);
|
||||
pb.push_obj(InlineObject::ControlEnd, &end_props);
|
||||
}
|
||||
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
|
||||
"m:oMath" => {
|
||||
let mut props = self.run_props_none(ctx);
|
||||
let mut math = crate::read::math::read_omath(k, Default::default());
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
|
||||
}
|
||||
"m:oMathPara" => {
|
||||
let props = self.run_props_none(ctx);
|
||||
let jc = crate::read::math::para_jc(k);
|
||||
for m in k.children("m:oMath") {
|
||||
let mut math = crate::read::math::read_omath(m, jc);
|
||||
let mut props = props.clone();
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_obj(sc, pb, o, &props);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
|
||||
}
|
||||
}
|
||||
// Some generators write a break directly under the paragraph instead of inside a run.
|
||||
"w:br" | "w:cr" => {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.read_run_child(sc, pb, k, &props, rels, &mut None, depth + 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_props(&mut self, rpr: &El, ctx: &RunCtx) -> CharProps {
|
||||
let mut p = self.pc.rpr(rpr);
|
||||
p.link = ctx.link.clone();
|
||||
p.ins = ctx.ins;
|
||||
p.del = ctx.del;
|
||||
p.fmt_change = self.rpr_change(rpr);
|
||||
p
|
||||
}
|
||||
|
||||
fn run_props_none(&self, ctx: &RunCtx) -> CharProps {
|
||||
CharProps { link: ctx.link.clone(), ins: ctx.ins, del: ctx.del, ..Default::default() }
|
||||
}
|
||||
|
||||
fn read_run(&mut self, sc: &mut StoryCtx, pb: &mut PB, r: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
|
||||
let props = match r.child("w:rPr") {
|
||||
Some(p) => self.run_props(p, ctx),
|
||||
None => self.run_props_none(ctx),
|
||||
};
|
||||
let mut note: Option<(NoteKind, u32, Option<String>)> = None;
|
||||
for k in r.els() {
|
||||
self.read_run_child(sc, pb, k, &props, rels, &mut note, depth + 1);
|
||||
}
|
||||
if let Some((kind, id, custom)) = note.take() {
|
||||
self.emit_obj(sc, pb, InlineObject::NoteRef { kind, id, custom: custom.unwrap_or_default() }, &props);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn read_run_child(
|
||||
&mut self,
|
||||
sc: &mut StoryCtx,
|
||||
pb: &mut PB,
|
||||
k: &El,
|
||||
props: &CharProps,
|
||||
rels: &Rels,
|
||||
note: &mut Option<(NoteKind, u32, Option<String>)>,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
// A custom footnote mark: the text right after the reference is the mark.
|
||||
if let Some((_, _, Some(custom))) = note.as_mut()
|
||||
&& k.name == "w:t"
|
||||
{
|
||||
custom.push_str(&clean_text(&k.text()));
|
||||
return;
|
||||
}
|
||||
if k.name != "w:rPr"
|
||||
&& let Some((kind, id, custom)) = note.take()
|
||||
{
|
||||
self.emit_obj(sc, pb, InlineObject::NoteRef { kind, id, custom: custom.unwrap_or_default() }, props);
|
||||
}
|
||||
match k.name.as_str() {
|
||||
"w:t" | "w:delText" => {
|
||||
let t = clean_text(&k.text());
|
||||
self.emit_text(sc, pb, &t, props);
|
||||
}
|
||||
"w:instrText" | "w:delInstrText" => {
|
||||
if let Some(f) = sc.fields.last_mut()
|
||||
&& f.phase == Phase::Instr
|
||||
&& !f.spilled
|
||||
&& f.instr.len() < MAX_INSTR
|
||||
{
|
||||
if f.sizes.len() < 64 {
|
||||
f.sizes.push((f.instr.len(), props.size));
|
||||
}
|
||||
f.instr.push_str(&k.text());
|
||||
}
|
||||
}
|
||||
"w:tab" => self.emit_text(sc, pb, "\t", props),
|
||||
// Alignment and base are required (ECMA-376 §17.3.3.23); without them it stays a plain tab.
|
||||
"w:ptab" => match (k.attr("w:alignment").and_then(PTabAlign::from_ooxml), k.attr("w:relativeTo").and_then(PTabBase::from_ooxml)) {
|
||||
(Some(align), Some(relative_to)) => {
|
||||
let leader = crate::read::props::tab_leader(k.attr("w:leader").unwrap_or("none"));
|
||||
self.emit_obj(sc, pb, InlineObject::PositionalTab { align, relative_to, leader }, props)
|
||||
}
|
||||
_ => self.emit_text(sc, pb, "\t", props),
|
||||
},
|
||||
"w:br" => {
|
||||
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
|
||||
return;
|
||||
}
|
||||
let ty = k.attr("w:type").map(|t| t.trim().to_ascii_lowercase());
|
||||
let c = match ty.as_deref() {
|
||||
Some("page") => "\u{000C}",
|
||||
Some("column") => "\u{000E}",
|
||||
_ => "\n",
|
||||
};
|
||||
self.emit_text(sc, pb, c, props);
|
||||
}
|
||||
"w:cr" if !sc.fields.iter().any(|f| f.phase == Phase::Instr) => {
|
||||
self.emit_text(sc, pb, "\n", props);
|
||||
}
|
||||
"w:cr" => {}
|
||||
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
|
||||
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
|
||||
"w:sym" => {
|
||||
let h_str = k.attr("w:char").map(|h| {
|
||||
let s = h.trim();
|
||||
let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
|
||||
let s = s.strip_prefix("U+").or_else(|| s.strip_prefix("u+")).unwrap_or(s);
|
||||
s.strip_prefix("#x").or_else(|| s.strip_prefix("#X")).unwrap_or(s)
|
||||
});
|
||||
let ch = h_str.and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
|
||||
if let Some(ch) = ch {
|
||||
let mut p = props.clone();
|
||||
// The symbol's own font, kept apart from the run's (ECMA-376 §17.3.3.30).
|
||||
if let Some(f) = k.attr("w:font").filter(|f| !f.trim().is_empty()) {
|
||||
p.sym_font = Some(f.to_string());
|
||||
}
|
||||
self.emit_text(sc, pb, &ch.to_string(), &p);
|
||||
}
|
||||
}
|
||||
"w:fldChar" => match k.attr("w:fldCharType") {
|
||||
Some("begin") => {
|
||||
let form = k.child("w:ffData").map(|f| f.to_xml()).filter(|x| x.len() <= MAX_FORM).unwrap_or_default();
|
||||
self.begin_field(sc, props.clone(), String::new(), on_off_attr(k, "w:fldLock"), on_off_attr(k, "w:dirty"), form)
|
||||
}
|
||||
Some("separate") => self.separate_field(sc, pb),
|
||||
Some("end") => self.end_field(sc, pb),
|
||||
_ => {}
|
||||
},
|
||||
"w:drawing" => {
|
||||
if let Some(o) = self.read_drawing(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
"w:pict" => {
|
||||
if let Some(o) = self.read_signature_line(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
} else {
|
||||
for o in self.read_vml_all(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
}
|
||||
"w:object" => {
|
||||
if let Some(o) = self.read_object(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
"w:contentPart" => {
|
||||
if let Some(o) = self.read_ink_content_part(k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
"w:footnoteReference" | "w:endnoteReference" => {
|
||||
let foot = k.local() == "footnoteReference";
|
||||
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
|
||||
let map = if foot { &self.footnotes } else { &self.endnotes };
|
||||
if let Some(id) = map.get(&fid).copied() {
|
||||
let kind = if foot { NoteKind::Footnote } else { NoteKind::Endnote };
|
||||
let custom = on_off_attr(k, "w:customMarkFollows").then(String::new);
|
||||
*note = Some((kind, id, custom));
|
||||
}
|
||||
}
|
||||
// The note's own number at the start of its text: a reference to the note being read.
|
||||
"w:footnoteRef" | "w:endnoteRef" => {
|
||||
let kind = if k.local() == "footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
|
||||
if let Some((nk, id)) = self.current_note
|
||||
&& nk == kind
|
||||
{
|
||||
*note = Some((kind, id, None));
|
||||
}
|
||||
}
|
||||
// The lines of a note separator (only there).
|
||||
"w:separator" | "w:continuationSeparator" if self.in_note_sep => {
|
||||
self.emit_obj(sc, pb, InlineObject::note_separator(k.name == "w:continuationSeparator"), props);
|
||||
}
|
||||
"w:commentReference" => {
|
||||
if let Some(cid) = k.attr("w:id").and_then(|i| self.comment_id(i))
|
||||
&& !self.comments_ended.contains(&cid)
|
||||
{
|
||||
self.comments_ended.insert(cid);
|
||||
pb.push_obj(InlineObject::CommentEnd { id: cid }, props);
|
||||
}
|
||||
}
|
||||
"w:pgNum" => self.emit_obj(
|
||||
sc,
|
||||
pb,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
props,
|
||||
),
|
||||
"w:ruby" => self.read_ruby(sc, pb, k, props),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
for x in c.els() {
|
||||
self.read_run_child(sc, pb, x, props, rels, note, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:ruby` (ECMA-376 §17.3.3.25): ruby text (`w:rt`) over base text (`w:rubyBase`), kept as
|
||||
/// one [`InlineObject::Ruby`] in the base text's formatting (the first base run's).
|
||||
fn read_ruby(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, props: &CharProps) {
|
||||
const MAX: usize = 4096;
|
||||
// The text of a part's runs and the formatting of its first run.
|
||||
let part = |name: &str| -> (String, Option<CharProps>) {
|
||||
let mut text = String::new();
|
||||
let mut first = None;
|
||||
for r in k.child(name).map(|e| e.children("w:r").collect::<Vec<_>>()).unwrap_or_default() {
|
||||
if first.is_none() {
|
||||
first = Some(r.child("w:rPr").map(|p| self.pc.rpr(p)).unwrap_or_default());
|
||||
}
|
||||
for t in r.els().filter(|t| matches!(t.name.as_str(), "w:t" | "w:delText")) {
|
||||
if text.len() < MAX {
|
||||
text.push_str(&clean_text(&t.text()).replace('\t', " "));
|
||||
}
|
||||
}
|
||||
}
|
||||
(text.chars().take(MAX).collect(), first)
|
||||
};
|
||||
let (base, base_props) = part("w:rubyBase");
|
||||
let (ruby, ruby_props) = part("w:rt");
|
||||
let mut bp = base_props.unwrap_or_default();
|
||||
(bp.link, bp.ins, bp.del) = (props.link.clone(), props.ins, props.del);
|
||||
if base.is_empty() {
|
||||
return;
|
||||
}
|
||||
if ruby.is_empty() {
|
||||
self.emit_text(sc, pb, &base, &bp);
|
||||
return;
|
||||
}
|
||||
let pr = k.child("w:rubyPr");
|
||||
let val = |n: &str| pr.and_then(|p| p.child(n)).and_then(|e| e.attr("w:val"));
|
||||
let pt = |n: &str| val(n).and_then(|v| measure(v, 2.0)).filter(|v| v.is_finite() && *v >= 0.0);
|
||||
let mut rp = ruby_props.unwrap_or_default();
|
||||
let base_size = pt("w:hpsBaseText").filter(|v| *v > 0.0).or(bp.size).unwrap_or(0.0).min(1584.0);
|
||||
let size = pt("w:hps").filter(|v| *v > 0.0).or(rp.size).unwrap_or(if base_size > 0.0 { base_size / 2.0 } else { 5.0 }).clamp(1.0, 1584.0);
|
||||
let raise = pt("w:hpsRaise").unwrap_or_else(|| wordcraft_doc::para::ruby_raise(0.0, base_size.max(size), size)).min(1584.0);
|
||||
// The ruby's size is `size`; its run keeps what it sets beyond the base text's formatting
|
||||
// (writers repeat the base's).
|
||||
(rp.size, rp.size_cs) = (None, None);
|
||||
let rp = rp.minus(&bp);
|
||||
let obj = InlineObject::Ruby {
|
||||
base,
|
||||
ruby,
|
||||
align: val("w:rubyAlign").and_then(wordcraft_doc::para::RubyAlign::from_ooxml).unwrap_or_default(),
|
||||
size,
|
||||
raise,
|
||||
base_size,
|
||||
lang: val("w:lid").map(|l| l.chars().take(32).collect()).unwrap_or_default(),
|
||||
props: Box::new(rp),
|
||||
};
|
||||
self.emit_obj(sc, pb, obj, &bp);
|
||||
}
|
||||
|
||||
pub(super) fn emit_text(&mut self, sc: &mut StoryCtx, pb: &mut PB, s: &str, props: &CharProps) {
|
||||
match sink(sc) {
|
||||
Some(f) => {
|
||||
if f.phase == Phase::Result && f.buf.len() < 100_000 {
|
||||
f.buf.push((Item::Text(s.to_string()), props.clone()));
|
||||
}
|
||||
}
|
||||
_ => pb.push_text(s, props),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
|
||||
match sink(sc) {
|
||||
Some(f) => {
|
||||
if f.phase == Phase::Result && f.buf.len() < 100_000 {
|
||||
f.buf.push((Item::Obj(Box::new(o)), props.clone()));
|
||||
}
|
||||
}
|
||||
_ => pb.push_obj(o, props),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
use super::*;
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Every legacy VML drawing in one `w:pict`, in document order: a `w:pict` may hold any
|
||||
/// number of shapes (ECMA-376 Part 4, VML), and each one is its own object. A `v:group`
|
||||
/// becomes an [`InlineObject::Group`].
|
||||
pub(super) fn read_vml_all(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Vec<InlineObject> {
|
||||
let mut out = Vec::new();
|
||||
for e in pict.els().filter(|e| e.name.starts_with("v:") && e.local() != "shapetype") {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
break;
|
||||
}
|
||||
if sc.in_hf
|
||||
&& let Some(wm) = vml_watermark(e)
|
||||
{
|
||||
// The document's watermark (the first one found); drawn on every page, not as
|
||||
// a shape, so it is not one of the objects this picture contributes.
|
||||
self.doc.settings.watermark.get_or_insert(wm);
|
||||
continue;
|
||||
}
|
||||
let o = if e.local() == "group" { self.read_vml_group(sc, pict, e, rels) } else { self.read_vml_shape(sc, pict, e, rels, None) };
|
||||
out.extend(o);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first VML drawing in `pict`, for the places that show a single one: an OLE object's
|
||||
/// picture and a `mc:Fallback` preview.
|
||||
pub(super) fn read_vml(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
self.read_vml_all(sc, pict, rels).into_iter().next()
|
||||
}
|
||||
|
||||
/// A `v:group`: its own box from its `style`, its members in the space `coordsize` and
|
||||
/// `coordorigin` give it, nested groups flattened into it as [`Self::group_members`] does for
|
||||
/// DrawingML. `None` when it has no member we can show. `pict` is the picture the group sits
|
||||
/// in, where a member's `v:shapetype` is written.
|
||||
fn read_vml_group(&mut self, sc: &mut StoryCtx, pict: &El, grp: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let style = grp.attr("style").unwrap_or("");
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
|
||||
let (w, h) = (len("width"), len("height"));
|
||||
let float = vml_float(grp, style, sc.in_hf);
|
||||
// The members' space; without a usable `coordsize` it is the group's own box.
|
||||
let (ch_w, ch_h) = vml_coordpair(grp, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((w.max(1.0), h.max(1.0)));
|
||||
let (cx, cy) = vml_coordpair(grp, "coordorigin").unwrap_or((0.0, 0.0));
|
||||
let mut children = Vec::new();
|
||||
self.vml_group_members(sc, pict, grp, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None })
|
||||
}
|
||||
|
||||
/// The members of VML group `g` into `out`, mapped into the outermost group's space by `t`
|
||||
/// (x0, y0, sx, sy: outer = x0 + inner × sx). A member is placed by `left`/`top` in that
|
||||
/// space, except a `v:line`, which carries its own offset in `from`.
|
||||
fn vml_group_members(
|
||||
&mut self,
|
||||
sc: &mut StoryCtx,
|
||||
pict: &El,
|
||||
g: &El,
|
||||
rels: &Rels,
|
||||
t: (f32, f32, f32, f32),
|
||||
depth: usize,
|
||||
out: &mut Vec<wordcraft_doc::para::GroupChild>,
|
||||
) {
|
||||
let (x0, y0, sx, sy) = t;
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
|
||||
for e in g.els() {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
return;
|
||||
}
|
||||
if !e.name.starts_with("v:") || e.local() == "shapetype" {
|
||||
continue;
|
||||
}
|
||||
let style = e.attr("style").unwrap_or("");
|
||||
let num = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).map_or(0.0, wordcraft_geom::finite);
|
||||
let ((ox, oy), (ew, eh)) = ((num("left"), num("top")), (num("width"), num("height")));
|
||||
if e.local() == "group" {
|
||||
if depth >= MAX_VML_GROUP_DEPTH {
|
||||
continue;
|
||||
}
|
||||
// Its members' space maps onto its own box.
|
||||
let (cw, ch) = vml_coordpair(e, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
|
||||
let (cox, coy) = vml_coordpair(e, "coordorigin").unwrap_or((0.0, 0.0));
|
||||
let (kx, ky) = (ew / cw, eh / ch);
|
||||
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
|
||||
self.vml_group_members(sc, pict, e, rels, nt, depth + 1, out);
|
||||
continue;
|
||||
}
|
||||
let Some(mut obj) = self.read_vml_shape(sc, pict, e, rels, Some((sx, sy))) else { continue };
|
||||
// A member is placed by `x`/`y`, so a line's own offset comes off the member itself.
|
||||
let (dx, dy) = match obj.float_mut() {
|
||||
Some(f) => {
|
||||
let d = (f.x, f.y);
|
||||
(f.x, f.y) = (0.0, 0.0);
|
||||
d
|
||||
}
|
||||
None => (0.0, 0.0),
|
||||
};
|
||||
out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + (ox + dx) * sx, -max), y: fit(y0 + (oy + dy) * sy, -max), obj });
|
||||
}
|
||||
}
|
||||
|
||||
/// One legacy VML picture, text box, line, rectangle, oval or preset shape (best effort).
|
||||
/// `pict` is the picture it sits in, where its `v:shapetype` is written -- for a group's
|
||||
/// member too, since Word writes each shapetype once, beside the group rather than inside
|
||||
/// it. `grp` scales a member from its group's space, and leaves placing it to the caller.
|
||||
fn read_vml_shape(&mut self, sc: &mut StoryCtx, pict: &El, shape: &El, rels: &Rels, grp: Option<(f32, f32)>) -> Option<InlineObject> {
|
||||
let style = shape.attr("style").unwrap_or("");
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
|
||||
let (sx, sy) = grp.unwrap_or((1.0, 1.0));
|
||||
let (mut w, mut h) = ((len("width") * sx).min(crate::units::MAX_LEN_PT), (len("height") * sy).min(crate::units::MAX_LEN_PT));
|
||||
// A member of a group never floats: the group does, and places it.
|
||||
let mut float = match grp {
|
||||
Some(_) => {
|
||||
let mut f = Float { hidden: vml_style(style, "visibility").is_some_and(|v| v.eq_ignore_ascii_case("hidden")), ..Float::default() };
|
||||
f.set_spin(vml_spin(style));
|
||||
f
|
||||
}
|
||||
None => vml_float(shape, style, sc.in_hf),
|
||||
};
|
||||
if let Some(img) = shape.find("v:imagedata") {
|
||||
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
|
||||
let media = self.media_for(rels, id)?;
|
||||
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
|
||||
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
|
||||
}
|
||||
// The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in
|
||||
// the same picture: what the shape leaves out, it takes from there.
|
||||
let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#'));
|
||||
let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id)));
|
||||
let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name)));
|
||||
let on = |name: &str| {
|
||||
attr(name).is_none_or(|v| {
|
||||
let t = v.trim();
|
||||
!t.eq_ignore_ascii_case("f") && !t.eq_ignore_ascii_case("false") && t != "0"
|
||||
})
|
||||
};
|
||||
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
|
||||
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
|
||||
let fill = vml_fill(shape, shapetype);
|
||||
let endcap = shape
|
||||
.child("v:stroke")
|
||||
.and_then(|s| s.attr("endcap"))
|
||||
.or_else(|| shapetype.and_then(|t| t.child("v:stroke")).and_then(|s| s.attr("endcap")));
|
||||
let cap = match endcap {
|
||||
Some("square") => wordcraft_doc::connector::LineCap::Square,
|
||||
Some("round") => wordcraft_doc::connector::LineCap::Round,
|
||||
_ => wordcraft_doc::connector::LineCap::Flat,
|
||||
};
|
||||
let kind = match shape.local() {
|
||||
"line" => Some(ShapeKind::Line),
|
||||
"rect" => Some(ShapeKind::Rectangle),
|
||||
"roundrect" => Some(ShapeKind::RoundedRectangle),
|
||||
"oval" => Some(ShapeKind::Ellipse),
|
||||
// A preset, unless the shape's own path replaces its shapetype's geometry.
|
||||
"shape" if shape.attr("path").is_none() && shape.child("v:path").and_then(|p| p.attr("v")).is_none() => {
|
||||
vml_spt(shape, shapetype, type_id).and_then(vml_preset_kind)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// Word 2007 WordArt: a `v:shape` whose `v:textpath` draws its `string` (the header watermark
|
||||
// is taken before this). Shown as a text box holding that text, in the textpath's font and the
|
||||
// shape's `fillcolor`; the box itself has neither fill nor outline, as the letters are the art.
|
||||
if shape.local() == "shape"
|
||||
&& let Some(tp) = shape.child("v:textpath")
|
||||
&& !tp.attr("on").is_some_and(|v| matches!(v, "f" | "false" | "0"))
|
||||
&& let Some(text) = tp.attr("string").map(|t| t.chars().take(200).collect::<String>()).filter(|t| !t.trim().is_empty())
|
||||
&& sc.story_depth < MAX_STORY_DEPTH
|
||||
{
|
||||
let font = tp
|
||||
.attr("style")
|
||||
.and_then(|s| vml_style(s, "font-family"))
|
||||
.map(|f| css_string(f).chars().take(100).collect::<String>())
|
||||
.filter(|f| !f.is_empty());
|
||||
// The shapetype's WordArt shape (`o:spt` 136-175) bends the text like DrawingML's warp.
|
||||
let warp = vml_spt(shape, shapetype, type_id)
|
||||
.and_then(|spt| spt.checked_sub(136))
|
||||
.and_then(|i| VML_TEXT_WARPS.get(i as usize))
|
||||
.copied()
|
||||
.and_then(TextWarp::new);
|
||||
// The letters fill the box (`fitshape`): sized to its height.
|
||||
let props = CharProps {
|
||||
font,
|
||||
size: Some((h * 0.75).clamp(8.0, 200.0)),
|
||||
color: attr("fillcolor").and_then(vml_color).map(TextColor::Rgb),
|
||||
..CharProps::default()
|
||||
};
|
||||
let id = self.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, props))]);
|
||||
if let Some(part) = self.doc.parts.get_mut(&id) {
|
||||
part.body.anchor = wordcraft_doc::props::VAlign::Center;
|
||||
part.body.inset = Some([0; 4]);
|
||||
}
|
||||
return Some(InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width,
|
||||
float,
|
||||
story: Some(id),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: ShapeExtra { warp, ..ShapeExtra::default() },
|
||||
});
|
||||
}
|
||||
if let Some((tb, t)) = vml_textbox(shape, 0)
|
||||
&& sc.story_depth < MAX_STORY_DEPTH
|
||||
{
|
||||
let id = self.read_textbox(sc, t, rels);
|
||||
if let Some(part) = self.doc.parts.get_mut(&id) {
|
||||
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
|
||||
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
|
||||
// `o:insetmode="auto"` leaves them to the application.
|
||||
let mut inset = wordcraft_doc::TEXT_BOX_INSET;
|
||||
let custom = tb.attr("o:insetmode").is_none_or(|m| !m.eq_ignore_ascii_case("auto"));
|
||||
for (slot, v) in inset.iter_mut().zip(tb.attr("inset").filter(|_| custom).unwrap_or("").split(',')) {
|
||||
if let Some(pt) = measure(v, 12_700.0) {
|
||||
*slot = (pt.max(0.0) * 12_700.0).round().min(wordcraft_doc::MAX_TEXT_BOX_INSET as f32) as u32;
|
||||
}
|
||||
}
|
||||
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
|
||||
// Resize shape to fit text (DrawingML's `a:spAutoFit`).
|
||||
let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text").or_else(|| vml_style(style, "mso-fit-shape-to-text"));
|
||||
part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true"));
|
||||
// Where the text sits between the box's top and bottom (DrawingML's `anchor`).
|
||||
if let Some(a) = vml_style(tb.attr("style").unwrap_or(""), "v-text-anchor").or_else(|| vml_style(style, "v-text-anchor")) {
|
||||
let al = a.trim().to_ascii_lowercase();
|
||||
part.body.anchor = if al.starts_with("middle") || al == "center" {
|
||||
wordcraft_doc::props::VAlign::Center
|
||||
} else if al.starts_with("bottom") {
|
||||
wordcraft_doc::props::VAlign::Bottom
|
||||
} else {
|
||||
wordcraft_doc::props::VAlign::Top
|
||||
};
|
||||
}
|
||||
}
|
||||
let story = Some(id);
|
||||
// Text in a preset shape keeps the shape around it; otherwise it is a text box.
|
||||
let kind = kind.filter(|k| *k != ShapeKind::Rectangle && !k.is_open()).unwrap_or(ShapeKind::TextBox);
|
||||
return Some(InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width,
|
||||
float,
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
});
|
||||
}
|
||||
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
|
||||
// or `nf` in the path leaves it unfilled. Not WordArt (`v:textpath`).
|
||||
if shape.local() == "shape"
|
||||
&& shape.child("v:textpath").is_none()
|
||||
&& let Some(path) = shape.attr("path")
|
||||
&& let Some(vp) = crate::read::freeform::vml_path(path, shape.attr("coordsize"), shape.attr("coordorigin"), w, h)
|
||||
{
|
||||
let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_opacity).unwrap_or(1.0);
|
||||
let fill = fill.filter(|_| !vp.no_fill && opacity("v:fill") > 0.0);
|
||||
let stroke = stroke.filter(|_| !vp.no_stroke);
|
||||
if stroke.is_none() && fill.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut f = vp.freeform;
|
||||
f.alpha = opacity("v:stroke");
|
||||
return Some(InlineObject::Shape {
|
||||
kind: ShapeKind::Freeform,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width,
|
||||
float,
|
||||
story: None,
|
||||
freeform: Some(Arc::new(f)),
|
||||
effects: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
});
|
||||
}
|
||||
let kind = kind?;
|
||||
let mut extra = ShapeExtra { cap, ..Default::default() };
|
||||
if kind == ShapeKind::Line {
|
||||
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
|
||||
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
|
||||
extra.arrow_start = arrow("startarrow");
|
||||
extra.arrow_end = arrow("endarrow");
|
||||
}
|
||||
if shape.local() == "line" {
|
||||
// A line runs `from` → `to` (points, offsets from where its style places it). A preset
|
||||
// line (`v:shape`) runs corner to corner of the box its style gives it.
|
||||
let pt = |name: &str| -> (f32, f32) {
|
||||
let v = shape.attr(name).unwrap_or("0,0");
|
||||
let (x, y) = v.split_once(',').unwrap_or((v, "0"));
|
||||
let m = |s: &str| measure(s, 1.0).map_or(0.0, wordcraft_geom::finite).clamp(-crate::units::MAX_LEN_PT, crate::units::MAX_LEN_PT);
|
||||
(m(x), m(y))
|
||||
};
|
||||
let ((x0, y0), (x1, y1)) = (pt("from"), pt("to"));
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
(w, h) = (((x1 - x0).abs() * sx).min(max), ((y1 - y0).abs() * sy).min(max));
|
||||
float.x = (float.x + x0.min(x1)).clamp(-max, max);
|
||||
float.y = (float.y + y0.min(y1)).clamp(-max, max);
|
||||
// Drawn top-left to bottom-right; flipped for the other directions.
|
||||
float.flip_h ^= x1 < x0;
|
||||
float.flip_v ^= y1 < y0;
|
||||
}
|
||||
if stroke.is_none() && (fill.is_none() || kind == ShapeKind::Line) {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill: fill.filter(|_| kind != ShapeKind::Line),
|
||||
stroke,
|
||||
stroke_width,
|
||||
float,
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra,
|
||||
})
|
||||
}
|
||||
|
||||
/// An OLE object (`w:object`, ECMA-376 §17.3.3.19): shown as its picture (VML, or a DrawingML
|
||||
/// picture in `w:drawing`), and kept whole so saving writes the object back.
|
||||
pub(super) fn read_object(&mut self, sc: &mut StoryCtx, obj: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let mut o = match self.read_vml(sc, obj, rels) {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
let d = obj.child("w:drawing")?;
|
||||
self.read_drawing(sc, d, rels)?
|
||||
}
|
||||
};
|
||||
if let InlineObject::Image { ole, .. } = &mut o {
|
||||
*ole = self.keep_embedded(obj, rels, &[]);
|
||||
}
|
||||
Some(o)
|
||||
}
|
||||
|
||||
/// A Word signature line (Insert › Signature Line): a `w:pict` whose one VML picture carries
|
||||
/// an `o:signatureline` (the suggested signer, title, e-mail and instructions). Shown as its
|
||||
/// picture, and kept whole like an OLE object so saving writes the signature line back,
|
||||
/// still signable in Word, not a plain picture. `None` for any other `w:pict`.
|
||||
pub(super) fn read_signature_line(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let mut drawings = pict.els().filter(|e| e.name.starts_with("v:") && e.local() != "shapetype");
|
||||
let shape = drawings.next().filter(|s| s.name == "v:shape")?;
|
||||
if drawings.next().is_some() || shape.child("o:signatureline").is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut o = self.read_vml_shape(sc, pict, shape, rels, None)?;
|
||||
if let InlineObject::Image { ole, .. } = &mut o {
|
||||
*ole = self.keep_embedded(pict, rels, &[]);
|
||||
}
|
||||
Some(o)
|
||||
}
|
||||
}
|
||||
|
||||
/// The DrawingML text warps of VML's WordArt shapes, `o:spt` 136 (plain text) to 175 (can down), in
|
||||
/// order: the same shapes under their DrawingML names (`ST_TextShapeType`).
|
||||
const VML_TEXT_WARPS: [&str; 40] = [
|
||||
"textPlain",
|
||||
"textStop",
|
||||
"textTriangle",
|
||||
"textTriangleInverted",
|
||||
"textChevron",
|
||||
"textChevronInverted",
|
||||
"textRingInside",
|
||||
"textRingOutside",
|
||||
"textArchUp",
|
||||
"textArchDown",
|
||||
"textCircle",
|
||||
"textButton",
|
||||
"textArchUpPour",
|
||||
"textArchDownPour",
|
||||
"textCirclePour",
|
||||
"textButtonPour",
|
||||
"textCurveUp",
|
||||
"textCurveDown",
|
||||
"textCascadeUp",
|
||||
"textCascadeDown",
|
||||
"textWave1",
|
||||
"textWave2",
|
||||
"textDoubleWave1",
|
||||
"textWave4",
|
||||
"textInflate",
|
||||
"textDeflate",
|
||||
"textInflateBottom",
|
||||
"textDeflateBottom",
|
||||
"textInflateTop",
|
||||
"textDeflateTop",
|
||||
"textDeflateInflate",
|
||||
"textDeflateInflateDeflate",
|
||||
"textFadeRight",
|
||||
"textFadeLeft",
|
||||
"textFadeUp",
|
||||
"textFadeDown",
|
||||
"textSlantUp",
|
||||
"textSlantDown",
|
||||
"textCanUp",
|
||||
"textCanDown",
|
||||
];
|
||||
|
||||
/// A VML shape's preset type (`o:spt`): its own, its shapetype's, or the number in Word's
|
||||
/// shapetype id `_x0000_tNN` (`type_id`), Word writing each shapetype only at its first use.
|
||||
fn vml_spt(shape: &El, shapetype: Option<&El>, type_id: Option<&str>) -> Option<u32> {
|
||||
let num = |v: &str| v.trim().parse::<u32>().ok();
|
||||
shape
|
||||
.attr("o:spt")
|
||||
.and_then(num)
|
||||
.or_else(|| shapetype.and_then(|t| t.attr("o:spt")).and_then(num))
|
||||
.or_else(|| type_id?.strip_prefix("_x0000_t").and_then(num))
|
||||
}
|
||||
|
||||
/// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins).
|
||||
/// The first `v:textbox` under `e` holding text, with its `w:txbxContent` (depth-first, bounded
|
||||
/// like `El::find`): in a group, a member's margins go with that member's text.
|
||||
/// A VML `coordsize="w,h"` or `coordorigin="x,y"` pair (ECMA-376 Part 4 §14.1.2): the member
|
||||
/// space of a group, in its own unitless coordinates.
|
||||
fn vml_coordpair(e: &El, name: &str) -> Option<(f32, f32)> {
|
||||
let v = e.attr(name)?;
|
||||
let (x, y) = v.split_once(',')?;
|
||||
let n = |s: &str| measure(s.trim(), 1.0).map(wordcraft_geom::finite);
|
||||
Some((n(x)?, n(y)?))
|
||||
}
|
||||
|
||||
fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
e.els().find_map(|c| match c.child("w:txbxContent") {
|
||||
Some(t) if c.name == "v:textbox" => Some((c, t)),
|
||||
_ => vml_textbox(c, depth + 1),
|
||||
})
|
||||
}
|
||||
|
||||
fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
|
||||
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
|
||||
}
|
||||
|
||||
/// A `v:textpath` style value with one pair of surrounding quotes removed and the writer's CSS
|
||||
/// escapes mapped back: `\` + up to six hex digits + one optional space is that character, and
|
||||
/// `\x` is `x` (#3994; `write::watermark::run` writes them). Only the surrounding pair goes, so a
|
||||
/// font name that starts or ends with an apostrophe keeps it. Word writes font names raw, and a
|
||||
/// lone trailing `\` none of CSS would accept stays literal.
|
||||
fn css_string(v: &str) -> String {
|
||||
let v = v.trim();
|
||||
let v = ['"', '\''].into_iter().find_map(|q| v.strip_prefix(q)?.strip_suffix(q)).unwrap_or(v).trim();
|
||||
let mut out = String::with_capacity(v.len());
|
||||
let mut chars = v.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let mut hex = String::new();
|
||||
while hex.len() < 6 {
|
||||
match chars.clone().next() {
|
||||
Some(h) if h.is_ascii_hexdigit() => {
|
||||
hex.push(h);
|
||||
chars.next();
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if hex.is_empty() {
|
||||
match chars.next() {
|
||||
Some(e) => out.push(e),
|
||||
None => out.push('\\'),
|
||||
}
|
||||
} else {
|
||||
if let Some(d) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
out.push(d);
|
||||
}
|
||||
// One whitespace after the escape is its terminator, not part of the value.
|
||||
if chars.clone().next().is_some_and(char::is_whitespace) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Placement of a VML shape (ECMA-376 Part 4, VML): `position:absolute` in its style makes it
|
||||
/// float, offset by `left`/`margin-left` and `top`/`margin-top` from the area named by
|
||||
/// `mso-position-horizontal-relative` / `mso-position-vertical-relative` (unless aligned with
|
||||
/// `mso-position-horizontal` / `mso-position-vertical`), wrapped as its `w10:wrap` says. Without
|
||||
/// wrapping (`none`, or no `w10:wrap`) it's in front of the text, or behind it at a negative
|
||||
/// `z-index`. Anything else stays inline.
|
||||
fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float {
|
||||
let get = |k: &str| vml_style(style, k);
|
||||
let hidden = get("visibility").is_some_and(|v| v.eq_ignore_ascii_case("hidden"));
|
||||
if !get("position").is_some_and(|p| p.eq_ignore_ascii_case("absolute")) {
|
||||
// Inline: only the turn (an OLE object turned in WordCraft is saved so, #319).
|
||||
let mut f = Float { hidden, ..Float::default() };
|
||||
f.set_spin(vml_spin(style));
|
||||
return f;
|
||||
}
|
||||
let off = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0);
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let behind = get("z-index").and_then(int).is_some_and(|z| z < 0);
|
||||
let wrap_el = shape.child("w10:wrap");
|
||||
let wrap_type = wrap_el.and_then(|w| w.attr("type")).unwrap_or("none");
|
||||
let wrap = match wrap_type {
|
||||
t if t.eq_ignore_ascii_case("square") => Wrap::Square,
|
||||
t if t.eq_ignore_ascii_case("tight") => Wrap::Tight,
|
||||
t if t.eq_ignore_ascii_case("through") => Wrap::Through,
|
||||
t if t.eq_ignore_ascii_case("topAndBottom") || t.eq_ignore_ascii_case("top-and-bottom") => Wrap::TopAndBottom,
|
||||
_ if behind => Wrap::BehindText,
|
||||
_ => Wrap::InFrontOfText,
|
||||
};
|
||||
let wrap_text = wrap_el.and_then(|w| w.attr("side")).and_then(wordcraft_doc::para::WrapText::from_ooxml).unwrap_or_default();
|
||||
let h_rel_str = get("mso-position-horizontal-relative").unwrap_or("text");
|
||||
let h_rel = match h_rel_str {
|
||||
s if s.eq_ignore_ascii_case("page") => Anchor::Page,
|
||||
s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
|
||||
s if s.eq_ignore_ascii_case("char") => Anchor::Character,
|
||||
s if s.eq_ignore_ascii_case("left-margin-area") => Anchor::LeftMargin,
|
||||
s if s.eq_ignore_ascii_case("right-margin-area") => Anchor::RightMargin,
|
||||
s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
|
||||
s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin,
|
||||
_ => Anchor::Column,
|
||||
};
|
||||
let v_rel_str = get("mso-position-vertical-relative").unwrap_or("text");
|
||||
let v_rel = match v_rel_str {
|
||||
s if s.eq_ignore_ascii_case("page") => Anchor::Page,
|
||||
s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
|
||||
s if s.eq_ignore_ascii_case("line") => Anchor::Line,
|
||||
s if s.eq_ignore_ascii_case("top-margin-area") => Anchor::TopMargin,
|
||||
s if s.eq_ignore_ascii_case("bottom-margin-area") => Anchor::BottomMargin,
|
||||
s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
|
||||
s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin,
|
||||
_ => Anchor::Paragraph,
|
||||
};
|
||||
let dist = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 1584.0);
|
||||
let mut f = Float {
|
||||
wrap,
|
||||
wrap_text,
|
||||
h_rel,
|
||||
v_rel,
|
||||
x: (off("left") + off("margin-left")).clamp(-max, max),
|
||||
y: (off("top") + off("margin-top")).clamp(-max, max),
|
||||
h_align: get("mso-position-horizontal").and_then(FloatAlign::from_ooxml),
|
||||
v_align: get("mso-position-vertical").and_then(FloatAlign::from_ooxml),
|
||||
dist: dist("mso-wrap-distance-left").max(dist("mso-wrap-distance-right")),
|
||||
dist_top: dist("mso-wrap-distance-top"),
|
||||
dist_bottom: dist("mso-wrap-distance-bottom"),
|
||||
hidden,
|
||||
// Word for Mac draws a VML shape anchored in a header table cell on the page whatever
|
||||
// `o:allowincell` says (user-checked), as for Word's picture and text watermarks.
|
||||
outside_cell: in_hf,
|
||||
..Float::default()
|
||||
};
|
||||
f.set_spin(vml_spin(style));
|
||||
f
|
||||
}
|
||||
|
||||
/// Word's text watermark (Design › Watermark): a VML shape in a header whose `v:textpath` draws
|
||||
/// its `string` as text (ECMA-376 Part 4, VML `textpath`), in the textpath's `font-family`, filled
|
||||
/// with the shape's `fillcolor`, semitransparent when its `v:fill` has an `opacity` below 1, and
|
||||
/// diagonal when the shape is turned. Only Word's watermark shape counts (its `id`), or text
|
||||
/// placed absolutely and centred both ways; other header WordArt (a logo or a title) is None.
|
||||
fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
|
||||
let tp = shape.child("v:textpath")?;
|
||||
if tp.attr("on").is_some_and(|v| matches!(v, "f" | "false" | "0")) {
|
||||
return None;
|
||||
}
|
||||
let style = shape.attr("style").unwrap_or("");
|
||||
let centred = |k: &str| vml_style(style, k).is_some_and(|v| v.eq_ignore_ascii_case("center"));
|
||||
let word = shape.attr("id").is_some_and(|id| id.starts_with("PowerPlusWaterMarkObject"));
|
||||
let placed = vml_style(style, "position").is_some_and(|p| p.eq_ignore_ascii_case("absolute"))
|
||||
&& centred("mso-position-horizontal")
|
||||
&& centred("mso-position-vertical");
|
||||
if !word && !placed {
|
||||
return None;
|
||||
}
|
||||
let text: String = tp.attr("string")?.chars().take(200).collect();
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let default = wordcraft_doc::Watermark::default();
|
||||
let font = tp
|
||||
.attr("style")
|
||||
.and_then(|s| vml_style(s, "font-family"))
|
||||
.map(|f| css_string(f).chars().take(100).collect::<String>())
|
||||
.filter(|f| !f.is_empty())
|
||||
.unwrap_or(default.font);
|
||||
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
|
||||
let opacity = shape.child("v:fill").and_then(|f| f.attr("opacity")).and_then(vml_opacity).unwrap_or(1.0);
|
||||
// The box the text is stretched to (`fitshape`).
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(4000.0));
|
||||
let size = len("width").zip(len("height")).map(|(w, h)| [w, h]);
|
||||
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0, size })
|
||||
}
|
||||
|
||||
/// A VML `opacity`, 0..=1.
|
||||
fn vml_opacity(v: &str) -> Option<f32> {
|
||||
vml_fraction(v).map(|f| f.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x`
|
||||
/// and/or `y`.
|
||||
fn vml_spin(style: &str) -> wordcraft_geom::Spin {
|
||||
let get = |k: &str| vml_style(style, k);
|
||||
let rot = get("rotation").and_then(|v| match v.strip_suffix("fd") {
|
||||
Some(fd) => fd.trim().parse::<f32>().ok().map(|f| f / 65_536.0),
|
||||
None => v.trim().parse::<f32>().ok(),
|
||||
});
|
||||
let flip = get("flip").unwrap_or("");
|
||||
wordcraft_geom::Spin::new(rot.unwrap_or(0.0), flip.contains('x'), flip.contains('y'))
|
||||
}
|
||||
Reference in New Issue
Block a user